mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 23:25:20 +00:00
712 lines
28 KiB
Swift
712 lines
28 KiB
Swift
import BitFoundation
|
|
import BitLogger
|
|
import Foundation
|
|
|
|
#if os(iOS)
|
|
import UIKit
|
|
#endif
|
|
|
|
struct LegacyPrivateMediaConsentRequest: Identifiable, Equatable {
|
|
let id: UUID
|
|
let peerID: PeerID
|
|
let peerName: String
|
|
let transferId: String
|
|
let messageID: String
|
|
}
|
|
|
|
struct PendingLegacyPrivateMediaConsent {
|
|
let request: LegacyPrivateMediaConsentRequest
|
|
let completion: @MainActor (Bool) -> Void
|
|
}
|
|
|
|
/// The narrow surface `ChatMediaTransferCoordinator` needs from its owner.
|
|
///
|
|
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
|
|
/// minimal context it actually uses instead of holding an `unowned` back-ref
|
|
/// to the whole `ChatViewModel`. This keeps the coordinator independently
|
|
/// testable (see `ChatMediaTransferCoordinatorContextTests`) and makes its
|
|
/// true dependencies explicit.
|
|
@MainActor
|
|
protocol ChatMediaTransferContext: AnyObject {
|
|
// MARK: Composition state
|
|
var canSendMediaInCurrentContext: Bool { get }
|
|
var selectedPrivateChatPeer: PeerID? { get }
|
|
var nickname: String { get }
|
|
var myPeerID: PeerID { get }
|
|
var activeChannel: ChannelID { get }
|
|
func nicknameForPeer(_ peerID: PeerID) -> String
|
|
func currentPublicSender() -> (name: String, peerID: PeerID)
|
|
|
|
// MARK: Message state
|
|
/// Appends a private message via the single-writer store intent.
|
|
@discardableResult
|
|
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool
|
|
/// Appends a public message via the single-writer store intent
|
|
/// (immediate: outgoing media placeholders must render without batching).
|
|
@discardableResult
|
|
func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool
|
|
func removeMessage(withID messageID: String, cleanupFile: Bool)
|
|
func addSystemMessage(_ content: String)
|
|
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
|
|
func notifyUIChanged()
|
|
|
|
// MARK: Delivery status & dedup
|
|
func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus)
|
|
func normalizedContentKey(_ content: String) -> String
|
|
func recordContentKey(_ key: String, timestamp: Date)
|
|
|
|
// MARK: Mesh file transfer
|
|
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy
|
|
func resolvePrivateMediaSendPolicy(
|
|
to peerID: PeerID,
|
|
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
|
)
|
|
func requestLegacyPrivateMediaConsent(
|
|
for peerID: PeerID,
|
|
transferId: String,
|
|
messageID: String,
|
|
completion: @escaping @MainActor (Bool) -> Void
|
|
)
|
|
func cancelLegacyPrivateMediaConsent(transferId: String, messageID: String)
|
|
func sendFilePrivate(
|
|
_ packet: BitchatFilePacket,
|
|
to peerID: PeerID,
|
|
transferId: String,
|
|
allowLegacyFallback: Bool
|
|
)
|
|
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
|
|
func cancelTransfer(_ transferId: String)
|
|
}
|
|
|
|
extension ChatViewModel: ChatMediaTransferContext {
|
|
// `canSendMediaInCurrentContext`, `selectedPrivateChatPeer`, `nickname`,
|
|
// `myPeerID`, `activeChannel`, `nicknameForPeer(_:)`,
|
|
// `currentPublicSender()`,
|
|
// `appendPublicMessage(_:to:)`, `removeMessage(withID:cleanupFile:)`,
|
|
// `addSystemMessage(_:)`, `notifyUIChanged()`,
|
|
// `updateMessageDeliveryStatus(_:status:)`, `normalizedContentKey(_:)`,
|
|
// and `recordContentKey(_:timestamp:)` are shared requirements with the
|
|
// other contexts or satisfied by existing `ChatViewModel` members. The
|
|
// members below flatten mesh service accesses.
|
|
|
|
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
|
|
meshService.privateMediaSendPolicy(to: peerID)
|
|
}
|
|
|
|
func resolvePrivateMediaSendPolicy(
|
|
to peerID: PeerID,
|
|
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
|
) {
|
|
meshService.resolvePrivateMediaSendPolicy(to: peerID, completion: completion)
|
|
}
|
|
|
|
func requestLegacyPrivateMediaConsent(
|
|
for peerID: PeerID,
|
|
transferId: String,
|
|
messageID: String,
|
|
completion: @escaping @MainActor (Bool) -> Void
|
|
) {
|
|
enqueueLegacyPrivateMediaConsent(
|
|
for: peerID,
|
|
transferId: transferId,
|
|
messageID: messageID,
|
|
completion: completion
|
|
)
|
|
}
|
|
|
|
func cancelLegacyPrivateMediaConsent(transferId: String, messageID: String) {
|
|
invalidateLegacyPrivateMediaConsent(
|
|
transferId: transferId,
|
|
messageID: messageID
|
|
)
|
|
}
|
|
|
|
func sendFilePrivate(
|
|
_ packet: BitchatFilePacket,
|
|
to peerID: PeerID,
|
|
transferId: String,
|
|
allowLegacyFallback: Bool
|
|
) {
|
|
meshService.sendFilePrivate(
|
|
packet,
|
|
to: peerID,
|
|
transferId: transferId,
|
|
allowLegacyFallback: allowLegacyFallback
|
|
)
|
|
}
|
|
|
|
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
|
|
meshService.sendFileBroadcast(packet, transferId: transferId)
|
|
}
|
|
|
|
func cancelTransfer(_ transferId: String) {
|
|
meshService.cancelTransfer(transferId)
|
|
}
|
|
}
|
|
|
|
/// Synchronous boundary between detached image writers and panic deletion.
|
|
///
|
|
/// Invalidation closes admission before waiting for writers that already
|
|
/// entered. Those writers never need the main actor while inside the boundary,
|
|
/// so a synchronous panic transaction can safely join them and then delete
|
|
/// every output before reporting completion.
|
|
private final class ImagePreparationBarrier: @unchecked Sendable {
|
|
private let condition = NSCondition()
|
|
private var generation: UInt64 = 0
|
|
private var activeOperations = 0
|
|
|
|
var currentGeneration: UInt64 {
|
|
condition.lock()
|
|
defer { condition.unlock() }
|
|
return generation
|
|
}
|
|
|
|
func isCurrent(_ candidate: UInt64) -> Bool {
|
|
condition.lock()
|
|
defer { condition.unlock() }
|
|
return generation == candidate
|
|
}
|
|
|
|
func performIfCurrent<T>(
|
|
generation candidate: UInt64,
|
|
operation: () throws -> T
|
|
) rethrows -> T? {
|
|
condition.lock()
|
|
guard generation == candidate else {
|
|
condition.unlock()
|
|
return nil
|
|
}
|
|
activeOperations += 1
|
|
condition.unlock()
|
|
|
|
defer {
|
|
condition.lock()
|
|
activeOperations -= 1
|
|
if activeOperations == 0 {
|
|
condition.broadcast()
|
|
}
|
|
condition.unlock()
|
|
}
|
|
return try operation()
|
|
}
|
|
|
|
func invalidateAndWait() {
|
|
condition.lock()
|
|
generation &+= 1
|
|
while activeOperations > 0 {
|
|
condition.wait()
|
|
}
|
|
condition.unlock()
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class ChatMediaTransferCoordinator {
|
|
private unowned let context: any ChatMediaTransferContext
|
|
private let prepareImagePacket: @Sendable (URL) throws -> ChatPreparedImage
|
|
private let imagePreparationBarrier = ImagePreparationBarrier()
|
|
private let prepareVoiceNotePacket: @Sendable (URL) throws -> BitchatFilePacket
|
|
|
|
private(set) var transferIdToMessageIDs: [String: [String]] = [:]
|
|
private(set) var messageIDToTransferId: [String: String] = [:]
|
|
|
|
init(
|
|
context: any ChatMediaTransferContext,
|
|
prepareImagePacket: @escaping @Sendable (URL) throws -> ChatPreparedImage = {
|
|
try ChatMediaPreparation.prepareImagePacket(from: $0)
|
|
},
|
|
prepareVoiceNotePacket: @escaping @Sendable (URL) throws -> BitchatFilePacket = {
|
|
try ChatMediaPreparation.prepareVoiceNotePacket(at: $0)
|
|
}
|
|
) {
|
|
self.context = context
|
|
self.prepareImagePacket = prepareImagePacket
|
|
self.prepareVoiceNotePacket = prepareVoiceNotePacket
|
|
}
|
|
|
|
func sendVoiceNote(at url: URL) {
|
|
guard context.canSendMediaInCurrentContext else {
|
|
SecureLogger.info("Voice note blocked outside mesh/private context", category: .session)
|
|
try? FileManager.default.removeItem(at: url)
|
|
context.addSystemMessage("Voice notes are only available in mesh chats.")
|
|
return
|
|
}
|
|
|
|
let targetPeer = context.selectedPrivateChatPeer
|
|
let message = enqueueMediaMessage(
|
|
content: "\(MimeType.Category.audio.messagePrefix)\(url.lastPathComponent)",
|
|
targetPeer: targetPeer
|
|
)
|
|
let messageID = message.id
|
|
let transferId = makeTransferID(messageID: messageID)
|
|
// Own the transfer before detached preparation begins. Cancel/delete
|
|
// must be able to invalidate this exact invocation even while file I/O
|
|
// is still running off the main actor.
|
|
registerTransfer(transferId: transferId, messageID: messageID)
|
|
let prepareVoiceNotePacket = self.prepareVoiceNotePacket
|
|
let barrier = imagePreparationBarrier
|
|
let generation = barrier.currentGeneration
|
|
|
|
Task.detached(priority: .userInitiated) { [weak self, barrier] in
|
|
do {
|
|
let packet = try prepareVoiceNotePacket(url)
|
|
|
|
await MainActor.run { [weak self, barrier] in
|
|
guard let self,
|
|
barrier.isCurrent(generation),
|
|
self.isRegisteredTransfer(transferId, messageID: messageID) else {
|
|
return
|
|
}
|
|
if let peerID = targetPeer {
|
|
self.beginPrivateMediaSend(
|
|
packet,
|
|
to: peerID,
|
|
transferId: transferId,
|
|
messageID: messageID
|
|
)
|
|
} else {
|
|
self.context.sendFileBroadcast(packet, transferId: transferId)
|
|
}
|
|
}
|
|
} catch ChatMediaPreparationError.voiceNoteTooLarge(let size) {
|
|
SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session)
|
|
try? FileManager.default.removeItem(at: url)
|
|
await MainActor.run { [weak self, barrier] in
|
|
guard let self,
|
|
barrier.isCurrent(generation),
|
|
self.isRegisteredTransfer(transferId, messageID: messageID) else {
|
|
return
|
|
}
|
|
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", comment: "Failure reason shown when a voice note exceeds the size limit"))
|
|
}
|
|
} catch {
|
|
SecureLogger.error("Voice note send failed: \(error)", category: .session)
|
|
await MainActor.run { [weak self, barrier] in
|
|
guard let self,
|
|
barrier.isCurrent(generation),
|
|
self.isRegisteredTransfer(transferId, messageID: messageID) else {
|
|
return
|
|
}
|
|
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", comment: "Failure reason shown when a voice note could not be sent"))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#if os(iOS)
|
|
func processThenSendImage(_ image: UIImage?) {
|
|
guard let image else { return }
|
|
let generation = imagePreparationBarrier.currentGeneration
|
|
let barrier = imagePreparationBarrier
|
|
Task.detached(priority: .userInitiated) { [weak self, barrier] in
|
|
do {
|
|
guard let processedURL = try barrier.performIfCurrent(
|
|
generation: generation,
|
|
operation: {
|
|
try ImageUtils.processImage(image)
|
|
}
|
|
) else {
|
|
return
|
|
}
|
|
await MainActor.run { [weak self, barrier] in
|
|
guard let self,
|
|
barrier.isCurrent(generation) else {
|
|
try? FileManager.default.removeItem(at: processedURL)
|
|
return
|
|
}
|
|
self.sendImage(from: processedURL)
|
|
}
|
|
} catch {
|
|
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
|
}
|
|
}
|
|
}
|
|
#elseif os(macOS)
|
|
func processThenSendImage(from url: URL?) {
|
|
guard let url else { return }
|
|
let generation = imagePreparationBarrier.currentGeneration
|
|
let barrier = imagePreparationBarrier
|
|
Task.detached(priority: .userInitiated) { [weak self, barrier] in
|
|
do {
|
|
guard let processedURL = try barrier.performIfCurrent(
|
|
generation: generation,
|
|
operation: {
|
|
try ImageUtils.processImage(at: url)
|
|
}
|
|
) else {
|
|
return
|
|
}
|
|
await MainActor.run { [weak self, barrier] in
|
|
guard let self,
|
|
barrier.isCurrent(generation) else {
|
|
try? FileManager.default.removeItem(at: processedURL)
|
|
return
|
|
}
|
|
self.sendImage(from: processedURL)
|
|
}
|
|
} catch {
|
|
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
func sendImage(from sourceURL: URL, cleanup: (() -> Void)? = nil) {
|
|
guard context.canSendMediaInCurrentContext else {
|
|
SecureLogger.info("Image send blocked outside mesh/private context", category: .session)
|
|
cleanup?()
|
|
context.addSystemMessage("Images are only available in mesh chats.")
|
|
return
|
|
}
|
|
|
|
let targetPeer = context.selectedPrivateChatPeer
|
|
let generation = imagePreparationBarrier.currentGeneration
|
|
|
|
do {
|
|
try ImageUtils.validateImageSource(at: sourceURL)
|
|
} catch {
|
|
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
|
|
context.addSystemMessage("Failed to prepare image for sending.")
|
|
return
|
|
}
|
|
|
|
let prepareImagePacket = self.prepareImagePacket
|
|
let barrier = imagePreparationBarrier
|
|
Task.detached(priority: .userInitiated) { [weak self, barrier] in
|
|
do {
|
|
guard let prepared = try barrier.performIfCurrent(
|
|
generation: generation,
|
|
operation: {
|
|
try prepareImagePacket(sourceURL)
|
|
}
|
|
) else {
|
|
return
|
|
}
|
|
|
|
await MainActor.run { [weak self, barrier] in
|
|
guard let self,
|
|
barrier.isCurrent(generation) else {
|
|
try? FileManager.default.removeItem(at: prepared.outputURL)
|
|
return
|
|
}
|
|
let message = self.enqueueMediaMessage(
|
|
content: "\(MimeType.Category.image.messagePrefix)\(prepared.outputURL.lastPathComponent)",
|
|
targetPeer: targetPeer
|
|
)
|
|
let messageID = message.id
|
|
let transferId = self.makeTransferID(messageID: messageID)
|
|
self.registerTransfer(transferId: transferId, messageID: messageID)
|
|
if let peerID = targetPeer {
|
|
self.beginPrivateMediaSend(
|
|
prepared.packet,
|
|
to: peerID,
|
|
transferId: transferId,
|
|
messageID: messageID
|
|
)
|
|
} else {
|
|
self.context.sendFileBroadcast(prepared.packet, transferId: transferId)
|
|
}
|
|
}
|
|
} catch ChatMediaPreparationError.imageTooLarge(let size) {
|
|
SecureLogger.warning("Processed image exceeds size limit (\(size) bytes)", category: .session)
|
|
await MainActor.run { [weak self, barrier] in
|
|
guard let self,
|
|
barrier.isCurrent(generation) else {
|
|
return
|
|
}
|
|
self.context.addSystemMessage("Image is too large to send.")
|
|
}
|
|
} catch {
|
|
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
|
|
await MainActor.run { [weak self, barrier] in
|
|
guard let self,
|
|
barrier.isCurrent(generation) else {
|
|
return
|
|
}
|
|
self.context.addSystemMessage("Failed to prepare image for sending.")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func enqueueMediaMessage(content: String, targetPeer: PeerID?) -> BitchatMessage {
|
|
let timestamp = Date()
|
|
let message: BitchatMessage
|
|
|
|
if let peerID = targetPeer {
|
|
message = BitchatMessage(
|
|
sender: context.nickname,
|
|
content: content,
|
|
timestamp: timestamp,
|
|
isRelay: false,
|
|
originalSender: nil,
|
|
isPrivate: true,
|
|
recipientNickname: context.nicknameForPeer(peerID),
|
|
senderPeerID: context.myPeerID,
|
|
deliveryStatus: .sending
|
|
)
|
|
context.appendPrivateMessage(message, to: peerID)
|
|
} else {
|
|
let (displayName, senderPeerID) = context.currentPublicSender()
|
|
message = BitchatMessage(
|
|
sender: displayName,
|
|
content: content,
|
|
timestamp: timestamp,
|
|
isRelay: false,
|
|
originalSender: nil,
|
|
isPrivate: false,
|
|
recipientNickname: nil,
|
|
senderPeerID: senderPeerID,
|
|
deliveryStatus: .sending
|
|
)
|
|
context.appendPublicMessage(message, to: ConversationID(channelID: context.activeChannel))
|
|
}
|
|
|
|
let key = context.normalizedContentKey(message.content)
|
|
context.recordContentKey(key, timestamp: timestamp)
|
|
context.notifyUIChanged()
|
|
return message
|
|
}
|
|
|
|
private func beginPrivateMediaSend(
|
|
_ packet: BitchatFilePacket,
|
|
to peerID: PeerID,
|
|
transferId: String,
|
|
messageID: String
|
|
) {
|
|
continuePrivateMediaSend(
|
|
packet,
|
|
to: peerID,
|
|
transferId: transferId,
|
|
messageID: messageID,
|
|
policy: context.privateMediaSendPolicy(to: peerID)
|
|
)
|
|
}
|
|
|
|
private func continuePrivateMediaSend(
|
|
_ packet: BitchatFilePacket,
|
|
to peerID: PeerID,
|
|
transferId: String,
|
|
messageID: String,
|
|
policy: PrivateMediaSendPolicy
|
|
) {
|
|
switch policy {
|
|
case .encrypted:
|
|
context.sendFilePrivate(
|
|
packet,
|
|
to: peerID,
|
|
transferId: transferId,
|
|
allowLegacyFallback: false
|
|
)
|
|
|
|
case .awaitingCapabilityProof:
|
|
context.resolvePrivateMediaSendPolicy(to: peerID) { [weak self] resolvedPolicy in
|
|
guard let self,
|
|
self.isRegisteredTransfer(transferId, messageID: messageID) else {
|
|
return
|
|
}
|
|
guard resolvedPolicy != .awaitingCapabilityProof else {
|
|
self.handleMediaSendFailure(
|
|
messageID: messageID,
|
|
reason: String(
|
|
localized: "content.delivery.reason.private_media_capability_unresolved",
|
|
defaultValue: "Could not confirm encrypted media support",
|
|
comment: "Failure reason when private-media capability negotiation did not resolve"
|
|
)
|
|
)
|
|
return
|
|
}
|
|
self.continuePrivateMediaSend(
|
|
packet,
|
|
to: peerID,
|
|
transferId: transferId,
|
|
messageID: messageID,
|
|
policy: resolvedPolicy
|
|
)
|
|
}
|
|
|
|
case .legacyRequiresConsent:
|
|
context.requestLegacyPrivateMediaConsent(
|
|
for: peerID,
|
|
transferId: transferId,
|
|
messageID: messageID
|
|
) { [weak self] approved in
|
|
guard let self else { return }
|
|
// Consent belongs to this exact placeholder/transfer. A late
|
|
// dialog callback after cancel/delete must never resurrect it.
|
|
guard self.messageIDToTransferId[messageID] == transferId,
|
|
self.transferIdToMessageIDs[transferId]?.contains(messageID) == true else {
|
|
return
|
|
}
|
|
guard approved else {
|
|
self.handleMediaSendFailure(
|
|
messageID: messageID,
|
|
reason: String(
|
|
localized: "content.delivery.reason.legacy_media_declined",
|
|
defaultValue: "Not sent without end-to-end encryption",
|
|
comment: "Failure reason after declining the warning for a legacy clear private-media send"
|
|
)
|
|
)
|
|
return
|
|
}
|
|
self.context.sendFilePrivate(
|
|
packet,
|
|
to: peerID,
|
|
transferId: transferId,
|
|
allowLegacyFallback: true
|
|
)
|
|
}
|
|
|
|
case .blockedDowngrade:
|
|
handleMediaSendFailure(
|
|
messageID: messageID,
|
|
reason: String(
|
|
localized: "content.delivery.reason.private_media_downgrade_blocked",
|
|
defaultValue: "Encrypted media required; ask this contact to upgrade",
|
|
comment: "Failure reason when a peer that previously supported encrypted media appears to downgrade"
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
func registerTransfer(transferId: String, messageID: String) {
|
|
transferIdToMessageIDs[transferId, default: []].append(messageID)
|
|
messageIDToTransferId[messageID] = transferId
|
|
}
|
|
|
|
private func isRegisteredTransfer(_ transferId: String, messageID: String) -> Bool {
|
|
messageIDToTransferId[messageID] == transferId
|
|
&& transferIdToMessageIDs[transferId]?.contains(messageID) == true
|
|
}
|
|
|
|
func makeTransferID(messageID: String) -> String {
|
|
"\(messageID)-\(UUID().uuidString)"
|
|
}
|
|
|
|
func clearTransferMapping(for messageID: String) {
|
|
guard let transferId = messageIDToTransferId.removeValue(forKey: messageID) else { return }
|
|
context.cancelLegacyPrivateMediaConsent(
|
|
transferId: transferId,
|
|
messageID: messageID
|
|
)
|
|
guard var queue = transferIdToMessageIDs[transferId] else { return }
|
|
|
|
if !queue.isEmpty {
|
|
if queue.first == messageID {
|
|
queue.removeFirst()
|
|
} else if let index = queue.firstIndex(of: messageID) {
|
|
queue.remove(at: index)
|
|
}
|
|
}
|
|
|
|
transferIdToMessageIDs[transferId] = queue.isEmpty ? nil : queue
|
|
}
|
|
|
|
func handleMediaSendFailure(messageID: String, reason: String) {
|
|
context.updateMessageDeliveryStatus(messageID, status: .failed(reason: reason))
|
|
clearTransferMapping(for: messageID)
|
|
}
|
|
|
|
func handleTransferEvent(_ event: TransferProgressManager.Event) {
|
|
switch event {
|
|
case .started(let id, let total):
|
|
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
|
|
context.updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: 0, total: total))
|
|
case .updated(let id, let sent, let total):
|
|
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
|
|
context.updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: sent, total: total))
|
|
case .completed(let id, _):
|
|
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
|
|
context.updateMessageDeliveryStatus(messageID, status: .sent)
|
|
clearTransferMapping(for: messageID)
|
|
case .cancelled(let id, _, _):
|
|
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
|
|
clearTransferMapping(for: messageID)
|
|
context.removeMessage(withID: messageID, cleanupFile: true)
|
|
case .rejected(let id, let reason):
|
|
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
|
|
handleMediaSendFailure(messageID: messageID, reason: reason)
|
|
}
|
|
}
|
|
|
|
func cleanupLocalFile(forMessage message: BitchatMessage) {
|
|
let categories: [MimeType.Category] = [.audio, .image, .file]
|
|
guard let category = categories.first(where: { message.content.hasPrefix($0.messagePrefix) }),
|
|
let rawFilename = String(message.content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty,
|
|
let base = try? applicationFilesDirectory(),
|
|
let safeFilename = (rawFilename as NSString).lastPathComponent.nilIfEmpty,
|
|
safeFilename != ".",
|
|
safeFilename != ".." else {
|
|
return
|
|
}
|
|
|
|
let subdirs = categories.flatMap { ["\($0.mediaDir)/outgoing", "\($0.mediaDir)/incoming"] }
|
|
for subdir in subdirs {
|
|
let target = base.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(safeFilename)
|
|
guard target.path.hasPrefix(base.path) else { continue }
|
|
|
|
do {
|
|
try FileManager.default.removeItem(at: target)
|
|
} catch CocoaError.fileNoSuchFile {
|
|
continue
|
|
} catch {
|
|
SecureLogger.error("Failed to cleanup \(safeFilename): \(error)", category: .session)
|
|
}
|
|
}
|
|
}
|
|
|
|
func cancelMediaSend(messageID: String) {
|
|
if let transferId = messageIDToTransferId[messageID],
|
|
let active = transferIdToMessageIDs[transferId]?.first,
|
|
active == messageID {
|
|
context.cancelTransfer(transferId)
|
|
}
|
|
clearTransferMapping(for: messageID)
|
|
context.removeMessage(withID: messageID, cleanupFile: true)
|
|
}
|
|
|
|
func deleteMediaMessage(messageID: String) {
|
|
// Delete is also a send cancellation. In particular, an approved
|
|
// legacy-clear send may still be waiting on BLEService.messageQueue;
|
|
// removing only the UI mapping would let that deferred work transmit.
|
|
if let transferId = messageIDToTransferId[messageID],
|
|
transferIdToMessageIDs[transferId]?.first == messageID {
|
|
context.cancelTransfer(transferId)
|
|
}
|
|
clearTransferMapping(for: messageID)
|
|
context.removeMessage(withID: messageID, cleanupFile: true)
|
|
}
|
|
|
|
/// Invalidates detached preparation work and cancels every transfer that
|
|
/// reached the transport. Closing image-preparation admission and joining
|
|
/// active synchronous writers ensures the following panic media deletion
|
|
/// is the last filesystem mutation before the transaction can complete.
|
|
func resetForPanic() {
|
|
imagePreparationBarrier.invalidateAndWait()
|
|
let transferIDs = Set(transferIdToMessageIDs.keys)
|
|
transferIdToMessageIDs.removeAll(keepingCapacity: false)
|
|
messageIDToTransferId.removeAll(keepingCapacity: false)
|
|
for transferID in transferIDs {
|
|
context.cancelTransfer(transferID)
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension ChatMediaTransferCoordinator {
|
|
func applicationFilesDirectory() throws -> URL {
|
|
let base = try FileManager.default.url(
|
|
for: .applicationSupportDirectory,
|
|
in: .userDomainMask,
|
|
appropriateFor: nil,
|
|
create: true
|
|
)
|
|
let filesDirectory = base.appendingPathComponent("files", isDirectory: true)
|
|
try FileManager.default.createDirectory(
|
|
at: filesDirectory,
|
|
withIntermediateDirectories: true,
|
|
attributes: nil
|
|
)
|
|
return filesDirectory
|
|
}
|
|
}
|