Retry confirmed private media after reconnect

This commit is contained in:
jack
2026-07-25 20:09:22 +02:00
parent 945bc759f8
commit de3d5ee951
8 changed files with 1783 additions and 13 deletions
+105 -2
View File
@@ -966,6 +966,29 @@ final class BLEService: NSObject {
collectionsQueue.sync { peerRegistry.capabilities(for: peerID) } 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( private func privateMediaPolicyFingerprint(
for peerID: PeerID, for peerID: PeerID,
expectedSessionGeneration: UUID? expectedSessionGeneration: UUID?
@@ -1449,6 +1472,36 @@ final class BLEService: NSObject {
to peerID: PeerID, to peerID: PeerID,
transferId: String, transferId: String,
allowLegacyFallback: Bool 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 // Register before enqueueing onto messageQueue. This closes the window
// where cancel/delete could run first, observe no scheduler state, and // where cancel/delete could run first, observe no scheduler state, and
@@ -1557,6 +1610,25 @@ final class BLEService: NSObject {
self.privateMediaTransferAdmissions.finish(transferId) self.privateMediaTransferAdmissions.finish(transferId)
return 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 { guard let typedPayload = BLENoisePayloadFactory.privateFile(filePacket) else {
SecureLogger.error("❌ Failed to encode file packet for private send", category: .session) SecureLogger.error("❌ Failed to encode file packet for private send", category: .session)
TransferProgressManager.shared.rejectBeforeStart( TransferProgressManager.shared.rejectBeforeStart(
@@ -1567,6 +1639,21 @@ final class BLEService: NSObject {
return return
} }
guard self.noiseService.hasEstablishedSession(with: targetID) else { 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) { let queued = self.collectionsQueue.sync(flags: .barrier) {
self.privateMediaTransferAdmissions.withActive(transferId) { self.privateMediaTransferAdmissions.withActive(transferId) {
self.pendingNoiseSessionQueues.appendTypedPayload( self.pendingNoiseSessionQueues.appendTypedPayload(
@@ -1598,7 +1685,12 @@ final class BLEService: NSObject {
self.privateMediaTransferAdmissions.finish(transferId) self.privateMediaTransferAdmissions.finish(transferId)
return 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 { guard self.privateMediaTransferAdmissions.isActive(transferId) else {
self.privateMediaTransferAdmissions.finish(transferId) self.privateMediaTransferAdmissions.finish(transferId)
return return
@@ -4863,7 +4955,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 encrypted: Data
let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: typedPayload.first) let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: typedPayload.first)
if isPrivateFile { if isPrivateFile {
@@ -4873,6 +4969,13 @@ extension BLEService {
let authenticated = authenticatedPeerStates[peerID], let authenticated = authenticatedPeerStates[peerID],
authenticated.sessionGeneration == generation, authenticated.sessionGeneration == generation,
authenticated.capabilities.contains(.privateMedia) else { return nil } authenticated.capabilities.contains(.privateMedia) else { return nil }
if requiresAuthenticatedPrivateMediaReceipts {
guard authenticated.capabilities.contains(
.privateMediaReceipts
) else {
return nil
}
}
return generation return generation
} }
guard let provenGeneration else { guard let provenGeneration else {
+23
View File
@@ -190,6 +190,14 @@ protocol Transport: AnyObject {
transferId: String, transferId: String,
allowLegacyFallback: Bool 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) func cancelTransfer(_ transferId: String)
// Live voice / push-to-talk (mesh transports only): one encoded // 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. /// empty for peers that predate the capabilities TLV.
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy 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( func resolvePrivateMediaSendPolicy(
to peerID: PeerID, to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
@@ -311,6 +324,11 @@ extension Transport {
func broadcastGroupMessage(_ envelope: Data) {} func broadcastGroupMessage(_ envelope: Data) {}
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] } func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { .blockedDowngrade } func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { .blockedDowngrade }
func authenticatedPrivateMediaReceiptSessionGeneration(
to peerID: PeerID
) -> UUID? {
nil
}
func resolvePrivateMediaSendPolicy( func resolvePrivateMediaSendPolicy(
to peerID: PeerID, to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
@@ -345,6 +363,11 @@ extension Transport {
guard !allowLegacyFallback else { return } guard !allowLegacyFallback else { return }
sendFilePrivate(packet, to: peerID, transferId: transferId) sendFilePrivate(packet, to: peerID, transferId: transferId)
} }
func sendFilePrivateReceiptRetry(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String
) {}
func cancelTransfer(_ transferId: String) {} func cancelTransfer(_ transferId: String) {}
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) { func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
@@ -55,6 +55,9 @@ extension ChatViewModel: ChatDeliveryContext {
func markMessageDelivered(_ messageID: String) { func markMessageDelivered(_ messageID: String) {
messageRouter.markDelivered(messageID) messageRouter.markDelivered(messageID)
mediaTransferCoordinator.confirmPrivateMediaDelivery(
messageID: messageID
)
} }
} }
@@ -19,6 +19,46 @@ struct PendingLegacyPrivateMediaConsent {
let completion: @MainActor (Bool) -> Void 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. /// The narrow surface `ChatMediaTransferCoordinator` needs from its owner.
/// ///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the /// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
@@ -57,6 +97,7 @@ protocol ChatMediaTransferContext: AnyObject {
// MARK: Mesh file transfer // MARK: Mesh file transfer
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy
func authenticatedPrivateMediaReceiptSessionGeneration(to peerID: PeerID) -> UUID?
func resolvePrivateMediaSendPolicy( func resolvePrivateMediaSendPolicy(
to peerID: PeerID, to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
@@ -74,6 +115,11 @@ protocol ChatMediaTransferContext: AnyObject {
transferId: String, transferId: String,
allowLegacyFallback: Bool allowLegacyFallback: Bool
) )
func sendFilePrivateReceiptRetry(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String
)
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
func cancelTransfer(_ transferId: String) func cancelTransfer(_ transferId: String)
} }
@@ -93,6 +139,14 @@ extension ChatViewModel: ChatMediaTransferContext {
meshService.privateMediaSendPolicy(to: peerID) meshService.privateMediaSendPolicy(to: peerID)
} }
func authenticatedPrivateMediaReceiptSessionGeneration(
to peerID: PeerID
) -> UUID? {
meshService.authenticatedPrivateMediaReceiptSessionGeneration(
to: peerID
)
}
func resolvePrivateMediaSendPolicy( func resolvePrivateMediaSendPolicy(
to peerID: PeerID, to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void 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) { func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
meshService.sendFileBroadcast(packet, transferId: transferId) meshService.sendFileBroadcast(packet, transferId: transferId)
} }
@@ -148,18 +214,51 @@ extension ChatViewModel: ChatMediaTransferContext {
final class ChatMediaTransferCoordinator { final class ChatMediaTransferCoordinator {
private unowned let context: any ChatMediaTransferContext private unowned let context: any ChatMediaTransferContext
private let prepareVoiceNotePacket: @Sendable (URL) throws -> BitchatFilePacket 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 transferIdToMessageIDs: [String: [String]] = [:]
private(set) var messageIDToTransferId: [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<Void, Never>] = [:]
var retainedReconnectRetryCount: Int {
reconnectRetryRecords.count
}
var retainedReconnectRetryBytes: Int {
reconnectRetryRecords.values.reduce(0) { $0 + $1.retainedBytes }
}
init( init(
context: any ChatMediaTransferContext, context: any ChatMediaTransferContext,
prepareVoiceNotePacket: @escaping @Sendable (URL) throws -> BitchatFilePacket = { prepareVoiceNotePacket: @escaping @Sendable (URL) throws -> BitchatFilePacket = {
try ChatMediaPreparation.prepareVoiceNotePacket(at: $0) try ChatMediaPreparation.prepareVoiceNotePacket(at: $0)
},
reconnectRetryLimits: PrivateMediaReconnectRetryLimits =
PrivateMediaReconnectRetryLimits(),
now: @escaping () -> Date = Date.init,
transferIDFactory: @escaping (String) -> String = {
"\($0)-\(UUID().uuidString)"
} }
) { ) {
self.context = context self.context = context
self.prepareVoiceNotePacket = prepareVoiceNotePacket self.prepareVoiceNotePacket = prepareVoiceNotePacket
self.reconnectRetryLimits = reconnectRetryLimits
self.now = now
self.transferIDFactory = transferIDFactory
} }
func sendVoiceNote(at url: URL) { func sendVoiceNote(at url: URL) {
@@ -400,6 +499,12 @@ final class ChatMediaTransferCoordinator {
) { ) {
switch policy { switch policy {
case .encrypted: case .encrypted:
retainForReconnectRetryIfEligible(
packet,
peerID: peerID,
messageID: messageID,
activeTransferID: transferId
)
context.sendFilePrivate( context.sendFilePrivate(
packet, packet,
to: peerID, to: peerID,
@@ -488,16 +593,32 @@ final class ChatMediaTransferCoordinator {
} }
func makeTransferID(messageID: String) -> String { func makeTransferID(messageID: String) -> String {
"\(messageID)-\(UUID().uuidString)" transferIDFactory(messageID)
} }
func clearTransferMapping(for messageID: String) { 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( context.cancelLegacyPrivateMediaConsent(
transferId: transferId, transferId: transferID,
messageID: messageID messageID: messageID
) )
guard var queue = transferIdToMessageIDs[transferId] else { return } guard var queue = transferIdToMessageIDs[transferID] else { return }
if !queue.isEmpty { if !queue.isEmpty {
if queue.first == messageID { if queue.first == messageID {
@@ -507,10 +628,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) { func handleMediaSendFailure(messageID: String, reason: String) {
discardReconnectRetry(
messageID: messageID,
cancelActiveTransfer: false
)
context.updateMessageDeliveryStatus(messageID, status: .failed(reason: reason)) context.updateMessageDeliveryStatus(messageID, status: .failed(reason: reason))
clearTransferMapping(for: messageID) clearTransferMapping(for: messageID)
} }
@@ -518,21 +661,88 @@ final class ChatMediaTransferCoordinator {
func handleTransferEvent(_ event: TransferProgressManager.Event) { func handleTransferEvent(_ event: TransferProgressManager.Event) {
switch event { switch event {
case .started(let id, let total): 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)) context.updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: 0, total: total))
case .updated(let id, let sent, let 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)) context.updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: sent, total: total))
case .completed(let id, _): 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) context.updateMessageDeliveryStatus(messageID, status: .sent)
clearTransferMapping(for: messageID) 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, _, _): 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) clearTransferMapping(for: messageID)
context.removeMessage(withID: messageID, cleanupFile: true) context.removeMessage(withID: messageID, cleanupFile: true)
case .rejected(let id, let reason): 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) handleMediaSendFailure(messageID: messageID, reason: reason)
} }
} }
@@ -564,6 +774,10 @@ final class ChatMediaTransferCoordinator {
} }
func cancelMediaSend(messageID: String) { func cancelMediaSend(messageID: String) {
discardReconnectRetry(
messageID: messageID,
cancelActiveTransfer: false
)
if let transferId = messageIDToTransferId[messageID], if let transferId = messageIDToTransferId[messageID],
let active = transferIdToMessageIDs[transferId]?.first, let active = transferIdToMessageIDs[transferId]?.first,
active == messageID { active == messageID {
@@ -574,6 +788,10 @@ final class ChatMediaTransferCoordinator {
} }
func deleteMediaMessage(messageID: String) { func deleteMediaMessage(messageID: String) {
discardReconnectRetry(
messageID: messageID,
cancelActiveTransfer: false
)
// Delete is also a send cancellation. In particular, an approved // Delete is also a send cancellation. In particular, an approved
// legacy-clear send may still be waiting on BLEService.messageQueue; // legacy-clear send may still be waiting on BLEService.messageQueue;
// removing only the UI mapping would let that deferred work transmit. // removing only the UI mapping would let that deferred work transmit.
@@ -584,9 +802,611 @@ final class ChatMediaTransferCoordinator {
clearTransferMapping(for: messageID) clearTransferMapping(for: messageID)
context.removeMessage(withID: messageID, cleanupFile: true) 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()
}
} }
private extension 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<PrivateMediaReconnectRetryRecord>
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 { func applicationFilesDirectory() throws -> URL {
let base = try FileManager.default.url( let base = try FileManager.default.url(
for: .applicationSupportDirectory, for: .applicationSupportDirectory,
@@ -58,6 +58,7 @@ protocol ChatVerificationContext: AnyObject {
func noiseStaticPublicKeyData() -> Data func noiseStaticPublicKeyData() -> Data
func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool
func triggerHandshake(with peerID: PeerID) func triggerHandshake(with peerID: PeerID)
func privateMediaPeerDidAuthenticate(_ peerID: PeerID)
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
func sendVerifyResponse(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() meshService.noiseStaticPublicKeyData()
} }
func privateMediaPeerDidAuthenticate(_ peerID: PeerID) {
mediaTransferCoordinator.peerDidAuthenticate(peerID.toShort())
}
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA) meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA)
} }
@@ -129,6 +134,10 @@ extension ChatViewModel: ChatVerificationContext {
} }
} }
extension ChatVerificationContext {
func privateMediaPeerDidAuthenticate(_ peerID: PeerID) {}
}
@MainActor @MainActor
final class ChatVerificationCoordinator { final class ChatVerificationCoordinator {
struct PendingVerification { struct PendingVerification {
@@ -197,6 +206,7 @@ final class ChatVerificationCoordinator {
guard let self else { return } guard let self else { return }
SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security) SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security)
self.context.privateMediaPeerDidAuthenticate(peerID)
if self.context.isVerifiedFingerprint(fingerprint) { if self.context.isVerifiedFingerprint(fingerprint) {
self.context.setEncryptionStatus(.noiseVerified, for: peerID) self.context.setEncryptionStatus(.noiseVerified, for: peerID)
+4
View File
@@ -1638,6 +1638,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
case .peerConnected(let peerID): case .peerConnected(let peerID):
transportEventCoordinator.didConnectToPeerSynchronously(peerID) transportEventCoordinator.didConnectToPeerSynchronously(peerID)
mediaTransferCoordinator.peerDidReconnect(peerID)
case .peerDisconnected(let peerID): case .peerDisconnected(let peerID):
transportEventCoordinator.didDisconnectFromPeerSynchronously(peerID) transportEventCoordinator.didDisconnectFromPeerSynchronously(peerID)
@@ -1727,6 +1728,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
func didConnectToPeer(_ peerID: PeerID) { func didConnectToPeer(_ peerID: PeerID) {
transportEventCoordinator.didConnectToPeer(peerID) transportEventCoordinator.didConnectToPeer(peerID)
Task { @MainActor [weak self] in
self?.mediaTransferCoordinator.peerDidReconnect(peerID)
}
} }
func didDisconnectFromPeer(_ peerID: PeerID) { func didDisconnectFromPeer(_ peerID: PeerID) {
@@ -95,10 +95,20 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
transferId: String transferId: String
)] = [] )] = []
private(set) var privateFileLegacyAllowances: [Bool] = [] private(set) var privateFileLegacyAllowances: [Bool] = []
private(set) var privateFileReceiptRetryTransferIDs: [String] = []
private(set) var broadcastFileSends: [String] = [] private(set) var broadcastFileSends: [String] = []
private(set) var cancelledTransfers: [String] = [] private(set) var cancelledTransfers: [String] = []
private(set) var privateMediaPolicyResolutionRequests: [PeerID] = []
var privateMediaPolicy: PrivateMediaSendPolicy = .encrypted var privateMediaPolicy: PrivateMediaSendPolicy = .encrypted
var resolvedPrivateMediaPolicy: PrivateMediaSendPolicy? 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: [( private(set) var legacyConsentRequests: [(
id: UUID, id: UUID,
peerID: PeerID, peerID: PeerID,
@@ -113,11 +123,40 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
privateMediaPolicy privateMediaPolicy
} }
func authenticatedPrivateMediaReceiptSessionGeneration(
to peerID: PeerID
) -> UUID? {
supportsAuthenticatedPrivateMediaReceipts
? authenticatedPrivateMediaReceiptGeneration
: nil
}
func resolvePrivateMediaSendPolicy( func resolvePrivateMediaSendPolicy(
to peerID: PeerID, to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
) { ) {
privateMediaPolicyResolutionRequests.append(peerID)
if resolvesPrivateMediaPolicyImmediately {
completion(resolvedPrivateMediaPolicy ?? privateMediaPolicy) 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( func requestLegacyPrivateMediaConsent(
@@ -162,6 +201,16 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
privateFileLegacyAllowances.append(allowLegacyFallback) 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) { func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
broadcastFileSends.append(transferId) 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: 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 // MARK: - Coordinator Tests Against Mock Context
/// Exercises `ChatMediaTransferCoordinator` against /// Exercises `ChatMediaTransferCoordinator` against
@@ -638,6 +740,483 @@ struct ChatMediaTransferCoordinatorContextTests {
#expect(context.legacyConsentRequests.isEmpty) #expect(context.legacyConsentRequests.isEmpty)
#expect(context.privateFileSends.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 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 func makeCoordinatorTestImageURL() throws -> URL { private func makeCoordinatorTestImageURL() throws -> URL {
@@ -299,6 +299,217 @@ struct PrivateMediaEndToEndTests {
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .legacyRequiresConsent) #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 @Test
func capabilityAnnounceCannotPoisonPinWithoutMatchingNoiseAuthentication() async throws { func capabilityAnnounceCannotPoisonPinWithoutMatchingNoiseAuthentication() async throws {
let root = FileManager.default.temporaryDirectory let root = FileManager.default.temporaryDirectory
@@ -1267,6 +1478,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 final class MessageCaptureDelegate: BitchatDelegate, @unchecked Sendable {
private let lock = NSLock() private let lock = NSLock()
private var messages: [BitchatMessage] = [] private var messages: [BitchatMessage] = []