From 319e7fcbd0e105f48364604703e4d8e2131313ed Mon Sep 17 00:00:00 2001 From: jack Date: Sat, 25 Jul 2026 17:50:07 +0200 Subject: [PATCH] Retry confirmed private media after reconnect --- bitchat/Services/BLE/BLEService.swift | 107 ++- bitchat/Services/Transport.swift | 23 + .../ViewModels/ChatDeliveryCoordinator.swift | 3 + .../ChatMediaTransferCoordinator.swift | 846 +++++++++++++++++- .../ChatVerificationCoordinator.swift | 10 + bitchat/ViewModels/ChatViewModel.swift | 4 + ...MediaTransferCoordinatorContextTests.swift | 646 ++++++++++++- ...tVerificationCoordinatorContextTests.swift | 5 + .../EndToEnd/PrivateMediaEndToEndTests.swift | 228 +++++ 9 files changed, 1859 insertions(+), 13 deletions(-) diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 334a73d9..3e8f0b2a 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -1119,6 +1119,29 @@ final class BLEService: NSObject { collectionsQueue.sync { peerRegistry.capabilities(for: peerID) } } + func authenticatedPrivateMediaReceiptSessionGeneration( + to peerID: PeerID + ) -> UUID? { + let normalizedPeerID = peerID.toShort() + let currentNoiseGeneration = + noiseService.sessionGeneration(for: normalizedPeerID) + return collectionsQueue.sync { + guard let generation = + privateMediaSessionGenerations[normalizedPeerID], + generation == currentNoiseGeneration, + let authenticated = + authenticatedPeerStates[normalizedPeerID], + authenticated.sessionGeneration == generation, + authenticated.capabilities.contains(.privateMedia), + authenticated.capabilities.contains( + .privateMediaReceipts + ) else { + return nil + } + return generation + } + } + private func privateMediaPolicyFingerprint( for peerID: PeerID, expectedSessionGeneration: UUID? @@ -1603,6 +1626,36 @@ final class BLEService: NSObject { to peerID: PeerID, transferId: String, allowLegacyFallback: Bool + ) { + sendFilePrivate( + filePacket, + to: peerID, + transferId: transferId, + allowLegacyFallback: allowLegacyFallback, + requiresAuthenticatedPrivateMediaReceipts: false + ) + } + + func sendFilePrivateReceiptRetry( + _ filePacket: BitchatFilePacket, + to peerID: PeerID, + transferId: String + ) { + sendFilePrivate( + filePacket, + to: peerID, + transferId: transferId, + allowLegacyFallback: false, + requiresAuthenticatedPrivateMediaReceipts: true + ) + } + + private func sendFilePrivate( + _ filePacket: BitchatFilePacket, + to peerID: PeerID, + transferId: String, + allowLegacyFallback: Bool, + requiresAuthenticatedPrivateMediaReceipts: Bool ) { // Register before enqueueing onto messageQueue. This closes the window // where cancel/delete could run first, observe no scheduler state, and @@ -1715,6 +1768,25 @@ final class BLEService: NSObject { self.privateMediaTransferAdmissions.finish(transferId) return } + if requiresAuthenticatedPrivateMediaReceipts, + self.authenticatedPrivateMediaReceiptSessionGeneration( + to: targetID + ) == nil { + SecureLogger.warning( + "Private media retry blocked without current authenticated receipt support for \(targetID.id.prefix(8))…", + category: .security + ) + TransferProgressManager.shared.rejectBeforeStart( + id: transferId, + 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" + ) + ) + self.privateMediaTransferAdmissions.finish(transferId) + return + } guard let typedPayload = BLENoisePayloadFactory.privateFile(filePacket) else { SecureLogger.error("❌ Failed to encode file packet for private send", category: .session) TransferProgressManager.shared.rejectBeforeStart( @@ -1725,6 +1797,21 @@ final class BLEService: NSObject { return } guard self.noiseService.hasEstablishedSession(with: targetID) else { + if requiresAuthenticatedPrivateMediaReceipts { + // A retry belongs to one exact authenticated generation. + // Never let it enter the ordinary pending queue where a + // bit-8-only replacement session could later flush it. + TransferProgressManager.shared.rejectBeforeStart( + id: transferId, + 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" + ) + ) + self.privateMediaTransferAdmissions.finish(transferId) + return + } let queued = self.collectionsQueue.sync(flags: .barrier) { self.privateMediaTransferAdmissions.withActive(transferId) { self.pendingNoiseSessionQueues.appendTypedPayload( @@ -1756,7 +1843,12 @@ final class BLEService: NSObject { self.privateMediaTransferAdmissions.finish(transferId) return } - let packet = try self.makeEncryptedNoisePacket(typedPayload, to: targetID) + let packet = try self.makeEncryptedNoisePacket( + typedPayload, + to: targetID, + requiresAuthenticatedPrivateMediaReceipts: + requiresAuthenticatedPrivateMediaReceipts + ) guard self.privateMediaTransferAdmissions.isActive(transferId) else { self.privateMediaTransferAdmissions.finish(transferId) return @@ -5164,7 +5256,11 @@ extension BLEService { } } - private func makeEncryptedNoisePacket(_ typedPayload: Data, to peerID: PeerID) throws -> BitchatPacket { + private func makeEncryptedNoisePacket( + _ typedPayload: Data, + to peerID: PeerID, + requiresAuthenticatedPrivateMediaReceipts: Bool = false + ) throws -> BitchatPacket { let encrypted: Data let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: typedPayload.first) if isPrivateFile { @@ -5174,6 +5270,13 @@ extension BLEService { let authenticated = authenticatedPeerStates[peerID], authenticated.sessionGeneration == generation, authenticated.capabilities.contains(.privateMedia) else { return nil } + if requiresAuthenticatedPrivateMediaReceipts { + guard authenticated.capabilities.contains( + .privateMediaReceipts + ) else { + return nil + } + } return generation } guard let provenGeneration else { diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index 2bb4ddab..09213001 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -190,6 +190,14 @@ protocol Transport: AnyObject { transferId: String, allowLegacyFallback: Bool ) + /// Automatic whole-file retry is admitted only while this exact Noise + /// generation authenticates bit 9. It must never queue across a session + /// replacement or enter the signed raw legacy path. + func sendFilePrivateReceiptRetry( + _ packet: BitchatFilePacket, + to peerID: PeerID, + transferId: String + ) func cancelTransfer(_ transferId: String) // Live voice / push-to-talk (mesh transports only): one encoded @@ -236,6 +244,11 @@ protocol Transport: AnyObject { /// empty for peers that predate the capabilities TLV. func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy + /// The exact current Noise generation that authenticated both encrypted + /// private media (bit 8) and durable receipts/retry (bit 9). + func authenticatedPrivateMediaReceiptSessionGeneration( + to peerID: PeerID + ) -> UUID? func resolvePrivateMediaSendPolicy( to peerID: PeerID, completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void @@ -311,6 +324,11 @@ extension Transport { func broadcastGroupMessage(_ envelope: Data) {} func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] } func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { .blockedDowngrade } + func authenticatedPrivateMediaReceiptSessionGeneration( + to peerID: PeerID + ) -> UUID? { + nil + } func resolvePrivateMediaSendPolicy( to peerID: PeerID, completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void @@ -345,6 +363,11 @@ extension Transport { guard !allowLegacyFallback else { return } sendFilePrivate(packet, to: peerID, transferId: transferId) } + func sendFilePrivateReceiptRetry( + _ packet: BitchatFilePacket, + to peerID: PeerID, + transferId: String + ) {} func cancelTransfer(_ transferId: String) {} func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) { diff --git a/bitchat/ViewModels/ChatDeliveryCoordinator.swift b/bitchat/ViewModels/ChatDeliveryCoordinator.swift index f2fc13c0..20049439 100644 --- a/bitchat/ViewModels/ChatDeliveryCoordinator.swift +++ b/bitchat/ViewModels/ChatDeliveryCoordinator.swift @@ -55,6 +55,9 @@ extension ChatViewModel: ChatDeliveryContext { func markMessageDelivered(_ messageID: String) { messageRouter.markDelivered(messageID) + mediaTransferCoordinator.confirmPrivateMediaDelivery( + messageID: messageID + ) } } diff --git a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift index 4fcc86b3..ea209af4 100644 --- a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift +++ b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift @@ -19,6 +19,46 @@ struct PendingLegacyPrivateMediaConsent { 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 @@ -57,6 +97,7 @@ protocol ChatMediaTransferContext: AnyObject { // 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 @@ -74,6 +115,11 @@ protocol ChatMediaTransferContext: AnyObject { transferId: String, allowLegacyFallback: Bool ) + func sendFilePrivateReceiptRetry( + _ packet: BitchatFilePacket, + to peerID: PeerID, + transferId: String + ) func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) func cancelTransfer(_ transferId: String) } @@ -93,6 +139,14 @@ extension ChatViewModel: ChatMediaTransferContext { meshService.privateMediaSendPolicy(to: peerID) } + func authenticatedPrivateMediaReceiptSessionGeneration( + to peerID: PeerID + ) -> UUID? { + meshService.authenticatedPrivateMediaReceiptSessionGeneration( + to: peerID + ) + } + func resolvePrivateMediaSendPolicy( to peerID: PeerID, completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void @@ -135,6 +189,18 @@ extension ChatViewModel: ChatMediaTransferContext { ) } + 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) } @@ -225,9 +291,33 @@ final class ChatMediaTransferCoordinator { private let prepareImagePacket: @Sendable (URL) throws -> ChatPreparedImage private let imagePreparationBarrier = ImagePreparationBarrier() private let prepareVoiceNotePacket: @Sendable (URL) throws -> BitchatFilePacket + 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 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, @@ -236,11 +326,20 @@ final class ChatMediaTransferCoordinator { }, prepareVoiceNotePacket: @escaping @Sendable (URL) throws -> BitchatFilePacket = { try ChatMediaPreparation.prepareVoiceNotePacket(at: $0) + }, + reconnectRetryLimits: PrivateMediaReconnectRetryLimits = + PrivateMediaReconnectRetryLimits(), + now: @escaping () -> Date = Date.init, + transferIDFactory: @escaping (String) -> String = { + "\($0)-\(UUID().uuidString)" } ) { self.context = context self.prepareImagePacket = prepareImagePacket self.prepareVoiceNotePacket = prepareVoiceNotePacket + self.reconnectRetryLimits = reconnectRetryLimits + self.now = now + self.transferIDFactory = transferIDFactory } func sendVoiceNote(at url: URL) { @@ -543,6 +642,12 @@ final class ChatMediaTransferCoordinator { ) { switch policy { case .encrypted: + retainForReconnectRetryIfEligible( + packet, + peerID: peerID, + messageID: messageID, + activeTransferID: transferId + ) context.sendFilePrivate( packet, to: peerID, @@ -631,16 +736,32 @@ final class ChatMediaTransferCoordinator { } func makeTransferID(messageID: String) -> String { - "\(messageID)-\(UUID().uuidString)" + transferIDFactory(messageID) } func clearTransferMapping(for messageID: String) { - guard let transferId = messageIDToTransferId.removeValue(forKey: messageID) else { return } + 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, + transferId: transferID, messageID: messageID ) - guard var queue = transferIdToMessageIDs[transferId] else { return } + guard var queue = transferIdToMessageIDs[transferID] else { return } if !queue.isEmpty { if queue.first == messageID { @@ -650,10 +771,32 @@ final class ChatMediaTransferCoordinator { } } - transferIdToMessageIDs[transferId] = queue.isEmpty ? nil : queue + 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) } @@ -661,21 +804,88 @@ final class ChatMediaTransferCoordinator { func handleTransferEvent(_ event: TransferProgressManager.Event) { switch event { case .started(let id, let total): - guard let messageID = transferIdToMessageIDs[id]?.first else { return } + 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 = transferIdToMessageIDs[id]?.first else { return } + 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 = transferIdToMessageIDs[id]?.first else { return } + 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 = transferIdToMessageIDs[id]?.first else { return } + 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.removeMessage(withID: messageID, cleanupFile: true) + case .rejected(let id, let reason): - guard let messageID = transferIdToMessageIDs[id]?.first else { return } + 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) } } @@ -707,6 +917,10 @@ final class ChatMediaTransferCoordinator { } func cancelMediaSend(messageID: String) { + discardReconnectRetry( + messageID: messageID, + cancelActiveTransfer: false + ) if let transferId = messageIDToTransferId[messageID], let active = transferIdToMessageIDs[transferId]?.first, active == messageID { @@ -717,6 +931,10 @@ final class ChatMediaTransferCoordinator { } func deleteMediaMessage(messageID: String) { + discardReconnectRetry( + messageID: messageID, + cancelActiveTransfer: false + ) // 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. @@ -728,12 +946,55 @@ final class ChatMediaTransferCoordinator { context.removeMessage(withID: messageID, cleanupFile: true) } + /// 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. 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() + 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) @@ -744,6 +1005,571 @@ final class ChatMediaTransferCoordinator { } 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, diff --git a/bitchat/ViewModels/ChatVerificationCoordinator.swift b/bitchat/ViewModels/ChatVerificationCoordinator.swift index 3b78797d..e767a5e9 100644 --- a/bitchat/ViewModels/ChatVerificationCoordinator.swift +++ b/bitchat/ViewModels/ChatVerificationCoordinator.swift @@ -58,6 +58,7 @@ protocol ChatVerificationContext: AnyObject { func noiseStaticPublicKeyData() -> Data func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool func triggerHandshake(with peerID: PeerID) + func privateMediaPeerDidAuthenticate(_ peerID: PeerID) func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) @@ -116,6 +117,10 @@ extension ChatViewModel: ChatVerificationContext { meshService.noiseStaticPublicKeyData() } + func privateMediaPeerDidAuthenticate(_ peerID: PeerID) { + mediaTransferCoordinator.peerDidAuthenticate(peerID.toShort()) + } + func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA) } @@ -129,6 +134,10 @@ extension ChatViewModel: ChatVerificationContext { } } +extension ChatVerificationContext { + func privateMediaPeerDidAuthenticate(_ peerID: PeerID) {} +} + @MainActor final class ChatVerificationCoordinator { struct PendingVerification { @@ -197,6 +206,7 @@ final class ChatVerificationCoordinator { guard let self else { return } SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security) + self.context.privateMediaPeerDidAuthenticate(peerID) if self.context.isVerifiedFingerprint(fingerprint) { self.context.setEncryptionStatus(.noiseVerified, for: peerID) diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 1a9740f2..ca30cd52 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1755,6 +1755,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage case .peerConnected(let peerID): transportEventCoordinator.didConnectToPeerSynchronously(peerID) + mediaTransferCoordinator.peerDidReconnect(peerID) case .peerDisconnected(let peerID): transportEventCoordinator.didDisconnectFromPeerSynchronously(peerID) @@ -1844,6 +1845,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage func didConnectToPeer(_ peerID: PeerID) { transportEventCoordinator.didConnectToPeer(peerID) + Task { @MainActor [weak self] in + self?.mediaTransferCoordinator.peerDidReconnect(peerID) + } } func didDisconnectFromPeer(_ peerID: PeerID) { diff --git a/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift b/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift index 824bae69..f0b88dd0 100644 --- a/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift +++ b/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift @@ -95,10 +95,20 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext { transferId: String )] = [] private(set) var privateFileLegacyAllowances: [Bool] = [] + private(set) var privateFileReceiptRetryTransferIDs: [String] = [] private(set) var broadcastFileSends: [String] = [] private(set) var cancelledTransfers: [String] = [] + private(set) var privateMediaPolicyResolutionRequests: [PeerID] = [] var privateMediaPolicy: PrivateMediaSendPolicy = .encrypted var resolvedPrivateMediaPolicy: PrivateMediaSendPolicy? + var resolvesPrivateMediaPolicyImmediately = true + var supportsAuthenticatedPrivateMediaReceipts = false + var authenticatedPrivateMediaReceiptGeneration = UUID( + uuidString: "00000000-0000-0000-0000-000000000001" + )! + private var pendingPrivateMediaPolicyResolutions: [ + @MainActor (PrivateMediaSendPolicy) -> Void + ] = [] private(set) var legacyConsentRequests: [( id: UUID, peerID: PeerID, @@ -113,11 +123,40 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext { privateMediaPolicy } + func authenticatedPrivateMediaReceiptSessionGeneration( + to peerID: PeerID + ) -> UUID? { + supportsAuthenticatedPrivateMediaReceipts + ? authenticatedPrivateMediaReceiptGeneration + : nil + } + func resolvePrivateMediaSendPolicy( to peerID: PeerID, completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void ) { - completion(resolvedPrivateMediaPolicy ?? privateMediaPolicy) + privateMediaPolicyResolutionRequests.append(peerID) + if resolvesPrivateMediaPolicyImmediately { + completion(resolvedPrivateMediaPolicy ?? privateMediaPolicy) + } else { + pendingPrivateMediaPolicyResolutions.append(completion) + } + } + + var pendingPrivateMediaPolicyResolutionCount: Int { + pendingPrivateMediaPolicyResolutions.count + } + + func resolveNextPrivateMediaPolicy( + _ policy: PrivateMediaSendPolicy? = nil + ) { + guard !pendingPrivateMediaPolicyResolutions.isEmpty else { return } + let completion = pendingPrivateMediaPolicyResolutions.removeFirst() + completion( + policy + ?? resolvedPrivateMediaPolicy + ?? privateMediaPolicy + ) } func requestLegacyPrivateMediaConsent( @@ -162,6 +201,16 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext { privateFileLegacyAllowances.append(allowLegacyFallback) } + func sendFilePrivateReceiptRetry( + _ packet: BitchatFilePacket, + to peerID: PeerID, + transferId: String + ) { + privateFileSends.append((packet, peerID, transferId)) + privateFileLegacyAllowances.append(false) + privateFileReceiptRetryTransferIDs.append(transferId) + } + func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) { broadcastFileSends.append(transferId) } @@ -216,6 +265,59 @@ private final class PausedVoiceNotePreparer: @unchecked Sendable { } } +private final class StaticVoiceNotePreparer: @unchecked Sendable { + private let packet: BitchatFilePacket + + init(fileName: String, content: Data = Data("voice".utf8)) { + packet = BitchatFilePacket( + fileName: fileName, + fileSize: UInt64(content.count), + mimeType: "audio/mp4", + content: content + ) + } + + func prepare(_ _: URL) throws -> BitchatFilePacket { + packet + } +} + +private final class DeterministicMediaTransferIDFactory: + @unchecked Sendable { + private let lock = NSLock() + private var nextOrdinal = 0 + + func make(messageID: String) -> String { + lock.lock() + defer { + nextOrdinal += 1 + lock.unlock() + } + return "\(messageID)-attempt-\(nextOrdinal)" + } +} + +private final class MutableMediaRetryClock: @unchecked Sendable { + private let lock = NSLock() + private var value: Date + + init(_ value: Date) { + self.value = value + } + + func now() -> Date { + lock.lock() + defer { lock.unlock() } + return value + } + + func advance(by interval: TimeInterval) { + lock.lock() + value = value.addingTimeInterval(interval) + lock.unlock() + } +} + // MARK: - Coordinator Tests Against Mock Context /// Exercises `ChatMediaTransferCoordinator` against @@ -798,6 +900,548 @@ struct ChatMediaTransferCoordinatorContextTests { #expect(context.legacyConsentRequests.isEmpty) #expect(context.privateFileSends.isEmpty) } + + @Test @MainActor + func receiptCapableEncryptedMediaRetriesExactPacketAfterReconnect() async throws { + let context = MockChatMediaTransferContext() + let peerID = PeerID(str: "1122334455667788") + context.selectedPrivateChatPeer = peerID + context.supportsAuthenticatedPrivateMediaReceipts = true + let fileName = "voice_0011223344556677.m4a" + let preparer = StaticVoiceNotePreparer(fileName: fileName) + let transferIDs = DeterministicMediaTransferIDFactory() + let coordinator = ChatMediaTransferCoordinator( + context: context, + prepareVoiceNotePacket: { url in + try preparer.prepare(url) + }, + transferIDFactory: transferIDs.make + ) + let url = try makeCoordinatorVoiceURL(fileName: fileName) + defer { + try? FileManager.default.removeItem( + at: url.deletingLastPathComponent() + ) + } + + coordinator.sendVoiceNote(at: url) + #expect(await TestHelpers.waitUntil( + { context.privateFileSends.count == 1 }, + timeout: TestConstants.longTimeout + )) + let messageID = try #require( + context.privateChats[peerID]?.first?.id + ) + let initial = try #require( + context.privateFileSends.first + ) + #expect(PrivateMediaMessageIdentity.isStableID(messageID)) + #expect(coordinator.retainedReconnectRetryCount == 1) + + coordinator.handleTransferEvent(.completed( + id: initial.transferId, + totalFragments: 1 + )) + #expect(coordinator.retainedReconnectRetryCount == 1) + #expect(context.deliveryStatusUpdates.last?.status == .sent) + + coordinator.peerDidReconnect(peerID) + #expect(context.privateFileSends.count == 2) + let retry = try #require(context.privateFileSends.last) + #expect(retry.packet.encode() == initial.packet.encode()) + #expect(retry.peerID == peerID) + #expect(retry.transferId != initial.transferId) + #expect(context.privateFileReceiptRetryTransferIDs == [ + retry.transferId + ]) + #expect(context.privateFileLegacyAllowances == [false, false]) + + coordinator.confirmPrivateMediaDelivery(messageID: messageID) + #expect(coordinator.retainedReconnectRetryCount == 0) + #expect(context.cancelledTransfers == [retry.transferId]) + #expect(context.removedMessages.isEmpty) + #expect(!context.deliveryStatusUpdates.contains { + if case .failed = $0.status { return true } + return false + }) + + // Receipt confirmation removes retry ownership before transport + // cancellation, so its late callback cannot delete the delivered row + // or re-arm another reconnect retry. + coordinator.handleTransferEvent(.cancelled( + id: retry.transferId, + sentFragments: 1, + totalFragments: 2 + )) + coordinator.peerDidReconnect(peerID) + #expect(context.privateFileSends.count == 2) + #expect(context.removedMessages.isEmpty) + #expect(coordinator.messageIDToTransferId[messageID] == nil) + } + + @Test @MainActor + func bit8OnlyEncryptedMediaNeverRetainsOrAutomaticallyRetries() async throws { + let context = MockChatMediaTransferContext() + let peerID = PeerID(str: "1122334455667788") + context.selectedPrivateChatPeer = peerID + context.privateMediaPolicy = .encrypted + context.supportsAuthenticatedPrivateMediaReceipts = false + let fileName = "voice_1111222233334444.m4a" + let preparer = StaticVoiceNotePreparer(fileName: fileName) + let coordinator = ChatMediaTransferCoordinator( + context: context, + prepareVoiceNotePacket: { url in + try preparer.prepare(url) + } + ) + let url = try makeCoordinatorVoiceURL(fileName: fileName) + defer { + try? FileManager.default.removeItem( + at: url.deletingLastPathComponent() + ) + } + + coordinator.sendVoiceNote(at: url) + #expect(await TestHelpers.waitUntil( + { context.privateFileSends.count == 1 }, + timeout: TestConstants.longTimeout + )) + let initial = try #require(context.privateFileSends.first) + coordinator.handleTransferEvent(.completed( + id: initial.transferId, + totalFragments: 1 + )) + coordinator.peerDidReconnect(peerID) + coordinator.peerDidAuthenticate(peerID) + + #expect(coordinator.retainedReconnectRetryCount == 0) + #expect(context.privateFileSends.count == 1) + #expect(context.privateFileReceiptRetryTransferIDs.isEmpty) + #expect(context.privateMediaPolicyResolutionRequests.isEmpty) + } + + @Test @MainActor + func consentedRawLegacyMediaNeverEntersAutomaticRetry() async throws { + let context = MockChatMediaTransferContext() + let peerID = PeerID(str: "1122334455667788") + context.selectedPrivateChatPeer = peerID + context.privateMediaPolicy = .legacyRequiresConsent + // Even a contradictory stale bit-9 observation must not retain an + // invocation that actually selected the explicit raw path. + context.supportsAuthenticatedPrivateMediaReceipts = true + let fileName = "voice_2222333344445555.m4a" + let preparer = StaticVoiceNotePreparer(fileName: fileName) + let coordinator = ChatMediaTransferCoordinator( + context: context, + prepareVoiceNotePacket: { url in + try preparer.prepare(url) + } + ) + let url = try makeCoordinatorVoiceURL(fileName: fileName) + defer { + try? FileManager.default.removeItem( + at: url.deletingLastPathComponent() + ) + } + + coordinator.sendVoiceNote(at: url) + #expect(await TestHelpers.waitUntil( + { context.legacyConsentRequests.count == 1 }, + timeout: TestConstants.longTimeout + )) + context.resolveNextLegacyConsent(true) + let initial = try #require(context.privateFileSends.first) + #expect(context.privateFileLegacyAllowances == [true]) + #expect(coordinator.retainedReconnectRetryCount == 0) + + coordinator.handleTransferEvent(.completed( + id: initial.transferId, + totalFragments: 1 + )) + context.privateMediaPolicy = .encrypted + coordinator.peerDidReconnect(peerID) + coordinator.peerDidAuthenticate(peerID) + + #expect(context.privateFileSends.count == 1) + #expect(context.privateFileReceiptRetryTransferIDs.isEmpty) + #expect(coordinator.retainedReconnectRetryCount == 0) + } + + @Test @MainActor + func authenticatedGenerationSupersedesStaleReconnectResolution() async throws { + let context = MockChatMediaTransferContext() + let peerID = PeerID(str: "1122334455667788") + context.selectedPrivateChatPeer = peerID + context.supportsAuthenticatedPrivateMediaReceipts = true + context.resolvesPrivateMediaPolicyImmediately = false + let oldGeneration = context + .authenticatedPrivateMediaReceiptGeneration + let newGeneration = UUID( + uuidString: "00000000-0000-0000-0000-000000000002" + )! + let fileName = "voice_3333444455556666.m4a" + let preparer = StaticVoiceNotePreparer(fileName: fileName) + let transferIDs = DeterministicMediaTransferIDFactory() + let coordinator = ChatMediaTransferCoordinator( + context: context, + prepareVoiceNotePacket: { url in + try preparer.prepare(url) + }, + transferIDFactory: transferIDs.make + ) + let url = try makeCoordinatorVoiceURL(fileName: fileName) + defer { + try? FileManager.default.removeItem( + at: url.deletingLastPathComponent() + ) + } + + coordinator.sendVoiceNote(at: url) + #expect(await TestHelpers.waitUntil( + { context.privateFileSends.count == 1 }, + timeout: TestConstants.longTimeout + )) + let initial = try #require(context.privateFileSends.first) + coordinator.peerDidReconnect(peerID) + #expect(context.pendingPrivateMediaPolicyResolutionCount == 1) + + context.authenticatedPrivateMediaReceiptGeneration = newGeneration + coordinator.peerDidAuthenticate(peerID) + #expect(context.pendingPrivateMediaPolicyResolutionCount == 2) + + // The old-generation completion lost ownership and is inert. + context.resolveNextPrivateMediaPolicy(.encrypted) + #expect(context.privateFileReceiptRetryTransferIDs.isEmpty) + #expect(context.cancelledTransfers.isEmpty) + + context.resolveNextPrivateMediaPolicy(.encrypted) + #expect(context.cancelledTransfers == [initial.transferId]) + #expect(context.privateFileReceiptRetryTransferIDs.count == 1) + #expect( + context.authenticatedPrivateMediaReceiptGeneration + == newGeneration + ) + #expect(oldGeneration != newGeneration) + } + + @Test @MainActor + func panicClearsRetainedRetryPendingResolutionAndExpiry() async throws { + let context = MockChatMediaTransferContext() + let peerID = PeerID(str: "1122334455667788") + context.selectedPrivateChatPeer = peerID + context.supportsAuthenticatedPrivateMediaReceipts = true + context.resolvesPrivateMediaPolicyImmediately = false + let fileName = "voice_3333444455556666.m4a" + let preparer = StaticVoiceNotePreparer(fileName: fileName) + let coordinator = ChatMediaTransferCoordinator( + context: context, + prepareVoiceNotePacket: { url in + try preparer.prepare(url) + }, + reconnectRetryLimits: PrivateMediaReconnectRetryLimits( + maxRetainedPackets: 1, + maxRetainedBytes: 1_024, + maxRetriesPerMessage: 1, + retentionSeconds: 0.1, + maxRetriesPerReconnect: 1 + ) + ) + let url = try makeCoordinatorVoiceURL(fileName: fileName) + defer { + try? FileManager.default.removeItem( + at: url.deletingLastPathComponent() + ) + } + + coordinator.sendVoiceNote(at: url) + #expect(await TestHelpers.waitUntil( + { context.privateFileSends.count == 1 }, + timeout: TestConstants.longTimeout + )) + let initial = try #require(context.privateFileSends.first) + coordinator.handleTransferEvent(.completed( + id: initial.transferId, + totalFragments: 1 + )) + #expect(coordinator.retainedReconnectRetryCount == 1) + + coordinator.peerDidReconnect(peerID) + #expect(context.pendingPrivateMediaPolicyResolutionCount == 1) + let failedBeforePanic = context.deliveryStatusUpdates.filter { + if case .failed = $0.status { return true } + return false + }.count + + coordinator.resetForPanic() + context.resolveNextPrivateMediaPolicy(.encrypted) + try await Task.sleep(nanoseconds: 250_000_000) + coordinator._test_expireReconnectRetries() + + #expect(coordinator.retainedReconnectRetryCount == 0) + #expect(coordinator.retainedReconnectRetryBytes == 0) + #expect(coordinator.transferIdToMessageIDs.isEmpty) + #expect(coordinator.messageIDToTransferId.isEmpty) + #expect(context.privateFileSends.count == 1) + #expect(context.privateFileReceiptRetryTransferIDs.isEmpty) + #expect(context.deliveryStatusUpdates.filter { + if case .failed = $0.status { return true } + return false + }.count == failedBeforePanic) + } + + @Test @MainActor + func retryCountAndRetentionTimeEndInVisibleFailure() async throws { + let context = MockChatMediaTransferContext() + let peerID = PeerID(str: "1122334455667788") + context.selectedPrivateChatPeer = peerID + context.supportsAuthenticatedPrivateMediaReceipts = true + let clock = MutableMediaRetryClock( + Date(timeIntervalSince1970: 4_000) + ) + let limits = PrivateMediaReconnectRetryLimits( + maxRetainedPackets: 2, + maxRetainedBytes: 1_024, + maxRetriesPerMessage: 1, + retentionSeconds: 10, + maxRetriesPerReconnect: 1 + ) + let fileName = "voice_4444555566667777.m4a" + let preparer = StaticVoiceNotePreparer(fileName: fileName) + let transferIDs = DeterministicMediaTransferIDFactory() + let coordinator = ChatMediaTransferCoordinator( + context: context, + prepareVoiceNotePacket: { url in + try preparer.prepare(url) + }, + reconnectRetryLimits: limits, + now: clock.now, + transferIDFactory: transferIDs.make + ) + let url = try makeCoordinatorVoiceURL(fileName: fileName) + defer { + try? FileManager.default.removeItem( + at: url.deletingLastPathComponent() + ) + } + + coordinator.sendVoiceNote(at: url) + #expect(await TestHelpers.waitUntil( + { context.privateFileSends.count == 1 }, + timeout: TestConstants.longTimeout + )) + let initial = try #require(context.privateFileSends.first) + coordinator.handleTransferEvent(.completed( + id: initial.transferId, + totalFragments: 1 + )) + coordinator.peerDidReconnect(peerID) + let retryID = try #require( + context.privateFileReceiptRetryTransferIDs.first + ) + coordinator.handleTransferEvent(.cancelled( + id: retryID, + sentFragments: 0, + totalFragments: 1 + )) + + #expect(coordinator.retainedReconnectRetryCount == 0) + #expect(context.removedMessages.isEmpty) + #expect(context.deliveryStatusUpdates.contains { + $0.messageID.hasPrefix("media-") + && $0.status == .failed(reason: String( + localized: "content.delivery.reason.not_delivered", + defaultValue: "Not delivered", + comment: "Failure reason shown when a private media transfer could not finish" + )) + }) + + // A separate retained row that locally completed but never received a + // remote receipt expires to a distinct visible failure. + let ttlFileName = "voice_5555666677778888.m4a" + let ttlPreparer = StaticVoiceNotePreparer( + fileName: ttlFileName + ) + let ttlCoordinator = ChatMediaTransferCoordinator( + context: context, + prepareVoiceNotePacket: { url in + try ttlPreparer.prepare(url) + }, + reconnectRetryLimits: limits, + now: clock.now, + transferIDFactory: transferIDs.make + ) + let ttlURL = try makeCoordinatorVoiceURL( + fileName: ttlFileName + ) + defer { + try? FileManager.default.removeItem( + at: ttlURL.deletingLastPathComponent() + ) + } + let sendsBeforeTTL = context.privateFileSends.count + ttlCoordinator.sendVoiceNote(at: ttlURL) + #expect(await TestHelpers.waitUntil( + { + context.privateFileSends.count + == sendsBeforeTTL + 1 + }, + timeout: TestConstants.longTimeout + )) + let ttlInitial = try #require(context.privateFileSends.last) + ttlCoordinator.handleTransferEvent(.completed( + id: ttlInitial.transferId, + totalFragments: 1 + )) + clock.advance(by: 10) + ttlCoordinator._test_expireReconnectRetries() + + #expect(ttlCoordinator.retainedReconnectRetryCount == 0) + #expect(context.deliveryStatusUpdates.contains { + $0.status == .failed( + reason: String( + localized: + "content.delivery.reason.private_media_delivery_unconfirmed", + defaultValue: "Delivery could not be confirmed" + ) + ) + }) + } + + @Test @MainActor + func retentionAndPerReconnectWorkAreBounded() async throws { + let context = MockChatMediaTransferContext() + let peerID = PeerID(str: "1122334455667788") + context.selectedPrivateChatPeer = peerID + context.supportsAuthenticatedPrivateMediaReceipts = true + let limits = PrivateMediaReconnectRetryLimits( + maxRetainedPackets: 2, + maxRetainedBytes: 10, + maxRetriesPerMessage: 2, + retentionSeconds: 120, + maxRetriesPerReconnect: 1 + ) + let transferIDs = DeterministicMediaTransferIDFactory() + let coordinator = ChatMediaTransferCoordinator( + context: context, + prepareVoiceNotePacket: { url in + let content = Data("voice".utf8) + return BitchatFilePacket( + fileName: url.lastPathComponent, + fileSize: UInt64(content.count), + mimeType: "audio/mp4", + content: content + ) + }, + reconnectRetryLimits: limits, + transferIDFactory: transferIDs.make + ) + let fileNames = [ + "voice_6666777788889999.m4a", + "voice_777788889999aaaa.m4a", + "voice_88889999aaaabbbb.m4a" + ] + var roots: [URL] = [] + defer { + for root in roots { + try? FileManager.default.removeItem(at: root) + } + } + + for fileName in fileNames { + let url = try makeCoordinatorVoiceURL( + fileName: fileName, + bytes: Data("voice".utf8) + ) + roots.append(url.deletingLastPathComponent()) + // The production preparer preserves this stable filename. + coordinator.sendVoiceNote(at: url) + } + #expect(await TestHelpers.waitUntil( + { context.privateFileSends.count == 3 }, + timeout: TestConstants.longTimeout + )) + #expect(coordinator.retainedReconnectRetryCount == 2) + #expect(coordinator.retainedReconnectRetryBytes <= 10) + + for send in context.privateFileSends { + coordinator.handleTransferEvent(.completed( + id: send.transferId, + totalFragments: 1 + )) + } + coordinator.peerDidReconnect(peerID) + #expect(context.privateFileReceiptRetryTransferIDs.count == 1) + } + + @Test @MainActor + func userCancellationReleasesRetainedBytesAndIgnoresLateEvent() async throws { + let context = MockChatMediaTransferContext() + let peerID = PeerID(str: "1122334455667788") + context.selectedPrivateChatPeer = peerID + context.supportsAuthenticatedPrivateMediaReceipts = true + let fileName = "voice_9999aaaabbbbcccc.m4a" + let preparer = StaticVoiceNotePreparer(fileName: fileName) + let coordinator = ChatMediaTransferCoordinator( + context: context, + prepareVoiceNotePacket: { url in + try preparer.prepare(url) + } + ) + let url = try makeCoordinatorVoiceURL(fileName: fileName) + defer { + try? FileManager.default.removeItem( + at: url.deletingLastPathComponent() + ) + } + + coordinator.sendVoiceNote(at: url) + #expect(await TestHelpers.waitUntil( + { context.privateFileSends.count == 1 }, + timeout: TestConstants.longTimeout + )) + let messageID = try #require( + context.privateChats[peerID]?.first?.id + ) + let transferID = try #require( + context.privateFileSends.first?.transferId + ) + + coordinator.cancelMediaSend(messageID: messageID) + coordinator.handleTransferEvent(.cancelled( + id: transferID, + sentFragments: 0, + totalFragments: 1 + )) + + #expect(coordinator.retainedReconnectRetryCount == 0) + #expect(coordinator.retainedReconnectRetryBytes == 0) + #expect(context.cancelledTransfers == [transferID]) + #expect(context.removedMessages.map(\.messageID) == [ + messageID + ]) + #expect(!context.deliveryStatusUpdates.contains { + if case .failed = $0.status { return true } + return false + }) + } +} + +private func makeCoordinatorVoiceURL( + fileName: String, + bytes: Data = Data("voice".utf8) +) throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent( + "media-retry-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + let url = directory.appendingPathComponent(fileName) + try bytes.write(to: url) + return url } private final class PausedImagePreparer: @unchecked Sendable { diff --git a/bitchatTests/ChatVerificationCoordinatorContextTests.swift b/bitchatTests/ChatVerificationCoordinatorContextTests.swift index 326ab453..59d04b9a 100644 --- a/bitchatTests/ChatVerificationCoordinatorContextTests.swift +++ b/bitchatTests/ChatVerificationCoordinatorContextTests.swift @@ -97,6 +97,7 @@ private final class MockChatVerificationContext: ChatVerificationContext { var noiseSessionKeysByPeerID: [PeerID: Data] = [:] private(set) var installedCallbacks: (onPeerAuthenticated: (PeerID, String) -> Void, onHandshakeRequired: (PeerID) -> Void)? private(set) var triggeredHandshakes: [PeerID] = [] + private(set) var privateMediaAuthenticatedPeers: [PeerID] = [] private(set) var sentChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = [] private(set) var sentResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = [] @@ -113,6 +114,9 @@ private final class MockChatVerificationContext: ChatVerificationContext { establishedNoiseSessions.contains(peerID) } func triggerHandshake(with peerID: PeerID) { triggeredHandshakes.append(peerID) } + func privateMediaPeerDidAuthenticate(_ peerID: PeerID) { + privateMediaAuthenticatedPeers.append(peerID) + } func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { sentChallenges.append((peerID, noiseKeyHex, nonceA)) @@ -277,6 +281,7 @@ struct ChatVerificationCoordinatorContextTests { #expect(context.encryptionStatuses[peerID] == .noiseVerified) #expect(context.stablePeerIDCache[peerID] == PeerID(hexData: noiseKey)) #expect(context.invalidatedEncryptionCachePeers.contains(peerID)) + #expect(context.privateMediaAuthenticatedPeers == [peerID]) // Handshake required -> handshaking status. callbacks?.onHandshakeRequired(peerID) diff --git a/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift b/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift index 735ade73..36f77df9 100644 --- a/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift +++ b/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift @@ -364,6 +364,217 @@ struct PrivateMediaEndToEndTests { #expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .legacyRequiresConsent) } + @Test + func privateMediaRetryRequiresExactAuthenticatedBit9Proof() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent( + "private-media-receipt-proof-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: root) } + let alice = makeService( + baseDirectory: root.appendingPathComponent( + "alice", + isDirectory: true + ) + ) + let bob = makeService( + baseDirectory: root.appendingPathComponent( + "bob", + isDirectory: true + ) + ) + let bothCapabilities: PeerCapabilities = [ + .privateMedia, + .privateMediaReceipts + ] + + // A public bit-9 announce is discovery only. + alice._test_seedConnectedPeer( + bob.myPeerID, + nickname: "Bob", + capabilities: bothCapabilities, + noisePublicKey: bob.noiseStaticPublicKeyData() + ) + #expect( + alice.authenticatedPrivateMediaReceiptSessionGeneration( + to: bob.myPeerID + ) == nil + ) + + let proofs = try await establishSessionCapturingPeerState( + alice: alice, + bob: bob + ) + #expect( + alice.authenticatedPrivateMediaReceiptSessionGeneration( + to: bob.myPeerID + ) == nil + ) + + // Bit 8 alone preserves encrypted transfer compatibility but cannot + // authorize automatic resend. + let privateMediaOnly = try authenticatedPeerStatePacket( + from: bob, + to: alice, + capabilities: .privateMedia + ) + alice._test_handlePacket( + privateMediaOnly, + fromPeerID: bob.myPeerID + ) + #expect(await TestHelpers.waitUntil( + { + alice.privateMediaSendPolicy(to: bob.myPeerID) + == .encrypted + }, + timeout: TestConstants.longTimeout + )) + #expect( + alice.authenticatedPrivateMediaReceiptSessionGeneration( + to: bob.myPeerID + ) == nil + ) + + let receiptCapable = try authenticatedPeerStatePacket( + from: bob, + to: alice, + capabilities: bothCapabilities + ) + alice._test_handlePacket( + receiptCapable, + fromPeerID: bob.myPeerID + ) + #expect(await TestHelpers.waitUntil( + { + alice.authenticatedPrivateMediaReceiptSessionGeneration( + to: bob.myPeerID + ) != nil + }, + timeout: TestConstants.longTimeout + )) + + bob._test_handlePacket( + proofs.alice, + fromPeerID: alice.myPeerID + ) + alice._test_onOutboundPacket = nil + bob._test_onOutboundPacket = nil + } + + @Test + func receiptRetryRechecksBit9AtDeferredTransportBoundary() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent( + "private-media-retry-proof-race-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: root) } + let alice = makeService( + baseDirectory: root.appendingPathComponent( + "alice", + isDirectory: true + ) + ) + let bob = makeService( + baseDirectory: root.appendingPathComponent( + "bob", + isDirectory: true + ) + ) + let receiptCapabilities: PeerCapabilities = [ + .privateMedia, + .privateMediaReceipts + ] + alice._test_seedConnectedPeer( + bob.myPeerID, + nickname: "Bob", + capabilities: receiptCapabilities, + noisePublicKey: bob.noiseStaticPublicKeyData() + ) + bob._test_seedConnectedPeer( + alice.myPeerID, + nickname: "Alice", + capabilities: receiptCapabilities, + noisePublicKey: alice.noiseStaticPublicKeyData() + ) + try await establishSession(alice: alice, bob: bob) + #expect( + alice.authenticatedPrivateMediaReceiptSessionGeneration( + to: bob.myPeerID + ) != nil + ) + + let privateMediaOnly = try authenticatedPeerStatePacket( + from: bob, + to: alice, + capabilities: .privateMedia + ) + let transferID = + "receipt-proof-race-\(UUID().uuidString)" + let tap = PacketTap() + let boundaryProofs = ReceiptCapabilityRecorder() + let rejections = TransferCancellationRecorder() + let cancellable = TransferProgressManager.shared.publisher.sink { + rejections.record($0) + } + alice._test_onOutboundPacket = tap.record + alice._test_beforePrivateMediaDeferredSend = { id in + guard id == transferID else { return } + boundaryProofs.record( + alice + .authenticatedPrivateMediaReceiptSessionGeneration( + to: bob.myPeerID + ) != nil + ) + } + defer { + alice._test_beforePrivateMediaDeferredSend = nil + alice._test_onOutboundPacket = nil + } + + // Rotate authenticated state before the deferred retry reaches its + // admission boundary. + alice._test_handlePacket( + privateMediaOnly, + fromPeerID: bob.myPeerID + ) + let content = Data("%PDF-1.7\nreceipt-proof-race".utf8) + alice.sendFilePrivateReceiptRetry( + BitchatFilePacket( + fileName: "receipt-proof-race.pdf", + fileSize: UInt64(content.count), + mimeType: "application/pdf", + content: content + ), + to: bob.myPeerID, + transferId: transferID + ) + #expect(await TestHelpers.waitUntil( + { boundaryProofs.snapshot() == [false] }, + timeout: TestConstants.longTimeout + )) + await alice._test_drainPrivateMediaSendPipeline() + + #expect(await TestHelpers.waitUntil( + { rejections.contains(transferID) }, + timeout: TestConstants.longTimeout + )) + #expect(tap.snapshot().allSatisfy { + $0.type != MessageType.fileTransfer.rawValue + && !( + $0.type == MessageType.noiseEncrypted.rawValue + && $0.version == 2 + ) + }) + let state = alice._test_privateMediaTransferState( + transferId: transferID + ) + #expect(!state.admissionActive) + #expect(!state.pendingNoise) + _ = cancellable + } + @Test func capabilityAnnounceCannotPoisonPinWithoutMatchingNoiseAuthentication() async throws { let root = FileManager.default.temporaryDirectory @@ -1397,6 +1608,23 @@ private final class PrivateMediaPolicyRecorder: @unchecked Sendable { } } +private final class ReceiptCapabilityRecorder: @unchecked Sendable { + private let lock = NSLock() + private var values: [Bool] = [] + + func record(_ value: Bool) { + lock.lock() + values.append(value) + lock.unlock() + } + + func snapshot() -> [Bool] { + lock.lock() + defer { lock.unlock() } + return values + } +} + private final class MessageCaptureDelegate: BitchatDelegate, @unchecked Sendable { private let lock = NSLock() private var messages: [BitchatMessage] = []