Correlate private media delivery receipts

This commit is contained in:
jack
2026-07-26 02:09:31 +02:00
parent 02f567229c
commit ae0563e830
12 changed files with 710 additions and 34 deletions
@@ -14,6 +14,7 @@ import BitFoundation
@MainActor
private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
var nickname = "me"
var myPeerID = PeerID(str: "0102030405060708")
var selectedPrivateChatPeer: PeerID?
var isViewingPublicMeshTimeline = false
var blockedPeers: Set<PeerID> = []
@@ -23,7 +24,10 @@ private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
private(set) var upsertedMessages: [(message: BitchatMessage, peerID: PeerID)] = []
private(set) var upsertedPublicMessages: [BitchatMessage] = []
private(set) var removedMessageIDs: [String] = []
private(set) var sentReadReceipts: [(receipt: ReadReceipt, peerID: PeerID)] = []
private(set) var talkerUpdates: [String?] = []
private(set) var privateMutationLog: [String] = []
private var readReceiptMessageIDs: Set<String> = []
func isPeerBlocked(_ peerID: PeerID) -> Bool { blockedPeers.contains(peerID) }
func resolveNickname(for peerID: PeerID) -> String { "alice" }
@@ -31,6 +35,7 @@ private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
func appendPublicMeshMessage(_ message: BitchatMessage) { appendedPublicMessages.append(message) }
func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID) {
upsertedMessages.append((message, peerID))
privateMutationLog.append("upsert:\(message.id)")
}
func upsertPublicMeshMessage(_ message: BitchatMessage) {
upsertedPublicMessages.append(message)
@@ -38,8 +43,18 @@ private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
@discardableResult
func removePrivateMessage(withID messageID: String) -> BitchatMessage? {
removedMessageIDs.append(messageID)
privateMutationLog.append("remove:\(messageID)")
return nil
}
func hasSentReadReceipt(_ messageID: String) -> Bool {
readReceiptMessageIDs.contains(messageID)
}
func markReadReceiptSent(_ messageID: String) -> Bool {
readReceiptMessageIDs.insert(messageID).inserted
}
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
sentReadReceipts.append((receipt, peerID))
}
func removeMessage(withID messageID: String, cleanupFile: Bool) {
removedMessageIDs.append(messageID)
}
@@ -151,17 +166,29 @@ struct ChatLiveVoiceCoordinatorTests {
@Test func absorbsFinalizedNoteIntoLiveBubble() throws {
let context = MockChatLiveVoiceContext()
context.selectedPrivateChatPeer = peer
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
let burstID = makeBurstID(0xB2)
let hex = burstID.hexEncodedString()
let fileName = "voice_\(hex).m4a"
let stableMessageID = try #require(PrivateMediaMessageIdentity.stableID(
senderPeerID: peer,
recipientPeerID: context.myPeerID,
fileName: fileName
))
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 7, count: 50)]))), to: coordinator, from: peer)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: peer)
let bubble = try #require(context.handledPrivateMessages.first)
// The user read the live bubble, then left before the finalized file
// arrived. Stable-ID adoption must preserve that read state.
#expect(context.markReadReceiptSent(bubble.id))
context.selectedPrivateChatPeer = nil
let note = BitchatMessage(
id: stableMessageID,
sender: "alice",
content: "[voice] voice_\(hex).m4a",
content: "[voice] \(fileName)",
timestamp: Date(),
isRelay: false,
isPrivate: true,
@@ -170,12 +197,21 @@ struct ChatLiveVoiceCoordinatorTests {
)
#expect(coordinator.absorbFinalizedVoiceNote(note))
// The note replaced the live bubble in place: same message ID, new
// content, partial capture deleted.
// The finalized note adopts the sender-correlatable ID, removes the
// receiver-local live ID, and emits a fresh READ now that the sender
// has created its finalized media row.
let replacement = try #require(context.upsertedMessages.last)
#expect(replacement.message.id == bubble.id)
#expect(replacement.message.id == stableMessageID)
#expect(replacement.message.content == note.content)
#expect(replacement.peerID == peer)
#expect(context.removedMessageIDs.contains(bubble.id))
#expect(Array(context.privateMutationLog.suffix(2)) == [
"upsert:\(stableMessageID)",
"remove:\(bubble.id)"
])
#expect(context.sentReadReceipts.count == 1)
#expect(context.sentReadReceipts.first?.receipt.originalMessageID == stableMessageID)
#expect(context.sentReadReceipts.first?.peerID == peer)
// The promoted partial capture is deleted in favor of the note.
let url = try #require(fallbackFileURL(burstID: burstID, peerID: peer))
#expect(!FileManager.default.fileExists(atPath: url.path))
@@ -449,7 +485,8 @@ struct ChatLiveVoiceCoordinatorTests {
isRelay: false, isPrivate: true, recipientNickname: "me", senderPeerID: peer
)
#expect(coordinator.absorbFinalizedVoiceNote(dmNote))
#expect(try #require(context.upsertedMessages.last).message.id == dmBubble.id)
#expect(try #require(context.upsertedMessages.last).message.id == dmNote.id)
#expect(context.removedMessageIDs.contains(dmBubble.id))
}
@Test func finalizedNoteBindsToItsAuthenticatedSender() throws {
@@ -479,8 +516,9 @@ struct ChatLiveVoiceCoordinatorTests {
)
#expect(coordinator.absorbFinalizedVoiceNote(note))
let replacement = try #require(context.upsertedMessages.last)
#expect(replacement.message.id == victimBubble.id)
#expect(replacement.message.id == note.id)
#expect(replacement.peerID == peer)
#expect(context.removedMessageIDs.contains(victimBubble.id))
// The attacker's note can only ever claim the attacker's own bubble.
let attackerNote = BitchatMessage(
@@ -489,8 +527,9 @@ struct ChatLiveVoiceCoordinatorTests {
)
#expect(coordinator.absorbFinalizedVoiceNote(attackerNote))
let attackerReplacement = try #require(context.upsertedMessages.last)
#expect(attackerReplacement.message.id == attackerBubble.id)
#expect(attackerReplacement.message.id == attackerNote.id)
#expect(attackerReplacement.peerID == attacker)
#expect(context.removedMessageIDs.contains(attackerBubble.id))
// Both registry entries are consumed nothing left to hijack.
#expect(!coordinator.absorbFinalizedVoiceNote(note))
@@ -89,7 +89,11 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
}
// Mesh file transfer
private(set) var privateFileSends: [(peerID: PeerID, transferId: String)] = []
private(set) var privateFileSends: [(
packet: BitchatFilePacket,
peerID: PeerID,
transferId: String
)] = []
private(set) var privateFileLegacyAllowances: [Bool] = []
private(set) var broadcastFileSends: [String] = []
private(set) var cancelledTransfers: [String] = []
@@ -154,7 +158,7 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
transferId: String,
allowLegacyFallback: Bool
) {
privateFileSends.append((peerID, transferId))
privateFileSends.append((packet, peerID, transferId))
privateFileLegacyAllowances.append(allowLegacyFallback)
}
@@ -172,19 +176,8 @@ private final class PausedVoiceNotePreparer: @unchecked Sendable {
private var started = false
private var released = false
private var finished = false
private let packet: BitchatFilePacket
init() {
let content = Data("voice".utf8)
packet = BitchatFilePacket(
fileName: "paused.m4a",
fileSize: UInt64(content.count),
mimeType: "audio/mp4",
content: content
)
}
func prepare(_: URL) throws -> BitchatFilePacket {
func prepare(_ url: URL) throws -> BitchatFilePacket {
condition.lock()
started = true
condition.broadcast()
@@ -194,7 +187,13 @@ private final class PausedVoiceNotePreparer: @unchecked Sendable {
finished = true
condition.broadcast()
condition.unlock()
return packet
let content = Data("voice".utf8)
return BitchatFilePacket(
fileName: url.lastPathComponent,
fileSize: UInt64(content.count),
mimeType: "audio/mp4",
content: content
)
}
var hasStarted: Bool {
@@ -452,6 +451,57 @@ struct ChatMediaTransferCoordinatorContextTests {
#expect(coordinator.transferIdToMessageIDs.isEmpty)
}
@Test @MainActor
func privateVoiceNoteUsesWireDerivableMessageID() async throws {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
context.selectedPrivateChatPeer = peerID
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("voice_receipt_\(UUID().uuidString).m4a")
try Data("voice".utf8).write(to: url)
defer { try? FileManager.default.removeItem(at: url) }
coordinator.sendVoiceNote(at: url)
#expect(await TestHelpers.waitUntil(
{ context.privateFileSends.count == 1 },
timeout: TestConstants.longTimeout
))
let message = try #require(context.privateChats[peerID]?.first)
let sentPacket = try #require(context.privateFileSends.first?.packet)
#expect(message.id == PrivateMediaMessageIdentity.stableID(
for: sentPacket,
senderPeerID: context.myPeerID,
recipientPeerID: peerID
))
}
@Test @MainActor
func privateImageUsesWireDerivableMessageID() async throws {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
let peerID = PeerID(str: "99aabbccddeeff00")
context.selectedPrivateChatPeer = peerID
let sourceURL = try makeCoordinatorTestImageURL()
defer { try? FileManager.default.removeItem(at: sourceURL) }
coordinator.sendImage(from: sourceURL)
#expect(await TestHelpers.waitUntil(
{ context.privateFileSends.count == 1 },
timeout: TestConstants.longTimeout
))
let message = try #require(context.privateChats[peerID]?.first)
let sentPacket = try #require(context.privateFileSends.first?.packet)
#expect(message.id == PrivateMediaMessageIdentity.stableID(
for: sentPacket,
senderPeerID: context.myPeerID,
recipientPeerID: peerID
))
coordinator.cleanupLocalFile(forMessage: message)
}
@Test @MainActor
func cancelVoiceNoteDuringDetachedPreparationCannotSendOrRestoreMapping() async throws {
let context = MockChatMediaTransferContext()
@@ -220,26 +220,64 @@ struct ChatTransportEventCoordinatorContextTests {
func didReceiveMessage_routesPrivateAndPublic_skipsBlockedAndEmpty() async {
let context = MockChatTransportEventContext()
let coordinator = ChatTransportEventCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
// Blocked messages are dropped before any handling.
context.blockedMessageIDs = ["blocked"]
context.blockedMessageIDs = ["blocked", "blocked-private"]
coordinator.didReceiveMessage(makeMessage(id: "blocked"))
coordinator.didReceiveMessage(makeMessage(
id: "blocked-private",
isPrivate: true,
senderPeerID: peerID
))
// Empty public content is dropped too.
coordinator.didReceiveMessage(makeMessage(id: "empty", content: " "))
await drainMainActorTasks()
#expect(context.handledPublicMessages.isEmpty)
#expect(context.handledPrivateMessages.isEmpty)
#expect(context.mentionCheckedMessageIDs.isEmpty)
#expect(context.meshDeliveryAcks.isEmpty)
// Private goes to the private handler, public to the public handler;
// both get mention checks and haptics.
coordinator.didReceiveMessage(makeMessage(id: "pm", isPrivate: true))
// both get mention checks and haptics. Only current-iOS media with a
// cross-device stable ID and authenticated sender gets the
// durable-arrival acknowledgement. Legacy/Android-style random IDs
// remain transfer-compatible but cannot correlate receipts.
let stableMediaID = "media-\(String(repeating: "a", count: 32))"
coordinator.didReceiveMessage(makeMessage(
id: stableMediaID,
isPrivate: true,
senderPeerID: peerID
))
coordinator.didReceiveMessage(makeMessage(
id: "legacy-media",
isPrivate: true,
senderPeerID: peerID
))
coordinator.didReceiveMessage(makeMessage(id: "pm-missing-sender", isPrivate: true))
coordinator.didReceiveMessage(makeMessage(id: "pub"))
await drainMainActorTasks()
#expect(context.handledPrivateMessages.map(\.id) == ["pm"])
#expect(context.handledPrivateMessages.map(\.id) == [
stableMediaID,
"legacy-media",
"pm-missing-sender"
])
#expect(context.handledPublicMessages.map(\.id) == ["pub"])
#expect(context.mentionCheckedMessageIDs == ["pm", "pub"])
#expect(context.hapticMessageIDs == ["pm", "pub"])
#expect(context.mentionCheckedMessageIDs == [
stableMediaID,
"legacy-media",
"pm-missing-sender",
"pub"
])
#expect(context.hapticMessageIDs == [
stableMediaID,
"legacy-media",
"pm-missing-sender",
"pub"
])
#expect(context.meshDeliveryAcks.count == 1)
#expect(context.meshDeliveryAcks.first?.messageID == stableMediaID)
#expect(context.meshDeliveryAcks.first?.peerID == peerID)
}
@Test @MainActor
@@ -866,7 +866,7 @@ struct PrivateMediaEndToEndTests {
+ marker
+ Data(repeating: 0x4A, count: 6 * 1024)
try await assertPrivateMediaRoundTrip(
fileName: "private.jpg",
fileName: "img_20260725_120000_11111111-1111-1111-1111-111111111111.jpg",
mimeType: "image/jpeg",
content: content,
marker: marker,
@@ -1137,6 +1137,17 @@ struct PrivateMediaEndToEndTests {
#expect(message.isPrivate)
#expect(message.senderPeerID == alice.myPeerID)
#expect(message.content.hasPrefix(expectedMessagePrefix))
if let stableMessageID = PrivateMediaMessageIdentity.stableID(
for: file,
senderPeerID: alice.myPeerID,
recipientPeerID: bob.myPeerID
) {
#expect(message.id == stableMessageID)
} else {
// Generic/legacy filenames retain random per-arrival IDs so two
// unrelated "photo.jpg" transfers are never deduplicated.
#expect(!message.id.hasPrefix("media-"))
}
let stored = recursivelyStoredFiles(under: bobRoot)
#expect(stored.count == 1)
@@ -1,3 +1,4 @@
import BitFoundation
import XCTest
@testable import bitchat
@@ -73,4 +74,85 @@ final class BitchatFilePacketTests: XCTestCase {
XCTAssertEqual(decoded.fileSize, UInt64(content.count))
XCTAssertEqual(decoded.content, content)
}
func testPrivateMediaMessageIdentityConvergesAcrossPeerIDAliases() throws {
let senderKey = Data(repeating: 0x11, count: 32)
let recipientKey = Data(repeating: 0x22, count: 32)
let senderStable = PeerID(hexData: senderKey)
let recipientStable = PeerID(hexData: recipientKey)
let fileName = "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
let senderID = try XCTUnwrap(PrivateMediaMessageIdentity.stableID(
senderPeerID: senderStable.toShort(),
recipientPeerID: PeerID(str: "mesh:\(recipientStable.toShort().bare)"),
fileName: fileName
))
let receiverID = try XCTUnwrap(PrivateMediaMessageIdentity.stableID(
senderPeerID: senderStable,
recipientPeerID: recipientStable.toShort(),
fileName: fileName
))
XCTAssertEqual(senderID, receiverID)
XCTAssertTrue(senderID.hasPrefix("media-"))
XCTAssertEqual(senderID.count, 38)
XCTAssertTrue(PrivateMediaMessageIdentity.isStableID(senderID))
XCTAssertFalse(PrivateMediaMessageIdentity.isStableID("media-\(String(repeating: "A", count: 32))"))
XCTAssertFalse(PrivateMediaMessageIdentity.isStableID("media-\(String(repeating: "a", count: 31))"))
XCTAssertFalse(PrivateMediaMessageIdentity.isStableID(UUID().uuidString))
}
func testPrivateMediaMessageIdentitySeparatesDirectionAndFilename() throws {
let alice = PeerID(str: "0011223344556677")
let bob = PeerID(str: "8899aabbccddeeff")
let firstName = "voice_20260725_105708_11111111-1111-1111-1111-111111111111.m4a"
let secondName = "voice_20260725_105709_22222222-2222-2222-2222-222222222222.m4a"
let first = try XCTUnwrap(PrivateMediaMessageIdentity.stableID(
senderPeerID: alice,
recipientPeerID: bob,
fileName: firstName
))
XCTAssertNotEqual(first, PrivateMediaMessageIdentity.stableID(
senderPeerID: bob,
recipientPeerID: alice,
fileName: firstName
))
XCTAssertNotEqual(first, PrivateMediaMessageIdentity.stableID(
senderPeerID: alice,
recipientPeerID: bob,
fileName: secondName
))
XCTAssertNil(PrivateMediaMessageIdentity.stableID(
senderPeerID: alice,
recipientPeerID: bob,
fileName: nil
))
XCTAssertNil(PrivateMediaMessageIdentity.stableID(
senderPeerID: alice,
recipientPeerID: bob,
fileName: "photo.jpg"
))
XCTAssertNil(PrivateMediaMessageIdentity.stableID(
senderPeerID: alice,
recipientPeerID: bob,
fileName: "img_11111111-1111-1111-1111-111111111111.pdf"
))
XCTAssertNotNil(PrivateMediaMessageIdentity.stableID(
senderPeerID: alice,
recipientPeerID: bob,
fileName: "voice_0011223344556677.m4a"
))
}
func testPrivateMediaMessageIdentityMatchesVersionOneGoldenVector() {
XCTAssertEqual(
PrivateMediaMessageIdentity.stableID(
senderPeerID: PeerID(str: "0011223344556677"),
recipientPeerID: PeerID(str: "8899aabbccddeeff"),
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
),
"media-910bd42c65060ab76bb6406f220c4516"
)
}
}
@@ -13,11 +13,20 @@ struct BLEFileTransferHandlerTests {
var signatureVerifyCount = 0
var signedNameQueries: [PeerID] = []
var blockedPeers: Set<PeerID> = []
var trackedPackets: [BitchatPacket] = []
var quotaReservations: [Int] = []
var saveCalls: [(data: Data, preferredName: String?, subdirectory: String, fallbackExtension: String?, defaultPrefix: String)] = []
var lastSeenUpdates: [PeerID] = []
var duplicateDeliveryAcks: [(messageID: String, peerID: PeerID)] = []
var deliveredMessages: [BitchatMessage] = []
var saveOverride: ((
_ data: Data,
_ preferredName: String?,
_ subdirectory: String,
_ fallbackExtension: String?,
_ defaultPrefix: String
) -> URL?)?
}
private let localPeerID = PeerID(str: "0102030405060708")
@@ -46,11 +55,20 @@ struct BLEFileTransferHandlerTests {
},
saveIncomingFile: { data, preferredName, subdirectory, fallbackExtension, defaultPrefix in
recorder.saveCalls.append((data, preferredName, subdirectory, fallbackExtension, defaultPrefix))
if let saveOverride = recorder.saveOverride {
return saveOverride(data, preferredName, subdirectory, fallbackExtension, defaultPrefix)
}
return recorder.saveResult
},
isPrivateMediaSenderBlocked: { peerID in
recorder.blockedPeers.contains(peerID)
},
updatePeerLastSeen: { peerID in
recorder.lastSeenUpdates.append(peerID)
},
acknowledgePrivateMediaDuplicate: { messageID, peerID in
recorder.duplicateDeliveryAcks.append((messageID, peerID))
},
deliverMessage: { message in
recorder.deliveredMessages.append(message)
}
@@ -284,6 +302,7 @@ struct BLEFileTransferHandlerTests {
#expect(recorder.lastSeenUpdates == [remotePeerID])
#expect(recorder.deliveredMessages.count == 1)
#expect(recorder.deliveredMessages.first?.isPrivate == true)
#expect(recorder.deliveredMessages.first?.id.hasPrefix("media-") == false)
// Must be explicit: BitchatMessage defaults private messages to
// .sending, which the media views render as an in-flight send
// (empty reveal mask, disabled reveal tap).
@@ -296,8 +315,9 @@ struct BLEFileTransferHandlerTests {
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
let handler = makeHandler(recorder: recorder)
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
let fileName = "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
let file = BitchatFilePacket(
fileName: "secret.jpg",
fileName: fileName,
fileSize: UInt64(content.count),
mimeType: "image/jpeg",
content: content
@@ -316,6 +336,173 @@ struct BLEFileTransferHandlerTests {
#expect(recorder.deliveredMessages.count == 1)
#expect(recorder.deliveredMessages.first?.isPrivate == true)
#expect(recorder.deliveredMessages.first?.timestamp == timestamp)
#expect(recorder.deliveredMessages.first?.id == PrivateMediaMessageIdentity.stableID(
senderPeerID: remotePeerID,
recipientPeerID: localPeerID,
fileName: fileName
))
}
@Test
func repeatedLegacyPrivateImageNamesKeepDistinctRandomMessageIDs() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
let handler = makeHandler(recorder: recorder)
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
let file = BitchatFilePacket(
fileName: "photo.jpg",
fileSize: UInt64(content.count),
mimeType: "image/jpeg",
content: content
)
let payload = try #require(file.encode())
#expect(handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_234)
))
#expect(handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_235)
))
#expect(recorder.deliveredMessages.count == 2)
#expect(recorder.deliveredMessages[0].id != recorder.deliveredMessages[1].id)
#expect(recorder.deliveredMessages.allSatisfy { !$0.id.hasPrefix("media-") })
}
@Test
func repeatedStablePrivateMediaIsAcknowledgedWithoutQuotaOrDiskWork() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
let handler = makeHandler(recorder: recorder)
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
let fileName = "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
let file = BitchatFilePacket(
fileName: fileName,
fileSize: UInt64(content.count),
mimeType: "image/jpeg",
content: content
)
let payload = try #require(file.encode())
let expectedID = try #require(PrivateMediaMessageIdentity.stableID(
senderPeerID: remotePeerID,
recipientPeerID: localPeerID,
fileName: fileName
))
#expect(handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_234)
))
#expect(handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_235)
))
#expect(recorder.quotaReservations == [content.count])
#expect(recorder.saveCalls.count == 1)
#expect(recorder.deliveredMessages.count == 1)
#expect(recorder.lastSeenUpdates == [remotePeerID, remotePeerID])
#expect(recorder.duplicateDeliveryAcks.count == 1)
#expect(recorder.duplicateDeliveryAcks.first?.messageID == expectedID)
#expect(recorder.duplicateDeliveryAcks.first?.peerID == remotePeerID)
}
@Test
func inFlightStableDuplicateIsNotAcknowledgedAndFailedSaveRemainsRetryable() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
let file = BitchatFilePacket(
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg",
fileSize: UInt64(content.count),
mimeType: "image/jpeg",
content: content
)
let payload = try #require(file.encode())
var handler: BLEFileTransferHandler!
var nestedResult: Bool?
var failFirstSave = true
recorder.saveOverride = { _, _, _, _, _ in
if failFirstSave {
failFirstSave = false
nestedResult = handler.handlePrivatePayload(
payload,
from: self.remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_235)
)
return nil
}
return recorder.saveResult
}
handler = makeHandler(recorder: recorder)
// The nested arrival sees the first reservation as pending. It is
// coalesced without an ACK; then the first durable save fails.
#expect(!handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_234)
))
#expect(nestedResult == true)
#expect(recorder.saveCalls.count == 1)
#expect(recorder.duplicateDeliveryAcks.isEmpty)
#expect(recorder.deliveredMessages.isEmpty)
// Failure released the reservation, so the sender's later retry can
// persist and deliver normally.
#expect(handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_236)
))
#expect(recorder.saveCalls.count == 2)
#expect(recorder.duplicateDeliveryAcks.isEmpty)
#expect(recorder.deliveredMessages.count == 1)
}
@Test
func blockedPrivateMediaIsDroppedBeforeQuotaDiskAndDedupState() throws {
let recorder = Recorder()
recorder.blockedPeers = [remotePeerID]
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
let handler = makeHandler(recorder: recorder)
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
let file = BitchatFilePacket(
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg",
fileSize: UInt64(content.count),
mimeType: "image/jpeg",
content: content
)
let payload = try #require(file.encode())
#expect(handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_234)
))
#expect(recorder.quotaReservations.isEmpty)
#expect(recorder.saveCalls.isEmpty)
#expect(recorder.lastSeenUpdates.isEmpty)
#expect(recorder.duplicateDeliveryAcks.isEmpty)
#expect(recorder.deliveredMessages.isEmpty)
// Unblocking must allow a retry through; the blocked attempt cannot
// poison the stable-ID dedup reservation.
recorder.blockedPeers = []
#expect(handler.handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_235)
))
#expect(recorder.saveCalls.count == 1)
#expect(recorder.deliveredMessages.count == 1)
}
@Test
@@ -337,6 +524,7 @@ struct BLEFileTransferHandlerTests {
#expect(recorder.quotaReservations.isEmpty)
#expect(recorder.saveCalls.isEmpty)
#expect(recorder.lastSeenUpdates.isEmpty)
#expect(recorder.duplicateDeliveryAcks.isEmpty)
#expect(recorder.deliveredMessages.isEmpty)
}