diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index ddf10da0..c67fb273 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -744,9 +744,8 @@ final class BLEService: NSObject { ingressLinks.removeAll() recentTrafficTracker.removeAll() scheduledRelays.cancelAll() - let policyCompletions = pendingPrivateMediaPolicyResolutions.values.flatMap { - Array($0.completions.values) - } + // These callbacks belong to pre-panic transfer state. Invoking + // them would let queued UI work recreate or resend wiped media. pendingPrivateMediaPolicyResolutions.removeAll() privateMediaSessionGenerations.removeAll() authenticatedPeerStates.removeAll() @@ -755,14 +754,13 @@ final class BLEService: NSObject { authenticatedPeerStateSendProgress.removeAll() // Let the post-panic identity publish its fresh bundle promptly. lastPrekeyBundleSentAt = nil - return (transfers, policyCompletions) + return transfers } - for entry in panicReset.0 { + for entry in panicReset { entry.workItems.forEach { $0.cancel() } TransferProgressManager.shared.cancel(id: entry.id) } - completePrivateMediaPolicyResolution(panicReset.1, with: .blockedDowngrade) bleQueue.sync { pendingWriteBuffers.removeAll() @@ -1262,7 +1260,7 @@ final class BLEService: NSObject { with policy: PrivateMediaSendPolicy ) { guard !completions.isEmpty else { return } - Task { @MainActor in + notifyUI { completions.forEach { $0(policy) } } } diff --git a/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift b/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift index 40b1a3cb..58e3b610 100644 --- a/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift +++ b/bitchatTests/EndToEnd/PrivateMediaEndToEndTests.swift @@ -546,6 +546,71 @@ struct PrivateMediaEndToEndTests { bob._test_onOutboundPacket = nil } + @Test + func panicDropsPendingPrivateMediaPolicyCompletion() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent( + "private-media-policy-panic-\(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 + ) + ) + alice._test_seedConnectedPeer( + bob.myPeerID, + nickname: "Prerelease Bob", + capabilities: .privateMedia, + noisePublicKey: bob.noiseStaticPublicKeyData() + ) + + _ = try await establishSessionCapturingPeerState( + alice: alice, + bob: bob + ) + #expect( + alice.privateMediaSendPolicy(to: bob.myPeerID) + == .awaitingCapabilityProof + ) + let recorder = PrivateMediaPolicyRecorder() + alice.resolvePrivateMediaSendPolicy(to: bob.myPeerID) { + recorder.record($0) + } + let registered = await TestHelpers.waitUntil( + { + alice._test_hasPendingPrivateMediaPolicyResolution( + for: bob.myPeerID + ) + }, + timeout: TestConstants.longTimeout + ) + #expect(registered) + + alice.suspendForPanicReset() + alice.resetIdentityForPanic( + currentNickname: "anon", + restartServices: false + ) + alice._test_forcePrivateMediaProofTimeout(for: bob.myPeerID) + await Task.yield() + + #expect(recorder.snapshot() == nil) + #expect( + !alice._test_hasPendingPrivateMediaPolicyResolution( + for: bob.myPeerID + ) + ) + } + @Test func queuedPrivatePayloadWaitsForProofNotHandshakeCompletion() async throws { let root = FileManager.default.temporaryDirectory