diff --git a/bitchat/Services/BLE/BLEIncomingFileStore.swift b/bitchat/Services/BLE/BLEIncomingFileStore.swift index a16efa87..9463c739 100644 --- a/bitchat/Services/BLE/BLEIncomingFileStore.swift +++ b/bitchat/Services/BLE/BLEIncomingFileStore.swift @@ -268,6 +268,17 @@ struct BLEIncomingFileStore { } } + /// Drops THIS instance's in-memory receipt index after a panic wipe. + /// + /// `panicWipe` already resets the receipt store it runs on, but the + /// production wipe runs on the `PanicRecoveryOperations.live()` file + /// store while receipt lookups are served by `BLEService`'s own + /// `incomingFileStore`. The service's panic path must invalidate its own + /// cache explicitly or pre-panic decisions survive in memory. + func resetPrivateMediaReceiptsForPanic() { + privateMediaReceipts.resetForPanic() + } + func privateMediaReceiptState( messageID: String ) -> BLEPrivateMediaReceiptState { diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 67f7eb05..265a4cc1 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -739,6 +739,12 @@ final class BLEService: NSObject { /// Reopen the radio only after media deletion and recovery-marker commit. func completePanicReset(restartServices: Bool) { + // The media wipe ran on the recovery operations' own file store; this + // service's store still caches pre-panic receipt decisions (and a + // callback drained during suspension may have re-read the pre-wipe + // ledger). Drop the cache before admission reopens so the next lookup + // rebuilds from the wiped directory. + incomingFileStore.resetPrivateMediaReceiptsForPanic() setPanicSuspended(false) guard restartServices else { return } startServices() @@ -3413,6 +3419,15 @@ extension BLEService { capturePanicLifecycleGeneration() != nil } + /// Queries the receipt store of the service's OWN incoming-file store — + /// the instance production lookups run against — so panic tests exercise + /// the real wiring instead of a same-instance shortcut. + func _test_privateMediaReceiptState( + messageID: String + ) -> BLEPrivateMediaReceiptState { + incomingFileStore.privateMediaReceiptState(messageID: messageID) + } + /// Models a CoreBluetooth delegate callback without requiring a physical /// peripheral. The callback itself runs on `bleQueue`, exactly where the /// panic radio-stop barrier must linearize it. diff --git a/bitchatTests/Services/BLEFileTransferHandlerTests.swift b/bitchatTests/Services/BLEFileTransferHandlerTests.swift index 618aa070..41f242e8 100644 --- a/bitchatTests/Services/BLEFileTransferHandlerTests.swift +++ b/bitchatTests/Services/BLEFileTransferHandlerTests.swift @@ -882,16 +882,40 @@ struct BLEFileTransferHandlerTests { let seed = BLEPrivateMediaReceiptStore(baseDirectory: base) #expect(seed.recordDeleted(messageID: messageID)) - let store = BLEIncomingFileStore(baseDirectory: base) + // Production wiring: receipt lookups run against the service's OWN + // incoming-file store while the panic wipe runs on the separate store + // `PanicRecoveryOperations.live()` constructs. The test must reset + // the instance BLEService uses, not a same-instance shortcut. + let keychain = MockKeychain() + let identityManager = MockIdentityManager(keychain) + let service = BLEService( + keychain: keychain, + idBridge: NostrIdentityBridge(keychain: MockKeychainHelper()), + identityManager: identityManager, + initializeBluetoothManagers: false, + incomingFileStore: BLEIncomingFileStore(baseDirectory: base) + ) #expect( - store.privateMediaReceiptState(messageID: messageID) + service._test_privateMediaReceiptState(messageID: messageID) == .tombstoned ) - try store.panicWipe() + service.suspendForPanicReset() + // A receive callback drained during suspension can still consult the + // ledger and re-cache the pre-wipe decision before media deletion. + #expect( + service._test_privateMediaReceiptState(messageID: messageID) + == .tombstoned + ) + + // The wipe itself runs on the recovery operations' distinct store, + // exactly like ChatViewModel's panic transaction. + let recoveryStore = BLEIncomingFileStore(baseDirectory: base) + try recoveryStore.panicWipe() + service.completePanicReset(restartServices: false) #expect( - store.privateMediaReceiptState(messageID: messageID) + service._test_privateMediaReceiptState(messageID: messageID) == .absent ) }