mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 06:45:20 +00:00
Retry confirmed private media after reconnect
This commit is contained in:
@@ -1119,6 +1119,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?
|
||||||
@@ -1603,6 +1626,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
|
||||||
@@ -1715,6 +1768,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(
|
||||||
@@ -1725,6 +1797,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(
|
||||||
@@ -1756,7 +1843,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
|
||||||
@@ -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 encrypted: Data
|
||||||
let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: typedPayload.first)
|
let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: typedPayload.first)
|
||||||
if isPrivateFile {
|
if isPrivateFile {
|
||||||
@@ -5174,6 +5270,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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -149,10 +215,34 @@ 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 prepareImagePacket: @Sendable (URL) throws -> ChatPreparedImage
|
private let prepareImagePacket: @Sendable (URL) throws -> ChatPreparedImage
|
||||||
|
private let reconnectRetryLimits: PrivateMediaReconnectRetryLimits
|
||||||
|
private let now: () -> Date
|
||||||
|
private let transferIDFactory: (String) -> String
|
||||||
|
|
||||||
private(set) var transferIdToMessageIDs: [String: [String]] = [:]
|
private(set) var transferIdToMessageIDs: [String: [String]] = [:]
|
||||||
private(set) var messageIDToTransferId: [String: String] = [:]
|
private(set) var messageIDToTransferId: [String: String] = [:]
|
||||||
private var preparationGeneration: UInt64 = 0
|
private var preparationGeneration: UInt64 = 0
|
||||||
|
private var reconnectRetryRecords: [
|
||||||
|
String: PrivateMediaReconnectRetryRecord
|
||||||
|
] = [:]
|
||||||
|
/// A newly authenticated session supersedes any raw-connect policy
|
||||||
|
/// resolution still in flight for that peer.
|
||||||
|
private var peersResolvingReconnectRetry: [
|
||||||
|
PeerID: (
|
||||||
|
id: UUID,
|
||||||
|
replacingActiveTransfer: Bool,
|
||||||
|
candidates: [PrivateMediaReconnectRetryCandidate]
|
||||||
|
)
|
||||||
|
] = [:]
|
||||||
|
private var reconnectRetryExpiryTasks: [String: Task<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,
|
||||||
@@ -161,11 +251,20 @@ final class ChatMediaTransferCoordinator {
|
|||||||
},
|
},
|
||||||
prepareImagePacket: @escaping @Sendable (URL) throws -> ChatPreparedImage = {
|
prepareImagePacket: @escaping @Sendable (URL) throws -> ChatPreparedImage = {
|
||||||
try ChatMediaPreparation.prepareImagePacket(from: $0)
|
try ChatMediaPreparation.prepareImagePacket(from: $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.prepareImagePacket = prepareImagePacket
|
self.prepareImagePacket = prepareImagePacket
|
||||||
|
self.reconnectRetryLimits = reconnectRetryLimits
|
||||||
|
self.now = now
|
||||||
|
self.transferIDFactory = transferIDFactory
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendVoiceNote(at url: URL) {
|
func sendVoiceNote(at url: URL) {
|
||||||
@@ -423,6 +522,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,
|
||||||
@@ -511,16 +616,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 {
|
||||||
@@ -530,10 +651,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)
|
||||||
}
|
}
|
||||||
@@ -541,21 +684,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -587,6 +797,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 {
|
||||||
@@ -597,6 +811,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.
|
||||||
@@ -608,11 +826,54 @@ final class ChatMediaTransferCoordinator {
|
|||||||
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()
|
||||||
|
}
|
||||||
|
|
||||||
/// Invalidates detached preparation work and cancels every transfer that
|
/// Invalidates detached preparation work and cancels every transfer that
|
||||||
/// reached the transport. Stale tasks check the generation before they
|
/// reached the transport. Stale tasks check the generation before they
|
||||||
/// can recreate a message or send after a panic wipe.
|
/// can recreate a message or send after a panic wipe.
|
||||||
func resetForPanic() {
|
func resetForPanic() {
|
||||||
preparationGeneration &+= 1
|
preparationGeneration &+= 1
|
||||||
|
peersResolvingReconnectRetry.removeAll(keepingCapacity: false)
|
||||||
|
for task in reconnectRetryExpiryTasks.values {
|
||||||
|
task.cancel()
|
||||||
|
}
|
||||||
|
reconnectRetryExpiryTasks.removeAll(keepingCapacity: false)
|
||||||
|
reconnectRetryRecords.removeAll(keepingCapacity: false)
|
||||||
let transferIDs = Set(transferIdToMessageIDs.keys)
|
let transferIDs = Set(transferIdToMessageIDs.keys)
|
||||||
transferIdToMessageIDs.removeAll(keepingCapacity: false)
|
transferIdToMessageIDs.removeAll(keepingCapacity: false)
|
||||||
messageIDToTransferId.removeAll(keepingCapacity: false)
|
messageIDToTransferId.removeAll(keepingCapacity: false)
|
||||||
@@ -623,6 +884,571 @@ final class ChatMediaTransferCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
@@ -1740,6 +1740,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)
|
||||||
@@ -1829,6 +1830,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
|
||||||
) {
|
) {
|
||||||
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(
|
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)
|
||||||
}
|
}
|
||||||
@@ -275,6 +324,59 @@ private final class PausedImagePreparer: @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
|
||||||
@@ -760,6 +862,548 @@ 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 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 func makeCoordinatorTestImageURL() throws -> URL {
|
private func makeCoordinatorTestImageURL() throws -> URL {
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ private final class MockChatVerificationContext: ChatVerificationContext {
|
|||||||
var noiseSessionKeysByPeerID: [PeerID: Data] = [:]
|
var noiseSessionKeysByPeerID: [PeerID: Data] = [:]
|
||||||
private(set) var installedCallbacks: (onPeerAuthenticated: (PeerID, String) -> Void, onHandshakeRequired: (PeerID) -> Void)?
|
private(set) var installedCallbacks: (onPeerAuthenticated: (PeerID, String) -> Void, onHandshakeRequired: (PeerID) -> Void)?
|
||||||
private(set) var triggeredHandshakes: [PeerID] = []
|
private(set) var triggeredHandshakes: [PeerID] = []
|
||||||
|
private(set) var privateMediaAuthenticatedPeers: [PeerID] = []
|
||||||
private(set) var sentChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
private(set) var sentChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||||
private(set) var sentResponses: [(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)
|
establishedNoiseSessions.contains(peerID)
|
||||||
}
|
}
|
||||||
func triggerHandshake(with peerID: PeerID) { triggeredHandshakes.append(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) {
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
||||||
sentChallenges.append((peerID, noiseKeyHex, nonceA))
|
sentChallenges.append((peerID, noiseKeyHex, nonceA))
|
||||||
@@ -277,6 +281,7 @@ struct ChatVerificationCoordinatorContextTests {
|
|||||||
#expect(context.encryptionStatuses[peerID] == .noiseVerified)
|
#expect(context.encryptionStatuses[peerID] == .noiseVerified)
|
||||||
#expect(context.stablePeerIDCache[peerID] == PeerID(hexData: noiseKey))
|
#expect(context.stablePeerIDCache[peerID] == PeerID(hexData: noiseKey))
|
||||||
#expect(context.invalidatedEncryptionCachePeers.contains(peerID))
|
#expect(context.invalidatedEncryptionCachePeers.contains(peerID))
|
||||||
|
#expect(context.privateMediaAuthenticatedPeers == [peerID])
|
||||||
|
|
||||||
// Handshake required -> handshaking status.
|
// Handshake required -> handshaking status.
|
||||||
callbacks?.onHandshakeRequired(peerID)
|
callbacks?.onHandshakeRequired(peerID)
|
||||||
|
|||||||
@@ -364,6 +364,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
|
||||||
@@ -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 final class MessageCaptureDelegate: BitchatDelegate, @unchecked Sendable {
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private var messages: [BitchatMessage] = []
|
private var messages: [BitchatMessage] = []
|
||||||
|
|||||||
Reference in New Issue
Block a user