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 } struct PrivateMediaReconnectRetryLimits: Equatable { var maxRetainedPackets = 8 var maxRetainedBytes = 4 * 1024 * 1024 var maxRetriesPerMessage = 2 var retentionSeconds: TimeInterval = 120 var maxRetriesPerReconnect = 2 } private struct PrivateMediaReconnectRetryRecord { let messageID: String let peerID: PeerID let packet: BitchatFilePacket var receiptSessionGeneration: UUID var createdAt: Date var retryCount: Int var activeTransferID: String? var retryAfterCompletion: Bool var idleOutcome: PrivateMediaReconnectRetryIdleOutcome var deferredTerminalFailureReason: String? var expiryToken: UUID? var retainedBytes: Int { packet.content.count } } private enum PrivateMediaReconnectRetryIdleOutcome { case none case locallyCompleted case cancelled case rejected(reason: String) } private struct PrivateMediaReconnectRetryCandidate { let messageID: String /// The receipt-capable Noise generation that owned this record when the /// reconnect/authentication event captured it. let receiptSessionGeneration: UUID } /// 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) /// Removes a media bubble with direction-scoped cleanup instead of the /// broad compatibility cleanup path. func removeUntombstonedMediaMessage(withID messageID: String) func removeOutgoingMediaMessage(withID messageID: String) 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 authenticatedPrivateMediaReceiptSessionGeneration(to peerID: PeerID) -> UUID? 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 sendFilePrivateReceiptRetry( _ packet: BitchatFilePacket, to peerID: PeerID, transferId: String ) func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) func cancelTransfer(_ transferId: String) /// Receiver-side stable-ID deletion commit. Implementations must invoke /// completion only after the entire batch is durably tombstoned. func persistDeletedPrivateMedia( messageIDs: [String], completion: @escaping @MainActor (Bool) -> Void ) /// Whether any current private-chat copy of this stable ID came from a /// remote peer and therefore requires a receiver tombstone. func requiresPrivateMediaTombstone(messageID: String) -> Bool } 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 authenticatedPrivateMediaReceiptSessionGeneration( to peerID: PeerID ) -> UUID? { meshService.authenticatedPrivateMediaReceiptSessionGeneration( 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 sendFilePrivateReceiptRetry( _ packet: BitchatFilePacket, to peerID: PeerID, transferId: String ) { meshService.sendFilePrivateReceiptRetry( packet, to: peerID, transferId: transferId ) } func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) { meshService.sendFileBroadcast(packet, transferId: transferId) } func cancelTransfer(_ transferId: String) { meshService.cancelTransfer(transferId) } func removeUntombstonedMediaMessage(withID messageID: String) { let message = conversations.conversationsByID.values.lazy .flatMap(\.messages) .first { $0.id == messageID } if let message { if !isIncomingPrivateMessage(message) { mediaTransferCoordinator.cleanupOutgoingLocalFile( forMessage: message ) } // Legacy/raw incoming media has no durable ID-to-file ownership. // Its basename may already belong to a pending newer arrival, so // leave the payload for bounded quota cleanup rather than unlink // an ambiguous path. } removeMessage(withID: messageID, cleanupFile: false) } func removeOutgoingMediaMessage(withID messageID: String) { let message = conversations.conversationsByID.values.lazy .flatMap(\.messages) .first { $0.id == messageID } if let message { mediaTransferCoordinator.cleanupOutgoingLocalFile( forMessage: message ) } removeMessage(withID: messageID, cleanupFile: false) } func persistDeletedPrivateMedia( messageIDs: [String], completion: @escaping @MainActor (Bool) -> Void ) { guard !messageIDs.isEmpty else { completion(true) return } guard let persistence = meshService as? any PrivateMediaDeletionPersisting else { completion(false) return } let requestedIDs = Set(messageIDs) let incomingPathReferences = Array( conversations.conversationsByID.values .lazy .flatMap(\.messages) .compactMap { message -> ( messageID: String, path: String )? in guard self.isIncomingPrivateMessage(message), let path = self.incomingMediaRelativePath( for: message ) else { return nil } return (message.id, path) } ) let ownerIDsByPath = Dictionary( grouping: incomingPathReferences, by: { $0.path } ).mapValues { Set($0.map(\.messageID)) } let protectedPayloadRelativePaths = Set( ownerIDsByPath.compactMap { path, ownerIDs in ownerIDs.isSubset(of: requestedIDs) ? nil : path } ) var payloadRelativePaths: [String: String] = [:] for reference in incomingPathReferences where requestedIDs.contains(reference.messageID) && ownerIDsByPath[reference.path, default: []] .isSubset(of: requestedIDs) { payloadRelativePaths[reference.messageID] = reference.path } persistence.persistDeletedPrivateMedia( messageIDs: messageIDs, payloadRelativePaths: payloadRelativePaths, protectedPayloadRelativePaths: protectedPayloadRelativePaths, completion: completion ) } func requiresPrivateMediaTombstone(messageID: String) -> Bool { guard PrivateMediaMessageIdentity.isStableID(messageID) else { return false } return privateChats.values.lazy.flatMap { $0 }.contains { message in message.id == messageID && isIncomingPrivateMessage(message) } } private func isIncomingPrivateMessage( _ message: BitchatMessage ) -> Bool { if let senderPeerID = message.senderPeerID { return senderPeerID.toShort() != meshService.myPeerID.toShort() } return message.sender != nickname && !message.sender.hasPrefix(nickname + "#") } private func incomingMediaRelativePath( for message: BitchatMessage ) -> String? { 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 safeFilename = (rawFilename as NSString).lastPathComponent.nilIfEmpty, safeFilename != ".", safeFilename != ".." else { return nil } return "\(category.mediaDir)/incoming/\(safeFilename)" } } @MainActor final class ChatMediaTransferCoordinator { private unowned let context: any ChatMediaTransferContext private let prepareVoiceNotePacket: @Sendable (URL) throws -> BitchatFilePacket private let prepareImagePacket: @Sendable (URL) throws -> ChatPreparedImage private let reconnectRetryLimits: PrivateMediaReconnectRetryLimits private let now: () -> Date private let transferIDFactory: (String) -> String private(set) var transferIdToMessageIDs: [String: [String]] = [:] private(set) var messageIDToTransferId: [String: String] = [:] private var preparationGeneration: UInt64 = 0 private var deletionGeneration: UInt64 = 0 private var reconnectRetryRecords: [ String: PrivateMediaReconnectRetryRecord ] = [:] /// A newly authenticated session supersedes any raw-connect policy /// resolution still in flight for that peer. private var peersResolvingReconnectRetry: [ PeerID: ( id: UUID, replacingActiveTransfer: Bool, candidates: [PrivateMediaReconnectRetryCandidate] ) ] = [:] private var reconnectRetryExpiryTasks: [String: Task] = [:] var retainedReconnectRetryCount: Int { reconnectRetryRecords.count } var retainedReconnectRetryBytes: Int { reconnectRetryRecords.values.reduce(0) { $0 + $1.retainedBytes } } init( context: any ChatMediaTransferContext, prepareVoiceNotePacket: @escaping @Sendable (URL) throws -> BitchatFilePacket = { try ChatMediaPreparation.prepareVoiceNotePacket(at: $0) }, prepareImagePacket: @escaping @Sendable (URL) throws -> ChatPreparedImage = { try ChatMediaPreparation.prepareImagePacket(from: $0) }, reconnectRetryLimits: PrivateMediaReconnectRetryLimits = PrivateMediaReconnectRetryLimits(), now: @escaping () -> Date = Date.init, transferIDFactory: @escaping (String) -> String = { "\($0)-\(UUID().uuidString)" } ) { self.context = context self.prepareVoiceNotePacket = prepareVoiceNotePacket self.prepareImagePacket = prepareImagePacket self.reconnectRetryLimits = reconnectRetryLimits self.now = now self.transferIDFactory = transferIDFactory } 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 privateMessageID = targetPeer.flatMap { peerID in PrivateMediaMessageIdentity.stableID( senderPeerID: context.myPeerID, recipientPeerID: peerID, fileName: url.lastPathComponent ) } let message = enqueueMediaMessage( content: "\(MimeType.Category.audio.messagePrefix)\(url.lastPathComponent)", targetPeer: targetPeer, messageID: privateMessageID ) 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 generation = preparationGeneration Task.detached(priority: .userInitiated) { [weak self] in do { let packet = try prepareVoiceNotePacket(url) await MainActor.run { [weak self] in guard let self, self.preparationGeneration == 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] in guard let self, self.preparationGeneration == 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] in guard let self, self.preparationGeneration == 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 = preparationGeneration Task.detached { [weak self] in do { let processedURL = try ImageUtils.processImage(image) await MainActor.run { [weak self] in guard let self, self.preparationGeneration == 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 = preparationGeneration Task.detached { [weak self] in do { let processedURL = try ImageUtils.processImage(at: url) await MainActor.run { [weak self] in guard let self, self.preparationGeneration == 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 = preparationGeneration 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 Task.detached(priority: .userInitiated) { [weak self] in do { let prepared = try prepareImagePacket(sourceURL) await MainActor.run { [weak self] in guard let self, self.preparationGeneration == generation else { try? FileManager.default.removeItem(at: prepared.outputURL) return } let privateMessageID = targetPeer.flatMap { peerID in PrivateMediaMessageIdentity.stableID( for: prepared.packet, senderPeerID: self.context.myPeerID, recipientPeerID: peerID ) } let message = self.enqueueMediaMessage( content: "\(MimeType.Category.image.messagePrefix)\(prepared.outputURL.lastPathComponent)", targetPeer: targetPeer, messageID: privateMessageID ) 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] in guard let self, self.preparationGeneration == 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] in guard let self, self.preparationGeneration == generation else { return } self.context.addSystemMessage("Failed to prepare image for sending.") } } } } func enqueueMediaMessage( content: String, targetPeer: PeerID?, messageID: String? = nil ) -> BitchatMessage { let timestamp = Date() let message: BitchatMessage if let peerID = targetPeer { message = BitchatMessage( id: messageID, 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: retainForReconnectRetryIfEligible( packet, peerID: peerID, messageID: messageID, activeTransferID: transferId ) 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 { transferIDFactory(messageID) } func clearTransferMapping(for messageID: String) { guard let transferId = messageIDToTransferId[messageID] else { return } clearTransferMapping( transferID: transferId, messageID: messageID, clearCurrentOwner: true ) } private func clearTransferMapping( transferID: String, messageID: String, clearCurrentOwner: Bool ) { if clearCurrentOwner, messageIDToTransferId[messageID] == transferID { messageIDToTransferId.removeValue(forKey: messageID) } 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 } /// Returns the message still owned by this exact transfer. Replacement /// retries can receive late callbacks from the cancelled predecessor; /// those callbacks may clear only their stale queue entry. private func currentMessageID(forTransferID transferID: String) -> String? { guard let messageID = transferIdToMessageIDs[transferID]?.first else { return nil } guard messageIDToTransferId[messageID] == transferID else { clearTransferMapping( transferID: transferID, messageID: messageID, clearCurrentOwner: false ) return nil } return messageID } func handleMediaSendFailure(messageID: String, reason: String) { discardReconnectRetry( messageID: messageID, cancelActiveTransfer: false ) 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 = currentMessageID(forTransferID: id) else { return } if isReconnectRetryTransfer(id, messageID: messageID) { return } context.updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: 0, total: total)) case .updated(let id, let sent, let total): guard let messageID = currentMessageID(forTransferID: id) else { return } if isReconnectRetryTransfer(id, messageID: messageID) { return } context.updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: sent, total: total)) case .completed(let id, _): guard let messageID = currentMessageID(forTransferID: id) else { return } let ownsRetainedRecord = reconnectRetryRecords[messageID]?.activeTransferID == id let retryAfterCompletion = ownsRetainedRecord && reconnectRetryRecords[messageID]?.retryAfterCompletion == true let deferredTerminalReason = ownsRetainedRecord ? reconnectRetryRecords[messageID]? .deferredTerminalFailureReason : nil if ownsRetainedRecord { reconnectRetryRecords[messageID]?.activeTransferID = nil reconnectRetryRecords[messageID]?.retryAfterCompletion = false reconnectRetryRecords[messageID]?.idleOutcome = .locallyCompleted reconnectRetryRecords[messageID]?.createdAt = now() } context.updateMessageDeliveryStatus(messageID, status: .sent) clearTransferMapping(for: messageID) if let deferredTerminalReason { terminalizeReconnectRetry( messageID: messageID, reason: deferredTerminalReason ) } else if retryAfterCompletion { startReconnectRetry(messageID: messageID) } else if ownsRetainedRecord { scheduleReconnectRetryExpiry(messageID: messageID) } case .cancelled(let id, _, _): guard let messageID = currentMessageID(forTransferID: id) else { return } if isRetainedPrivateMediaTransfer(id, messageID: messageID) { finishRetainedTransfer( id, messageID: messageID, outcome: .cancelled, rejectionReason: nil ) return } discardReconnectRetry( messageID: messageID, cancelActiveTransfer: false ) clearTransferMapping(for: messageID) context.removeOutgoingMediaMessage(withID: messageID) case .rejected(let id, let reason): guard let messageID = currentMessageID(forTransferID: id) else { return } if isRetainedPrivateMediaTransfer(id, messageID: messageID) { finishRetainedTransfer( id, messageID: messageID, outcome: .rejected(reason: reason), rejectionReason: reason ) return } handleMediaSendFailure(messageID: messageID, reason: reason) } } func cleanupLocalFile(forMessage message: BitchatMessage) { cleanupLocalFile( forMessage: message, directions: ["outgoing", "incoming"], searchAllCategories: true ) } /// `/clear` may cancel an outgoing message before receiver tombstones are /// committed. Restrict cleanup to that message's outgoing directory so a /// same-name incoming payload cannot be removed prematurely. func cleanupOutgoingLocalFile(forMessage message: BitchatMessage) { cleanupLocalFile( forMessage: message, directions: ["outgoing"], searchAllCategories: false ) } /// Receiver cleanup runs only after any required tombstone commit. Keep it /// scoped to the parsed media category and incoming directory so unrelated /// outgoing or cross-category payloads with the same basename survive. func cleanupIncomingLocalFile(forMessage message: BitchatMessage) { cleanupLocalFile( forMessage: message, directions: ["incoming"], searchAllCategories: false ) } private func cleanupLocalFile( forMessage message: BitchatMessage, directions: [String], searchAllCategories: Bool ) { 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 targetCategories = searchAllCategories ? categories : [category] let subdirs = targetCategories.flatMap { category in directions.map { "\(category.mediaDir)/\($0)" } } for subdir in subdirs { let target = base.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(safeFilename) guard target.path.hasPrefix(base.path) else { continue } guard FileManager.default.fileExists(atPath: target.path) else { continue } guard let values = try? target.resourceValues( forKeys: [.isRegularFileKey] ), values.isRegularFile == true else { SecureLogger.warning( "Refusing to cleanup non-file media target \(safeFilename)", category: .session ) 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) { cancelAllMediaSendOwners(messageID: messageID) context.removeOutgoingMediaMessage(withID: messageID) } /// Lets `/clear` cancel send ownership without implicitly deciding which /// bubbles/files its deletion transaction may remove. func cancelMediaTransferForConversationClear(messageID: String) { cancelAllMediaSendOwners(messageID: messageID) } func deleteMediaMessage(messageID: String) { // Stop every exact sender owner before the durable receiver commit. // Otherwise a retained retry or admitted legacy send could transmit // after the bubble and payload have been deleted. cancelAllMediaSendOwners(messageID: messageID) guard context.requiresPrivateMediaTombstone( messageID: messageID ) else { finishMediaDeletion( messageID: messageID, receiverJournalOwnsPayload: false ) return } let generation = deletionGeneration context.persistDeletedPrivateMedia( messageIDs: [messageID] ) { [weak self] persisted in guard let self, self.deletionGeneration == generation else { return } guard persisted else { SecureLogger.error( "Refusing to delete private media without a durable tombstone id=\(messageID.prefix(12))…", category: .session ) return } self.finishMediaDeletion( messageID: messageID, receiverJournalOwnsPayload: true ) } } private func finishMediaDeletion( messageID: String, receiverJournalOwnsPayload: Bool ) { if receiverJournalOwnsPayload { // The journal already owns the exact path. A basename cleanup here // could delete a different arrival that reused it after unlink. context.removeMessage(withID: messageID, cleanupFile: false) } else { context.removeUntombstonedMediaMessage(withID: messageID) } } private func cancelAllMediaSendOwners(messageID: String) { // This releases the retained packet and expiry/retry owner. When its // exact active transfer still owns the mapping, it cancels that owner // before any deletion persistence or UI mutation can proceed. discardReconnectRetry( messageID: messageID, cancelActiveTransfer: true ) // In particular, an approved legacy send may still be waiting on // BLEService.messageQueue. Its admission must be canceled before the // mapping/consent owner is released. if let transferId = messageIDToTransferId[messageID], transferIdToMessageIDs[transferId]?.first == messageID { context.cancelTransfer(transferId) } clearTransferMapping(for: messageID) } /// A raw link callback can arrive before the replacement Noise session /// proves its capabilities. Resolve against the exact session before /// releasing any retained bytes into a whole-file retry. func peerDidReconnect(_ peerID: PeerID) { resolveReconnectRetries( for: peerID, replacingActiveTransfer: false ) } /// Authentication supersedes a raw-connect resolution that may still /// refer to the cached generation and replaces only stale active sends. func peerDidAuthenticate(_ peerID: PeerID) { resolveReconnectRetries( for: peerID, replacingActiveTransfer: true ) } /// Local fragment completion is not proof that the recipient reconstructed /// the file. Only a remote delivery/read receipt releases retry ownership. func confirmPrivateMediaDelivery(messageID: String) { guard PrivateMediaMessageIdentity.isStableID(messageID) else { return } discardReconnectRetry( messageID: messageID, cancelActiveTransfer: true ) } /// Deterministic clock seam for focused tests. Production records also own /// wall-clock expiry tasks. func _test_expireReconnectRetries() { pruneExpiredReconnectRetries() } /// Invalidates detached preparation work and cancels every transfer that /// reached the transport. Stale tasks check the generation before they /// can recreate a message or send after a panic wipe. func resetForPanic() { preparationGeneration &+= 1 deletionGeneration &+= 1 peersResolvingReconnectRetry.removeAll(keepingCapacity: false) for task in reconnectRetryExpiryTasks.values { task.cancel() } reconnectRetryExpiryTasks.removeAll(keepingCapacity: false) reconnectRetryRecords.removeAll(keepingCapacity: false) let transferIDs = Set(transferIdToMessageIDs.keys) transferIdToMessageIDs.removeAll(keepingCapacity: false) messageIDToTransferId.removeAll(keepingCapacity: false) for transferID in transferIDs { context.cancelTransfer(transferID) } } } private extension ChatMediaTransferCoordinator { func reconnectRetryCandidates( for peerID: PeerID, limit: Int? ) -> [PrivateMediaReconnectRetryCandidate] { let records = reconnectRetryRecords.values .filter { $0.peerID == peerID && $0.retryCount < reconnectRetryLimits.maxRetriesPerMessage } .sorted { if $0.createdAt == $1.createdAt { return $0.messageID < $1.messageID } return $0.createdAt < $1.createdAt } let selected: ArraySlice if let limit { selected = records.prefix(max(0, limit)) } else { selected = records[...] } return selected.map { PrivateMediaReconnectRetryCandidate( messageID: $0.messageID, receiptSessionGeneration: $0.receiptSessionGeneration ) } } func resolveReconnectRetries( for peerID: PeerID, replacingActiveTransfer: Bool ) { pruneExpiredReconnectRetries() let normalizedPeerID = peerID.toShort() let candidates: [PrivateMediaReconnectRetryCandidate] if let pending = peersResolvingReconnectRetry[normalizedPeerID] { // Authentication is the only event that may supersede a raw-link // resolution; duplicate callbacks add no new proof. guard replacingActiveTransfer, !pending.replacingActiveTransfer else { return } candidates = reconnectRetryCandidates( for: normalizedPeerID, limit: nil ) } else { candidates = reconnectRetryCandidates( for: normalizedPeerID, limit: replacingActiveTransfer ? nil : max( 0, reconnectRetryLimits.maxRetriesPerReconnect ) ) } guard !candidates.isEmpty else { return } let resolutionID = UUID() peersResolvingReconnectRetry[normalizedPeerID] = ( id: resolutionID, replacingActiveTransfer: replacingActiveTransfer, candidates: candidates ) context.resolvePrivateMediaSendPolicy( to: normalizedPeerID ) { [weak self] policy in guard let self, let pending = self.peersResolvingReconnectRetry[normalizedPeerID], pending.id == resolutionID else { return } self.peersResolvingReconnectRetry.removeValue( forKey: normalizedPeerID ) guard policy == .encrypted, let provenGeneration = self.context .authenticatedPrivateMediaReceiptSessionGeneration( to: normalizedPeerID ) else { self.terminalizeUnavailableCapabilityProof( pending.candidates, for: normalizedPeerID ) return } self.scheduleReconnectRetries( pending.candidates, for: normalizedPeerID, replacingActiveTransfer: pending.replacingActiveTransfer, provenGeneration: provenGeneration ) } } func retainForReconnectRetryIfEligible( _ packet: BitchatFilePacket, peerID: PeerID, messageID: String, activeTransferID: String ) { let normalizedPeerID = peerID.toShort() guard let receiptSessionGeneration = context .authenticatedPrivateMediaReceiptSessionGeneration( to: normalizedPeerID ), reconnectRetryLimits.maxRetainedPackets > 0, reconnectRetryLimits.maxRetainedBytes > 0, reconnectRetryLimits.maxRetriesPerMessage > 0, packet.content.count <= reconnectRetryLimits.maxRetainedBytes, PrivateMediaMessageIdentity.isStableID(messageID), PrivateMediaMessageIdentity.stableID( for: packet, senderPeerID: context.myPeerID, recipientPeerID: normalizedPeerID ) == messageID else { return } pruneExpiredReconnectRetries() if var existing = reconnectRetryRecords[messageID] { cancelReconnectRetryExpiry(messageID: messageID) existing.receiptSessionGeneration = receiptSessionGeneration existing.createdAt = now() existing.activeTransferID = activeTransferID existing.retryAfterCompletion = false existing.idleOutcome = .none existing.deferredTerminalFailureReason = nil existing.expiryToken = nil reconnectRetryRecords[messageID] = existing return } makeReconnectRetryCapacity(for: packet.content.count) guard reconnectRetryRecords.count < reconnectRetryLimits.maxRetainedPackets, retainedReconnectRetryBytes + packet.content.count <= reconnectRetryLimits.maxRetainedBytes else { SecureLogger.debug( "Private media retry retention full; sending once id=\(messageID.prefix(12))…", category: .session ) return } reconnectRetryRecords[messageID] = PrivateMediaReconnectRetryRecord( messageID: messageID, peerID: normalizedPeerID, packet: packet, receiptSessionGeneration: receiptSessionGeneration, createdAt: now(), retryCount: 0, activeTransferID: activeTransferID, retryAfterCompletion: false, idleOutcome: .none, deferredTerminalFailureReason: nil, expiryToken: nil ) } func terminalizeUnavailableCapabilityProof( _ candidates: [PrivateMediaReconnectRetryCandidate], for peerID: PeerID ) { let reason = privateMediaCapabilityUnresolvedReason for candidate in candidates { guard var record = reconnectRetryRecords[candidate.messageID], record.peerID == peerID, record.receiptSessionGeneration == candidate.receiptSessionGeneration else { continue } if record.activeTransferID != nil { // The original transport owner can still produce a valid // remote receipt. Defer failure until it releases ownership. record.deferredTerminalFailureReason = reason reconnectRetryRecords[candidate.messageID] = record } else { terminalizeReconnectRetry( messageID: candidate.messageID, reason: reason ) } } } func scheduleReconnectRetries( _ candidates: [PrivateMediaReconnectRetryCandidate], for peerID: PeerID, replacingActiveTransfer: Bool, provenGeneration: UUID ) { pruneExpiredReconnectRetries() var scheduledCount = 0 let limit = max( 0, reconnectRetryLimits.maxRetriesPerReconnect ) for candidate in candidates { guard scheduledCount < limit else { break } let messageID = candidate.messageID guard var record = reconnectRetryRecords[messageID], record.peerID == peerID, record.receiptSessionGeneration == candidate.receiptSessionGeneration, record.retryCount < reconnectRetryLimits.maxRetriesPerMessage else { continue } if record.deferredTerminalFailureReason != nil { record.deferredTerminalFailureReason = nil reconnectRetryRecords[messageID] = record } if replacingActiveTransfer, candidate.receiptSessionGeneration == provenGeneration { continue } if record.activeTransferID != nil { if replacingActiveTransfer { if replaceActiveTransferAfterAuthentication( messageID: messageID ) { scheduledCount += 1 } continue } // Arm one retry after the current transfer drains. Duplicate // reconnect callbacks cannot chain more work. if record.retryCount == 0 { record.retryAfterCompletion = true reconnectRetryRecords[messageID] = record scheduledCount += 1 } } else if startReconnectRetry(messageID: messageID) { scheduledCount += 1 } } } @discardableResult func replaceActiveTransferAfterAuthentication( messageID: String ) -> Bool { guard var record = reconnectRetryRecords[messageID], let staleTransferID = record.activeTransferID, record.retryCount < reconnectRetryLimits.maxRetriesPerMessage else { return false } record.activeTransferID = nil record.retryAfterCompletion = false record.createdAt = now() reconnectRetryRecords[messageID] = record if messageIDToTransferId[messageID] == staleTransferID { clearTransferMapping(for: messageID) } context.cancelTransfer(staleTransferID) return startReconnectRetry(messageID: messageID) } @discardableResult func startReconnectRetry(messageID: String) -> Bool { pruneExpiredReconnectRetries() guard var record = reconnectRetryRecords[messageID], record.activeTransferID == nil, record.retryCount < reconnectRetryLimits.maxRetriesPerMessage else { return false } guard context.privateMediaSendPolicy(to: record.peerID) == .encrypted, let receiptSessionGeneration = context .authenticatedPrivateMediaReceiptSessionGeneration( to: record.peerID ) else { terminalizeReconnectRetry( messageID: messageID, reason: privateMediaCapabilityUnresolvedReason ) return false } cancelReconnectRetryExpiry(messageID: messageID) let transferID = makeTransferID(messageID: messageID) record.retryCount += 1 record.receiptSessionGeneration = receiptSessionGeneration record.activeTransferID = transferID record.retryAfterCompletion = false record.idleOutcome = .none record.deferredTerminalFailureReason = nil record.expiryToken = nil reconnectRetryRecords[messageID] = record registerTransfer( transferId: transferID, messageID: messageID ) SecureLogger.debug( "🔄 Retrying private media after reconnect id=\(messageID.prefix(12))… attempt=\(record.retryCount)", category: .session ) context.sendFilePrivateReceiptRetry( record.packet, to: record.peerID, transferId: transferID ) return true } func finishRetainedTransfer( _ transferID: String, messageID: String, outcome: PrivateMediaReconnectRetryIdleOutcome, rejectionReason: String? ) { guard var record = reconnectRetryRecords[messageID], record.activeTransferID == transferID else { return } let retryAfterCompletion = record.retryAfterCompletion let deferredTerminalReason = record.deferredTerminalFailureReason record.activeTransferID = nil record.retryAfterCompletion = false record.idleOutcome = outcome record.createdAt = now() reconnectRetryRecords[messageID] = record clearTransferMapping(for: messageID) if let deferredTerminalReason { terminalizeReconnectRetry( messageID: messageID, reason: deferredTerminalReason ) } else if retryAfterCompletion { startReconnectRetry(messageID: messageID) } else if record.retryCount >= reconnectRetryLimits.maxRetriesPerMessage { terminalizeReconnectRetry( messageID: messageID, reason: rejectionReason ?? privateMediaNotDeliveredReason ) } else { scheduleReconnectRetryExpiry(messageID: messageID) } if let rejectionReason { SecureLogger.debug( "Private media retry rejected id=\(messageID.prefix(12))…: \(rejectionReason)", category: .session ) } } func isReconnectRetryTransfer( _ transferID: String, messageID: String ) -> Bool { guard let record = reconnectRetryRecords[messageID] else { return false } return record.retryCount > 0 && record.activeTransferID == transferID } func isRetainedPrivateMediaTransfer( _ transferID: String, messageID: String ) -> Bool { reconnectRetryRecords[messageID]?.activeTransferID == transferID } func discardReconnectRetry( messageID: String, cancelActiveTransfer: Bool ) { cancelReconnectRetryExpiry(messageID: messageID) guard let record = reconnectRetryRecords.removeValue(forKey: messageID) else { return } guard cancelActiveTransfer, let transferID = record.activeTransferID, messageIDToTransferId[messageID] == transferID else { return } // Release ownership before cancellation so its late callback cannot // remove a remotely confirmed row. clearTransferMapping(for: messageID) context.cancelTransfer(transferID) } func pruneExpiredReconnectRetries() { let current = now() let lifetime = max( 0, reconnectRetryLimits.retentionSeconds ) let expiredMessageIDs: [String] = reconnectRetryRecords.values.compactMap { record in guard record.activeTransferID == nil, current.timeIntervalSince(record.createdAt) >= lifetime else { return nil } return record.messageID } for messageID in expiredMessageIDs { guard let record = reconnectRetryRecords[messageID], record.activeTransferID == nil else { continue } terminalizeReconnectRetry( messageID: messageID, reason: expiryFailureReason(for: record) ) } } func makeReconnectRetryCapacity(for incomingBytes: Int) { while reconnectRetryRecords.count >= reconnectRetryLimits.maxRetainedPackets || retainedReconnectRetryBytes + incomingBytes > reconnectRetryLimits.maxRetainedBytes { guard let victim = reconnectRetryRecords.values .filter({ $0.activeTransferID == nil }) .min(by: { if $0.createdAt == $1.createdAt { return $0.messageID < $1.messageID } return $0.createdAt < $1.createdAt }) else { return } terminalizeReconnectRetry( messageID: victim.messageID, reason: expiryFailureReason(for: victim) ) } } var privateMediaNotDeliveredReason: String { String( localized: "content.delivery.reason.not_delivered", defaultValue: "Not delivered", comment: "Failure reason shown when a private media transfer could not finish" ) } var privateMediaCapabilityUnresolvedReason: String { 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" ) } var privateMediaDeliveryUnconfirmedReason: String { String( localized: "content.delivery.reason.private_media_delivery_unconfirmed", defaultValue: "Delivery could not be confirmed", comment: "Failure reason when private media left this device but no delivery receipt arrived" ) } func expiryFailureReason( for record: PrivateMediaReconnectRetryRecord ) -> String { switch record.idleOutcome { case .locallyCompleted: return privateMediaDeliveryUnconfirmedReason case .rejected(let reason): return reason case .none, .cancelled: return privateMediaNotDeliveredReason } } func terminalizeReconnectRetry( messageID: String, reason: String ) { guard let record = reconnectRetryRecords[messageID], record.activeTransferID == nil else { return } cancelReconnectRetryExpiry(messageID: messageID) guard reconnectRetryRecords.removeValue(forKey: messageID) != nil else { return } context.updateMessageDeliveryStatus( messageID, status: .failed(reason: reason) ) } func scheduleReconnectRetryExpiry(messageID: String) { guard var record = reconnectRetryRecords[messageID], record.activeTransferID == nil else { return } cancelReconnectRetryExpiry(messageID: messageID) let lifetime = max( 0, reconnectRetryLimits.retentionSeconds ) let elapsed = max( 0, now().timeIntervalSince(record.createdAt) ) let delay = max(0, lifetime - elapsed) let token = UUID() record.expiryToken = token reconnectRetryRecords[messageID] = record let nanoseconds = UInt64( min( delay, TimeInterval(UInt64.max) / 1_000_000_000 ) * 1_000_000_000 ) reconnectRetryExpiryTasks[messageID] = Task { @MainActor [weak self] in if nanoseconds > 0 { try? await Task.sleep(nanoseconds: nanoseconds) } guard !Task.isCancelled, let self, let current = self.reconnectRetryRecords[messageID], current.activeTransferID == nil, current.expiryToken == token else { return } self.terminalizeReconnectRetry( messageID: messageID, reason: self.expiryFailureReason(for: current) ) } } func cancelReconnectRetryExpiry(messageID: String) { reconnectRetryExpiryTasks.removeValue( forKey: messageID )?.cancel() if reconnectRetryRecords[messageID]?.expiryToken != nil { reconnectRetryRecords[messageID]?.expiryToken = nil } } 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 } }