mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 08:25:19 +00:00
Retry confirmed private media after reconnect
This commit is contained in:
@@ -95,10 +95,20 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
||||
transferId: String
|
||||
)] = []
|
||||
private(set) var privateFileLegacyAllowances: [Bool] = []
|
||||
private(set) var privateFileReceiptRetryTransferIDs: [String] = []
|
||||
private(set) var broadcastFileSends: [String] = []
|
||||
private(set) var cancelledTransfers: [String] = []
|
||||
private(set) var privateMediaPolicyResolutionRequests: [PeerID] = []
|
||||
var privateMediaPolicy: PrivateMediaSendPolicy = .encrypted
|
||||
var resolvedPrivateMediaPolicy: PrivateMediaSendPolicy?
|
||||
var resolvesPrivateMediaPolicyImmediately = true
|
||||
var supportsAuthenticatedPrivateMediaReceipts = false
|
||||
var authenticatedPrivateMediaReceiptGeneration = UUID(
|
||||
uuidString: "00000000-0000-0000-0000-000000000001"
|
||||
)!
|
||||
private var pendingPrivateMediaPolicyResolutions: [
|
||||
@MainActor (PrivateMediaSendPolicy) -> Void
|
||||
] = []
|
||||
private(set) var legacyConsentRequests: [(
|
||||
id: UUID,
|
||||
peerID: PeerID,
|
||||
@@ -113,11 +123,40 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
||||
privateMediaPolicy
|
||||
}
|
||||
|
||||
func authenticatedPrivateMediaReceiptSessionGeneration(
|
||||
to peerID: PeerID
|
||||
) -> UUID? {
|
||||
supportsAuthenticatedPrivateMediaReceipts
|
||||
? authenticatedPrivateMediaReceiptGeneration
|
||||
: nil
|
||||
}
|
||||
|
||||
func resolvePrivateMediaSendPolicy(
|
||||
to peerID: PeerID,
|
||||
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
|
||||
) {
|
||||
completion(resolvedPrivateMediaPolicy ?? privateMediaPolicy)
|
||||
privateMediaPolicyResolutionRequests.append(peerID)
|
||||
if resolvesPrivateMediaPolicyImmediately {
|
||||
completion(resolvedPrivateMediaPolicy ?? privateMediaPolicy)
|
||||
} else {
|
||||
pendingPrivateMediaPolicyResolutions.append(completion)
|
||||
}
|
||||
}
|
||||
|
||||
var pendingPrivateMediaPolicyResolutionCount: Int {
|
||||
pendingPrivateMediaPolicyResolutions.count
|
||||
}
|
||||
|
||||
func resolveNextPrivateMediaPolicy(
|
||||
_ policy: PrivateMediaSendPolicy? = nil
|
||||
) {
|
||||
guard !pendingPrivateMediaPolicyResolutions.isEmpty else { return }
|
||||
let completion = pendingPrivateMediaPolicyResolutions.removeFirst()
|
||||
completion(
|
||||
policy
|
||||
?? resolvedPrivateMediaPolicy
|
||||
?? privateMediaPolicy
|
||||
)
|
||||
}
|
||||
|
||||
func requestLegacyPrivateMediaConsent(
|
||||
@@ -162,6 +201,16 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
||||
privateFileLegacyAllowances.append(allowLegacyFallback)
|
||||
}
|
||||
|
||||
func sendFilePrivateReceiptRetry(
|
||||
_ packet: BitchatFilePacket,
|
||||
to peerID: PeerID,
|
||||
transferId: String
|
||||
) {
|
||||
privateFileSends.append((packet, peerID, transferId))
|
||||
privateFileLegacyAllowances.append(false)
|
||||
privateFileReceiptRetryTransferIDs.append(transferId)
|
||||
}
|
||||
|
||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
|
||||
broadcastFileSends.append(transferId)
|
||||
}
|
||||
@@ -851,6 +900,548 @@ struct ChatMediaTransferCoordinatorContextTests {
|
||||
#expect(context.legacyConsentRequests.isEmpty)
|
||||
#expect(context.privateFileSends.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func receiptCapableEncryptedMediaRetriesExactPacketAfterReconnect() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.supportsAuthenticatedPrivateMediaReceipts = true
|
||||
let fileName = "voice_0011223344556677.m4a"
|
||||
let preparer = StaticVoiceNotePreparer(fileName: fileName)
|
||||
let transferIDs = DeterministicMediaTransferIDFactory()
|
||||
let coordinator = ChatMediaTransferCoordinator(
|
||||
context: context,
|
||||
prepareVoiceNotePacket: { url in
|
||||
try preparer.prepare(url)
|
||||
},
|
||||
transferIDFactory: transferIDs.make
|
||||
)
|
||||
let url = try makeCoordinatorVoiceURL(fileName: fileName)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(
|
||||
at: url.deletingLastPathComponent()
|
||||
)
|
||||
}
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ context.privateFileSends.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
let messageID = try #require(
|
||||
context.privateChats[peerID]?.first?.id
|
||||
)
|
||||
let initial = try #require(
|
||||
context.privateFileSends.first
|
||||
)
|
||||
#expect(PrivateMediaMessageIdentity.isStableID(messageID))
|
||||
#expect(coordinator.retainedReconnectRetryCount == 1)
|
||||
|
||||
coordinator.handleTransferEvent(.completed(
|
||||
id: initial.transferId,
|
||||
totalFragments: 1
|
||||
))
|
||||
#expect(coordinator.retainedReconnectRetryCount == 1)
|
||||
#expect(context.deliveryStatusUpdates.last?.status == .sent)
|
||||
|
||||
coordinator.peerDidReconnect(peerID)
|
||||
#expect(context.privateFileSends.count == 2)
|
||||
let retry = try #require(context.privateFileSends.last)
|
||||
#expect(retry.packet.encode() == initial.packet.encode())
|
||||
#expect(retry.peerID == peerID)
|
||||
#expect(retry.transferId != initial.transferId)
|
||||
#expect(context.privateFileReceiptRetryTransferIDs == [
|
||||
retry.transferId
|
||||
])
|
||||
#expect(context.privateFileLegacyAllowances == [false, false])
|
||||
|
||||
coordinator.confirmPrivateMediaDelivery(messageID: messageID)
|
||||
#expect(coordinator.retainedReconnectRetryCount == 0)
|
||||
#expect(context.cancelledTransfers == [retry.transferId])
|
||||
#expect(context.removedMessages.isEmpty)
|
||||
#expect(!context.deliveryStatusUpdates.contains {
|
||||
if case .failed = $0.status { return true }
|
||||
return false
|
||||
})
|
||||
|
||||
// Receipt confirmation removes retry ownership before transport
|
||||
// cancellation, so its late callback cannot delete the delivered row
|
||||
// or re-arm another reconnect retry.
|
||||
coordinator.handleTransferEvent(.cancelled(
|
||||
id: retry.transferId,
|
||||
sentFragments: 1,
|
||||
totalFragments: 2
|
||||
))
|
||||
coordinator.peerDidReconnect(peerID)
|
||||
#expect(context.privateFileSends.count == 2)
|
||||
#expect(context.removedMessages.isEmpty)
|
||||
#expect(coordinator.messageIDToTransferId[messageID] == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func bit8OnlyEncryptedMediaNeverRetainsOrAutomaticallyRetries() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.privateMediaPolicy = .encrypted
|
||||
context.supportsAuthenticatedPrivateMediaReceipts = false
|
||||
let fileName = "voice_1111222233334444.m4a"
|
||||
let preparer = StaticVoiceNotePreparer(fileName: fileName)
|
||||
let coordinator = ChatMediaTransferCoordinator(
|
||||
context: context,
|
||||
prepareVoiceNotePacket: { url in
|
||||
try preparer.prepare(url)
|
||||
}
|
||||
)
|
||||
let url = try makeCoordinatorVoiceURL(fileName: fileName)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(
|
||||
at: url.deletingLastPathComponent()
|
||||
)
|
||||
}
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ context.privateFileSends.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
let initial = try #require(context.privateFileSends.first)
|
||||
coordinator.handleTransferEvent(.completed(
|
||||
id: initial.transferId,
|
||||
totalFragments: 1
|
||||
))
|
||||
coordinator.peerDidReconnect(peerID)
|
||||
coordinator.peerDidAuthenticate(peerID)
|
||||
|
||||
#expect(coordinator.retainedReconnectRetryCount == 0)
|
||||
#expect(context.privateFileSends.count == 1)
|
||||
#expect(context.privateFileReceiptRetryTransferIDs.isEmpty)
|
||||
#expect(context.privateMediaPolicyResolutionRequests.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func consentedRawLegacyMediaNeverEntersAutomaticRetry() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.privateMediaPolicy = .legacyRequiresConsent
|
||||
// Even a contradictory stale bit-9 observation must not retain an
|
||||
// invocation that actually selected the explicit raw path.
|
||||
context.supportsAuthenticatedPrivateMediaReceipts = true
|
||||
let fileName = "voice_2222333344445555.m4a"
|
||||
let preparer = StaticVoiceNotePreparer(fileName: fileName)
|
||||
let coordinator = ChatMediaTransferCoordinator(
|
||||
context: context,
|
||||
prepareVoiceNotePacket: { url in
|
||||
try preparer.prepare(url)
|
||||
}
|
||||
)
|
||||
let url = try makeCoordinatorVoiceURL(fileName: fileName)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(
|
||||
at: url.deletingLastPathComponent()
|
||||
)
|
||||
}
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ context.legacyConsentRequests.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
context.resolveNextLegacyConsent(true)
|
||||
let initial = try #require(context.privateFileSends.first)
|
||||
#expect(context.privateFileLegacyAllowances == [true])
|
||||
#expect(coordinator.retainedReconnectRetryCount == 0)
|
||||
|
||||
coordinator.handleTransferEvent(.completed(
|
||||
id: initial.transferId,
|
||||
totalFragments: 1
|
||||
))
|
||||
context.privateMediaPolicy = .encrypted
|
||||
coordinator.peerDidReconnect(peerID)
|
||||
coordinator.peerDidAuthenticate(peerID)
|
||||
|
||||
#expect(context.privateFileSends.count == 1)
|
||||
#expect(context.privateFileReceiptRetryTransferIDs.isEmpty)
|
||||
#expect(coordinator.retainedReconnectRetryCount == 0)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func authenticatedGenerationSupersedesStaleReconnectResolution() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.supportsAuthenticatedPrivateMediaReceipts = true
|
||||
context.resolvesPrivateMediaPolicyImmediately = false
|
||||
let oldGeneration = context
|
||||
.authenticatedPrivateMediaReceiptGeneration
|
||||
let newGeneration = UUID(
|
||||
uuidString: "00000000-0000-0000-0000-000000000002"
|
||||
)!
|
||||
let fileName = "voice_3333444455556666.m4a"
|
||||
let preparer = StaticVoiceNotePreparer(fileName: fileName)
|
||||
let transferIDs = DeterministicMediaTransferIDFactory()
|
||||
let coordinator = ChatMediaTransferCoordinator(
|
||||
context: context,
|
||||
prepareVoiceNotePacket: { url in
|
||||
try preparer.prepare(url)
|
||||
},
|
||||
transferIDFactory: transferIDs.make
|
||||
)
|
||||
let url = try makeCoordinatorVoiceURL(fileName: fileName)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(
|
||||
at: url.deletingLastPathComponent()
|
||||
)
|
||||
}
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ context.privateFileSends.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
let initial = try #require(context.privateFileSends.first)
|
||||
coordinator.peerDidReconnect(peerID)
|
||||
#expect(context.pendingPrivateMediaPolicyResolutionCount == 1)
|
||||
|
||||
context.authenticatedPrivateMediaReceiptGeneration = newGeneration
|
||||
coordinator.peerDidAuthenticate(peerID)
|
||||
#expect(context.pendingPrivateMediaPolicyResolutionCount == 2)
|
||||
|
||||
// The old-generation completion lost ownership and is inert.
|
||||
context.resolveNextPrivateMediaPolicy(.encrypted)
|
||||
#expect(context.privateFileReceiptRetryTransferIDs.isEmpty)
|
||||
#expect(context.cancelledTransfers.isEmpty)
|
||||
|
||||
context.resolveNextPrivateMediaPolicy(.encrypted)
|
||||
#expect(context.cancelledTransfers == [initial.transferId])
|
||||
#expect(context.privateFileReceiptRetryTransferIDs.count == 1)
|
||||
#expect(
|
||||
context.authenticatedPrivateMediaReceiptGeneration
|
||||
== newGeneration
|
||||
)
|
||||
#expect(oldGeneration != newGeneration)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func panicClearsRetainedRetryPendingResolutionAndExpiry() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.supportsAuthenticatedPrivateMediaReceipts = true
|
||||
context.resolvesPrivateMediaPolicyImmediately = false
|
||||
let fileName = "voice_3333444455556666.m4a"
|
||||
let preparer = StaticVoiceNotePreparer(fileName: fileName)
|
||||
let coordinator = ChatMediaTransferCoordinator(
|
||||
context: context,
|
||||
prepareVoiceNotePacket: { url in
|
||||
try preparer.prepare(url)
|
||||
},
|
||||
reconnectRetryLimits: PrivateMediaReconnectRetryLimits(
|
||||
maxRetainedPackets: 1,
|
||||
maxRetainedBytes: 1_024,
|
||||
maxRetriesPerMessage: 1,
|
||||
retentionSeconds: 0.1,
|
||||
maxRetriesPerReconnect: 1
|
||||
)
|
||||
)
|
||||
let url = try makeCoordinatorVoiceURL(fileName: fileName)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(
|
||||
at: url.deletingLastPathComponent()
|
||||
)
|
||||
}
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ context.privateFileSends.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
let initial = try #require(context.privateFileSends.first)
|
||||
coordinator.handleTransferEvent(.completed(
|
||||
id: initial.transferId,
|
||||
totalFragments: 1
|
||||
))
|
||||
#expect(coordinator.retainedReconnectRetryCount == 1)
|
||||
|
||||
coordinator.peerDidReconnect(peerID)
|
||||
#expect(context.pendingPrivateMediaPolicyResolutionCount == 1)
|
||||
let failedBeforePanic = context.deliveryStatusUpdates.filter {
|
||||
if case .failed = $0.status { return true }
|
||||
return false
|
||||
}.count
|
||||
|
||||
coordinator.resetForPanic()
|
||||
context.resolveNextPrivateMediaPolicy(.encrypted)
|
||||
try await Task.sleep(nanoseconds: 250_000_000)
|
||||
coordinator._test_expireReconnectRetries()
|
||||
|
||||
#expect(coordinator.retainedReconnectRetryCount == 0)
|
||||
#expect(coordinator.retainedReconnectRetryBytes == 0)
|
||||
#expect(coordinator.transferIdToMessageIDs.isEmpty)
|
||||
#expect(coordinator.messageIDToTransferId.isEmpty)
|
||||
#expect(context.privateFileSends.count == 1)
|
||||
#expect(context.privateFileReceiptRetryTransferIDs.isEmpty)
|
||||
#expect(context.deliveryStatusUpdates.filter {
|
||||
if case .failed = $0.status { return true }
|
||||
return false
|
||||
}.count == failedBeforePanic)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func retryCountAndRetentionTimeEndInVisibleFailure() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.supportsAuthenticatedPrivateMediaReceipts = true
|
||||
let clock = MutableMediaRetryClock(
|
||||
Date(timeIntervalSince1970: 4_000)
|
||||
)
|
||||
let limits = PrivateMediaReconnectRetryLimits(
|
||||
maxRetainedPackets: 2,
|
||||
maxRetainedBytes: 1_024,
|
||||
maxRetriesPerMessage: 1,
|
||||
retentionSeconds: 10,
|
||||
maxRetriesPerReconnect: 1
|
||||
)
|
||||
let fileName = "voice_4444555566667777.m4a"
|
||||
let preparer = StaticVoiceNotePreparer(fileName: fileName)
|
||||
let transferIDs = DeterministicMediaTransferIDFactory()
|
||||
let coordinator = ChatMediaTransferCoordinator(
|
||||
context: context,
|
||||
prepareVoiceNotePacket: { url in
|
||||
try preparer.prepare(url)
|
||||
},
|
||||
reconnectRetryLimits: limits,
|
||||
now: clock.now,
|
||||
transferIDFactory: transferIDs.make
|
||||
)
|
||||
let url = try makeCoordinatorVoiceURL(fileName: fileName)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(
|
||||
at: url.deletingLastPathComponent()
|
||||
)
|
||||
}
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ context.privateFileSends.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
let initial = try #require(context.privateFileSends.first)
|
||||
coordinator.handleTransferEvent(.completed(
|
||||
id: initial.transferId,
|
||||
totalFragments: 1
|
||||
))
|
||||
coordinator.peerDidReconnect(peerID)
|
||||
let retryID = try #require(
|
||||
context.privateFileReceiptRetryTransferIDs.first
|
||||
)
|
||||
coordinator.handleTransferEvent(.cancelled(
|
||||
id: retryID,
|
||||
sentFragments: 0,
|
||||
totalFragments: 1
|
||||
))
|
||||
|
||||
#expect(coordinator.retainedReconnectRetryCount == 0)
|
||||
#expect(context.removedMessages.isEmpty)
|
||||
#expect(context.deliveryStatusUpdates.contains {
|
||||
$0.messageID.hasPrefix("media-")
|
||||
&& $0.status == .failed(reason: String(
|
||||
localized: "content.delivery.reason.not_delivered",
|
||||
defaultValue: "Not delivered",
|
||||
comment: "Failure reason shown when a private media transfer could not finish"
|
||||
))
|
||||
})
|
||||
|
||||
// A separate retained row that locally completed but never received a
|
||||
// remote receipt expires to a distinct visible failure.
|
||||
let ttlFileName = "voice_5555666677778888.m4a"
|
||||
let ttlPreparer = StaticVoiceNotePreparer(
|
||||
fileName: ttlFileName
|
||||
)
|
||||
let ttlCoordinator = ChatMediaTransferCoordinator(
|
||||
context: context,
|
||||
prepareVoiceNotePacket: { url in
|
||||
try ttlPreparer.prepare(url)
|
||||
},
|
||||
reconnectRetryLimits: limits,
|
||||
now: clock.now,
|
||||
transferIDFactory: transferIDs.make
|
||||
)
|
||||
let ttlURL = try makeCoordinatorVoiceURL(
|
||||
fileName: ttlFileName
|
||||
)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(
|
||||
at: ttlURL.deletingLastPathComponent()
|
||||
)
|
||||
}
|
||||
let sendsBeforeTTL = context.privateFileSends.count
|
||||
ttlCoordinator.sendVoiceNote(at: ttlURL)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{
|
||||
context.privateFileSends.count
|
||||
== sendsBeforeTTL + 1
|
||||
},
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
let ttlInitial = try #require(context.privateFileSends.last)
|
||||
ttlCoordinator.handleTransferEvent(.completed(
|
||||
id: ttlInitial.transferId,
|
||||
totalFragments: 1
|
||||
))
|
||||
clock.advance(by: 10)
|
||||
ttlCoordinator._test_expireReconnectRetries()
|
||||
|
||||
#expect(ttlCoordinator.retainedReconnectRetryCount == 0)
|
||||
#expect(context.deliveryStatusUpdates.contains {
|
||||
$0.status == .failed(
|
||||
reason: String(
|
||||
localized:
|
||||
"content.delivery.reason.private_media_delivery_unconfirmed",
|
||||
defaultValue: "Delivery could not be confirmed"
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func retentionAndPerReconnectWorkAreBounded() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.supportsAuthenticatedPrivateMediaReceipts = true
|
||||
let limits = PrivateMediaReconnectRetryLimits(
|
||||
maxRetainedPackets: 2,
|
||||
maxRetainedBytes: 10,
|
||||
maxRetriesPerMessage: 2,
|
||||
retentionSeconds: 120,
|
||||
maxRetriesPerReconnect: 1
|
||||
)
|
||||
let transferIDs = DeterministicMediaTransferIDFactory()
|
||||
let coordinator = ChatMediaTransferCoordinator(
|
||||
context: context,
|
||||
prepareVoiceNotePacket: { url in
|
||||
let content = Data("voice".utf8)
|
||||
return BitchatFilePacket(
|
||||
fileName: url.lastPathComponent,
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "audio/mp4",
|
||||
content: content
|
||||
)
|
||||
},
|
||||
reconnectRetryLimits: limits,
|
||||
transferIDFactory: transferIDs.make
|
||||
)
|
||||
let fileNames = [
|
||||
"voice_6666777788889999.m4a",
|
||||
"voice_777788889999aaaa.m4a",
|
||||
"voice_88889999aaaabbbb.m4a"
|
||||
]
|
||||
var roots: [URL] = []
|
||||
defer {
|
||||
for root in roots {
|
||||
try? FileManager.default.removeItem(at: root)
|
||||
}
|
||||
}
|
||||
|
||||
for fileName in fileNames {
|
||||
let url = try makeCoordinatorVoiceURL(
|
||||
fileName: fileName,
|
||||
bytes: Data("voice".utf8)
|
||||
)
|
||||
roots.append(url.deletingLastPathComponent())
|
||||
// The production preparer preserves this stable filename.
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
}
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ context.privateFileSends.count == 3 },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
#expect(coordinator.retainedReconnectRetryCount == 2)
|
||||
#expect(coordinator.retainedReconnectRetryBytes <= 10)
|
||||
|
||||
for send in context.privateFileSends {
|
||||
coordinator.handleTransferEvent(.completed(
|
||||
id: send.transferId,
|
||||
totalFragments: 1
|
||||
))
|
||||
}
|
||||
coordinator.peerDidReconnect(peerID)
|
||||
#expect(context.privateFileReceiptRetryTransferIDs.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func userCancellationReleasesRetainedBytesAndIgnoresLateEvent() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.supportsAuthenticatedPrivateMediaReceipts = true
|
||||
let fileName = "voice_9999aaaabbbbcccc.m4a"
|
||||
let preparer = StaticVoiceNotePreparer(fileName: fileName)
|
||||
let coordinator = ChatMediaTransferCoordinator(
|
||||
context: context,
|
||||
prepareVoiceNotePacket: { url in
|
||||
try preparer.prepare(url)
|
||||
}
|
||||
)
|
||||
let url = try makeCoordinatorVoiceURL(fileName: fileName)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(
|
||||
at: url.deletingLastPathComponent()
|
||||
)
|
||||
}
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ context.privateFileSends.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
let messageID = try #require(
|
||||
context.privateChats[peerID]?.first?.id
|
||||
)
|
||||
let transferID = try #require(
|
||||
context.privateFileSends.first?.transferId
|
||||
)
|
||||
|
||||
coordinator.cancelMediaSend(messageID: messageID)
|
||||
coordinator.handleTransferEvent(.cancelled(
|
||||
id: transferID,
|
||||
sentFragments: 0,
|
||||
totalFragments: 1
|
||||
))
|
||||
|
||||
#expect(coordinator.retainedReconnectRetryCount == 0)
|
||||
#expect(coordinator.retainedReconnectRetryBytes == 0)
|
||||
#expect(context.cancelledTransfers == [transferID])
|
||||
#expect(context.removedMessages.map(\.messageID) == [
|
||||
messageID
|
||||
])
|
||||
#expect(!context.deliveryStatusUpdates.contains {
|
||||
if case .failed = $0.status { return true }
|
||||
return false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private func makeCoordinatorVoiceURL(
|
||||
fileName: String,
|
||||
bytes: Data = Data("voice".utf8)
|
||||
) throws -> URL {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(
|
||||
"media-retry-\(UUID().uuidString)",
|
||||
isDirectory: true
|
||||
)
|
||||
try FileManager.default.createDirectory(
|
||||
at: directory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let url = directory.appendingPathComponent(fileName)
|
||||
try bytes.write(to: url)
|
||||
return url
|
||||
}
|
||||
|
||||
private final class PausedImagePreparer: @unchecked Sendable {
|
||||
|
||||
@@ -97,6 +97,7 @@ private final class MockChatVerificationContext: ChatVerificationContext {
|
||||
var noiseSessionKeysByPeerID: [PeerID: Data] = [:]
|
||||
private(set) var installedCallbacks: (onPeerAuthenticated: (PeerID, String) -> Void, onHandshakeRequired: (PeerID) -> Void)?
|
||||
private(set) var triggeredHandshakes: [PeerID] = []
|
||||
private(set) var privateMediaAuthenticatedPeers: [PeerID] = []
|
||||
private(set) var sentChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||
private(set) var sentResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||
|
||||
@@ -113,6 +114,9 @@ private final class MockChatVerificationContext: ChatVerificationContext {
|
||||
establishedNoiseSessions.contains(peerID)
|
||||
}
|
||||
func triggerHandshake(with peerID: PeerID) { triggeredHandshakes.append(peerID) }
|
||||
func privateMediaPeerDidAuthenticate(_ peerID: PeerID) {
|
||||
privateMediaAuthenticatedPeers.append(peerID)
|
||||
}
|
||||
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
||||
sentChallenges.append((peerID, noiseKeyHex, nonceA))
|
||||
@@ -277,6 +281,7 @@ struct ChatVerificationCoordinatorContextTests {
|
||||
#expect(context.encryptionStatuses[peerID] == .noiseVerified)
|
||||
#expect(context.stablePeerIDCache[peerID] == PeerID(hexData: noiseKey))
|
||||
#expect(context.invalidatedEncryptionCachePeers.contains(peerID))
|
||||
#expect(context.privateMediaAuthenticatedPeers == [peerID])
|
||||
|
||||
// Handshake required -> handshaking status.
|
||||
callbacks?.onHandshakeRequired(peerID)
|
||||
|
||||
@@ -364,6 +364,217 @@ struct PrivateMediaEndToEndTests {
|
||||
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .legacyRequiresConsent)
|
||||
}
|
||||
|
||||
@Test
|
||||
func privateMediaRetryRequiresExactAuthenticatedBit9Proof() async throws {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(
|
||||
"private-media-receipt-proof-\(UUID().uuidString)",
|
||||
isDirectory: true
|
||||
)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let alice = makeService(
|
||||
baseDirectory: root.appendingPathComponent(
|
||||
"alice",
|
||||
isDirectory: true
|
||||
)
|
||||
)
|
||||
let bob = makeService(
|
||||
baseDirectory: root.appendingPathComponent(
|
||||
"bob",
|
||||
isDirectory: true
|
||||
)
|
||||
)
|
||||
let bothCapabilities: PeerCapabilities = [
|
||||
.privateMedia,
|
||||
.privateMediaReceipts
|
||||
]
|
||||
|
||||
// A public bit-9 announce is discovery only.
|
||||
alice._test_seedConnectedPeer(
|
||||
bob.myPeerID,
|
||||
nickname: "Bob",
|
||||
capabilities: bothCapabilities,
|
||||
noisePublicKey: bob.noiseStaticPublicKeyData()
|
||||
)
|
||||
#expect(
|
||||
alice.authenticatedPrivateMediaReceiptSessionGeneration(
|
||||
to: bob.myPeerID
|
||||
) == nil
|
||||
)
|
||||
|
||||
let proofs = try await establishSessionCapturingPeerState(
|
||||
alice: alice,
|
||||
bob: bob
|
||||
)
|
||||
#expect(
|
||||
alice.authenticatedPrivateMediaReceiptSessionGeneration(
|
||||
to: bob.myPeerID
|
||||
) == nil
|
||||
)
|
||||
|
||||
// Bit 8 alone preserves encrypted transfer compatibility but cannot
|
||||
// authorize automatic resend.
|
||||
let privateMediaOnly = try authenticatedPeerStatePacket(
|
||||
from: bob,
|
||||
to: alice,
|
||||
capabilities: .privateMedia
|
||||
)
|
||||
alice._test_handlePacket(
|
||||
privateMediaOnly,
|
||||
fromPeerID: bob.myPeerID
|
||||
)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{
|
||||
alice.privateMediaSendPolicy(to: bob.myPeerID)
|
||||
== .encrypted
|
||||
},
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
#expect(
|
||||
alice.authenticatedPrivateMediaReceiptSessionGeneration(
|
||||
to: bob.myPeerID
|
||||
) == nil
|
||||
)
|
||||
|
||||
let receiptCapable = try authenticatedPeerStatePacket(
|
||||
from: bob,
|
||||
to: alice,
|
||||
capabilities: bothCapabilities
|
||||
)
|
||||
alice._test_handlePacket(
|
||||
receiptCapable,
|
||||
fromPeerID: bob.myPeerID
|
||||
)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{
|
||||
alice.authenticatedPrivateMediaReceiptSessionGeneration(
|
||||
to: bob.myPeerID
|
||||
) != nil
|
||||
},
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
|
||||
bob._test_handlePacket(
|
||||
proofs.alice,
|
||||
fromPeerID: alice.myPeerID
|
||||
)
|
||||
alice._test_onOutboundPacket = nil
|
||||
bob._test_onOutboundPacket = nil
|
||||
}
|
||||
|
||||
@Test
|
||||
func receiptRetryRechecksBit9AtDeferredTransportBoundary() async throws {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(
|
||||
"private-media-retry-proof-race-\(UUID().uuidString)",
|
||||
isDirectory: true
|
||||
)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let alice = makeService(
|
||||
baseDirectory: root.appendingPathComponent(
|
||||
"alice",
|
||||
isDirectory: true
|
||||
)
|
||||
)
|
||||
let bob = makeService(
|
||||
baseDirectory: root.appendingPathComponent(
|
||||
"bob",
|
||||
isDirectory: true
|
||||
)
|
||||
)
|
||||
let receiptCapabilities: PeerCapabilities = [
|
||||
.privateMedia,
|
||||
.privateMediaReceipts
|
||||
]
|
||||
alice._test_seedConnectedPeer(
|
||||
bob.myPeerID,
|
||||
nickname: "Bob",
|
||||
capabilities: receiptCapabilities,
|
||||
noisePublicKey: bob.noiseStaticPublicKeyData()
|
||||
)
|
||||
bob._test_seedConnectedPeer(
|
||||
alice.myPeerID,
|
||||
nickname: "Alice",
|
||||
capabilities: receiptCapabilities,
|
||||
noisePublicKey: alice.noiseStaticPublicKeyData()
|
||||
)
|
||||
try await establishSession(alice: alice, bob: bob)
|
||||
#expect(
|
||||
alice.authenticatedPrivateMediaReceiptSessionGeneration(
|
||||
to: bob.myPeerID
|
||||
) != nil
|
||||
)
|
||||
|
||||
let privateMediaOnly = try authenticatedPeerStatePacket(
|
||||
from: bob,
|
||||
to: alice,
|
||||
capabilities: .privateMedia
|
||||
)
|
||||
let transferID =
|
||||
"receipt-proof-race-\(UUID().uuidString)"
|
||||
let tap = PacketTap()
|
||||
let boundaryProofs = ReceiptCapabilityRecorder()
|
||||
let rejections = TransferCancellationRecorder()
|
||||
let cancellable = TransferProgressManager.shared.publisher.sink {
|
||||
rejections.record($0)
|
||||
}
|
||||
alice._test_onOutboundPacket = tap.record
|
||||
alice._test_beforePrivateMediaDeferredSend = { id in
|
||||
guard id == transferID else { return }
|
||||
boundaryProofs.record(
|
||||
alice
|
||||
.authenticatedPrivateMediaReceiptSessionGeneration(
|
||||
to: bob.myPeerID
|
||||
) != nil
|
||||
)
|
||||
}
|
||||
defer {
|
||||
alice._test_beforePrivateMediaDeferredSend = nil
|
||||
alice._test_onOutboundPacket = nil
|
||||
}
|
||||
|
||||
// Rotate authenticated state before the deferred retry reaches its
|
||||
// admission boundary.
|
||||
alice._test_handlePacket(
|
||||
privateMediaOnly,
|
||||
fromPeerID: bob.myPeerID
|
||||
)
|
||||
let content = Data("%PDF-1.7\nreceipt-proof-race".utf8)
|
||||
alice.sendFilePrivateReceiptRetry(
|
||||
BitchatFilePacket(
|
||||
fileName: "receipt-proof-race.pdf",
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "application/pdf",
|
||||
content: content
|
||||
),
|
||||
to: bob.myPeerID,
|
||||
transferId: transferID
|
||||
)
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ boundaryProofs.snapshot() == [false] },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
await alice._test_drainPrivateMediaSendPipeline()
|
||||
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ rejections.contains(transferID) },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
#expect(tap.snapshot().allSatisfy {
|
||||
$0.type != MessageType.fileTransfer.rawValue
|
||||
&& !(
|
||||
$0.type == MessageType.noiseEncrypted.rawValue
|
||||
&& $0.version == 2
|
||||
)
|
||||
})
|
||||
let state = alice._test_privateMediaTransferState(
|
||||
transferId: transferID
|
||||
)
|
||||
#expect(!state.admissionActive)
|
||||
#expect(!state.pendingNoise)
|
||||
_ = cancellable
|
||||
}
|
||||
|
||||
@Test
|
||||
func capabilityAnnounceCannotPoisonPinWithoutMatchingNoiseAuthentication() async throws {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
@@ -1397,6 +1608,23 @@ private final class PrivateMediaPolicyRecorder: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
private final class ReceiptCapabilityRecorder: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var values: [Bool] = []
|
||||
|
||||
func record(_ value: Bool) {
|
||||
lock.lock()
|
||||
values.append(value)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func snapshot() -> [Bool] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return values
|
||||
}
|
||||
}
|
||||
|
||||
private final class MessageCaptureDelegate: BitchatDelegate, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var messages: [BitchatMessage] = []
|
||||
|
||||
Reference in New Issue
Block a user