Retry confirmed private media after reconnect

This commit is contained in:
jack
2026-07-26 00:12:27 +02:00
parent 82ffdd560c
commit 62ee7755d3
9 changed files with 1806 additions and 13 deletions
@@ -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] = []