diff --git a/bitchat/Services/PrivateChatManager.swift b/bitchat/Services/PrivateChatManager.swift index 3141ec0b..a243d570 100644 --- a/bitchat/Services/PrivateChatManager.swift +++ b/bitchat/Services/PrivateChatManager.swift @@ -252,15 +252,23 @@ final class PrivateChatManager: ObservableObject { /// suites through the shared UserDefaults-backed setting. var sendsReadReceipts: () -> Bool = { ReadReceiptSettings.sendReadReceipts } + /// Records a withheld receipt in the owner's persisted set too: the + /// lifecycle read pass dedups against ChatViewModel.sentReadReceipts, + /// not this manager's set, so claiming only locally would let a receipt + /// for a message read while the setting was OFF fire after re-enabling. + var markReceiptHandled: ((String) -> Void)? + private func sendReadReceipt(for message: BitchatMessage) { guard !sentReadReceipts.contains(message.id), let senderPeerID = message.senderPeerID else { return } - // Withheld receipts are still claimed below as sent: re-enabling the - // setting must never fire a retroactive burst disclosing past reads. + // Withheld receipts are still claimed as sent — in BOTH tracking + // sets: re-enabling the setting must never fire a retroactive burst + // disclosing past reads, from this manager or the lifecycle pass. guard sendsReadReceipts() else { sentReadReceipts.insert(message.id) + markReceiptHandled?(message.id) return } diff --git a/bitchat/ViewModels/ChatLiveVoiceCoordinator.swift b/bitchat/ViewModels/ChatLiveVoiceCoordinator.swift index 78fa83e1..acc4a298 100644 --- a/bitchat/ViewModels/ChatLiveVoiceCoordinator.swift +++ b/bitchat/ViewModels/ChatLiveVoiceCoordinator.swift @@ -79,6 +79,11 @@ enum VoiceBurstScope: Hashable { /// bubble so nobody sees a duplicate. @MainActor final class ChatLiveVoiceCoordinator { + /// Injectable so tests exercise the live path without racing other + /// suites through the shared UserDefaults-backed preference (which now + /// defaults OFF). + var liveVoiceEnabled: () -> Bool = { PTTSettings.liveVoiceEnabled } + /// Burst IDs are sender-chosen, so they only identify a burst *within* /// an authenticated (peer, scope) pair: keying assemblies by the full /// triple stops an attacker who observed a public burst ID from racing @@ -187,7 +192,7 @@ final class ChatLiveVoiceCoordinator { // Live voice off means classic-notes-only in both directions: no live // bubble, no partial file, no early notification — the finalized // voice note still arrives through the normal pipeline. - guard PTTSettings.liveVoiceEnabled else { + guard liveVoiceEnabled() else { SecureLogger.debug("PTT: dropping inbound voice frame — live voice is toggled off", category: .session) return } @@ -403,7 +408,7 @@ final class ChatLiveVoiceCoordinator { case .directMessage: context.selectedPrivateChatPeer == peerID case .publicMesh: context.isViewingPublicMeshTimeline } - if PTTSettings.liveVoiceEnabled, PTTSettings.isAppActive, isViewing { + if liveVoiceEnabled(), PTTSettings.isAppActive, isViewing { assembly.player = PTTBurstPlayer() } diff --git a/bitchat/ViewModels/ChatViewModelBootstrapper.swift b/bitchat/ViewModels/ChatViewModelBootstrapper.swift index 3ccb7d12..ca769b05 100644 --- a/bitchat/ViewModels/ChatViewModelBootstrapper.swift +++ b/bitchat/ViewModels/ChatViewModelBootstrapper.swift @@ -90,6 +90,9 @@ private extension ChatViewModelBootstrapper { viewModel.privateChatManager.conversationStore = viewModel.conversations viewModel.privateChatManager.messageRouter = viewModel.messageRouter viewModel.privateChatManager.unifiedPeerService = viewModel.unifiedPeerService + viewModel.privateChatManager.markReceiptHandled = { [weak viewModel] messageID in + viewModel?.markReadReceiptSent(messageID) + } viewModel.unifiedPeerService.messageRouter = viewModel.messageRouter // Surface silent outbox drops (attempt cap, TTL expiry, overflow // eviction) as a visible failure. The store's no-downgrade rule does diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift index 9dad8030..968da1a9 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+PrivateChat.swift @@ -69,7 +69,7 @@ extension ChatViewModel { @MainActor private func liveVoiceTarget() -> LiveVoiceTarget? { - guard PTTSettings.liveVoiceEnabled else { return nil } + guard liveVoiceCoordinator.liveVoiceEnabled() else { return nil } if let selectedPeer = selectedPrivateChatPeer { guard !selectedPeer.isGeoDM, !selectedPeer.isGeoChat, !selectedPeer.isGroup else { return nil } diff --git a/bitchatTests/ChatLiveVoiceCoordinatorTests.swift b/bitchatTests/ChatLiveVoiceCoordinatorTests.swift index 2e5ca317..67b509c7 100644 --- a/bitchatTests/ChatLiveVoiceCoordinatorTests.swift +++ b/bitchatTests/ChatLiveVoiceCoordinatorTests.swift @@ -127,6 +127,7 @@ struct ChatLiveVoiceCoordinatorTests { @Test func burstCreatesBubbleAndPersistsFramesInOrder() throws { let context = MockChatLiveVoiceContext() let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) + coordinator.liveVoiceEnabled = { true } let burstID = makeBurstID(0xA1) defer { fallbackFileURL(burstID: burstID, peerID: peer).map { try? FileManager.default.removeItem(at: $0) } } @@ -168,6 +169,7 @@ struct ChatLiveVoiceCoordinatorTests { let context = MockChatLiveVoiceContext() context.selectedPrivateChatPeer = peer let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) + coordinator.liveVoiceEnabled = { true } let burstID = makeBurstID(0xB2) let hex = burstID.hexEncodedString() let fileName = "voice_\(hex).m4a" @@ -223,6 +225,7 @@ struct ChatLiveVoiceCoordinatorTests { @Test func absorbIgnoresUnrelatedVoiceNotes() throws { let context = MockChatLiveVoiceContext() let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) + coordinator.liveVoiceEnabled = { true } // A classic voice note (date-stamped name) and a live-capture name // must both pass through untouched. @@ -247,6 +250,7 @@ struct ChatLiveVoiceCoordinatorTests { @Test func canceledBurstRemovesBubbleAndFile() throws { let context = MockChatLiveVoiceContext() let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) + coordinator.liveVoiceEnabled = { true } let burstID = makeBurstID(0xC3) send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 9, count: 40)]))), to: coordinator, from: peer) @@ -262,6 +266,7 @@ struct ChatLiveVoiceCoordinatorTests { @Test func emptyBurstLeavesNoBubble() throws { let context = MockChatLiveVoiceContext() let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) + coordinator.liveVoiceEnabled = { true } let burstID = makeBurstID(0xD4) send(try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer) @@ -275,6 +280,7 @@ struct ChatLiveVoiceCoordinatorTests { @Test func ignoresBlockedPeersAndUnknownControlPackets() throws { let context = MockChatLiveVoiceContext() let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) + coordinator.liveVoiceEnabled = { true } context.blockedPeers = [peer] send(try #require(VoiceBurstPacket(burstID: makeBurstID(0xE5), seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer) @@ -290,6 +296,7 @@ struct ChatLiveVoiceCoordinatorTests { @Test func concurrentAssemblyCapDropsExtraBursts() throws { let context = MockChatLiveVoiceContext() let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) + coordinator.liveVoiceEnabled = { true } var cleanup: [Data] = [] defer { @@ -309,12 +316,10 @@ struct ChatLiveVoiceCoordinatorTests { } @Test func liveVoiceToggleOffDropsInboundFrames() throws { - let previous = PTTSettings.liveVoiceEnabled - PTTSettings.liveVoiceEnabled = false - defer { PTTSettings.liveVoiceEnabled = previous } - let context = MockChatLiveVoiceContext() let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) + coordinator.liveVoiceEnabled = { true } + coordinator.liveVoiceEnabled = { false } let burstID = makeBurstID(0xE8) // Off means classic-notes-only: no live bubble, no partial file. @@ -328,6 +333,7 @@ struct ChatLiveVoiceCoordinatorTests { @Test func publicBurstCreatesMeshBubbleAndTracksTalker() throws { let context = MockChatLiveVoiceContext() let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) + coordinator.liveVoiceEnabled = { true } let burstID = makeBurstID(0x71) defer { incomingFileURL(burstID: burstID, peerID: peer, scope: .publicMesh).map { try? FileManager.default.removeItem(at: $0) } @@ -369,6 +375,7 @@ struct ChatLiveVoiceCoordinatorTests { @Test func absorbEnforcesScopeBinding() throws { let context = MockChatLiveVoiceContext() let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) + coordinator.liveVoiceEnabled = { true } let burstID = makeBurstID(0x72) defer { fallbackFileURL(burstID: burstID, peerID: peer).map { try? FileManager.default.removeItem(at: $0) } } @@ -400,6 +407,7 @@ struct ChatLiveVoiceCoordinatorTests { @Test func collidingBurstIDFromAnotherPeerCannotHijackAssembly() throws { let context = MockChatLiveVoiceContext() let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) + coordinator.liveVoiceEnabled = { true } let burstID = makeBurstID(0x73) let attacker = PeerID(str: "ddddeeeeffff0002") defer { @@ -435,6 +443,7 @@ struct ChatLiveVoiceCoordinatorTests { @Test func sameBurstIDCoexistsAcrossScopes() throws { let context = MockChatLiveVoiceContext() let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) + coordinator.liveVoiceEnabled = { true } let burstID = makeBurstID(0x74) defer { fallbackFileURL(burstID: burstID, peerID: peer).map { try? FileManager.default.removeItem(at: $0) } @@ -492,6 +501,7 @@ struct ChatLiveVoiceCoordinatorTests { @Test func finalizedNoteBindsToItsAuthenticatedSender() throws { let context = MockChatLiveVoiceContext() let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false) + coordinator.liveVoiceEnabled = { true } let burstID = makeBurstID(0x75) let hex = burstID.hexEncodedString() let attacker = PeerID(str: "ddddeeeeffff0002") @@ -550,6 +560,7 @@ struct ChatLiveVoiceCoordinatorTests { let context = MockChatLiveVoiceContext() let coordinator = ChatLiveVoiceCoordinator(context: context, fileStore: store) + coordinator.liveVoiceEnabled = { true } let burstID = makeBurstID(0x76) send(try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer) @@ -571,6 +582,7 @@ struct ChatLiveVoiceCoordinatorTests { let context = MockChatLiveVoiceContext() let coordinator = ChatLiveVoiceCoordinator(context: context, fileStore: store) + coordinator.liveVoiceEnabled = { true } let burstID = makeBurstID(0x77) send(try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer) send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 8, count: 60)]))), to: coordinator, from: peer) @@ -618,6 +630,7 @@ struct ChatLiveVoiceCoordinatorTests { // sender out of range): the capture is the row's only audio. let context = MockChatLiveVoiceContext() let coordinator = ChatLiveVoiceCoordinator(context: context, fileStore: store) + coordinator.liveVoiceEnabled = { true } let burstID = makeBurstID(0x79) let frame = Data(repeating: 0x0B, count: 60) send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([frame]))), to: coordinator, from: peer) @@ -642,6 +655,7 @@ struct ChatLiveVoiceCoordinatorTests { let context = MockChatLiveVoiceContext() let coordinator = ChatLiveVoiceCoordinator(context: context, fileStore: store) + coordinator.liveVoiceEnabled = { true } let burstID = makeBurstID(0x7A) send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 0x0C, count: 60)]))), to: coordinator, from: peer) send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: peer) diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index 1bd66497..b7684aa3 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -462,10 +462,13 @@ struct ChatViewModelServiceLifecycleTests { #expect(!sentReadReceipt) // ...while the chat is still marked read locally and the receipt is - // recorded as handled, so re-enabling the setting never fires a - // retroactive burst disclosing past reading activity. + // recorded as handled in BOTH tracking sets (the lifecycle pass + // dedups against the owner's persisted set, the manager against its + // own), so re-enabling the setting never fires a retroactive burst + // disclosing past reading activity from either path. #expect(!viewModel.unreadPrivateMessages.contains(peerID)) - #expect(viewModel.sentReadReceipts.contains("read-2") || viewModel.privateChatManager.sentReadReceipts.contains("read-2")) + #expect(viewModel.sentReadReceipts.contains("read-2")) + #expect(viewModel.privateChatManager.sentReadReceipts.contains("read-2")) } @Test @MainActor @@ -1588,6 +1591,9 @@ struct ChatViewModelPrivateMediaDeletionTests { kind: .canceled )) let coordinator = viewModel.liveVoiceCoordinator + // The live-voice preference defaults OFF now; this fixture exercises + // the opted-in live path. + coordinator.liveVoiceEnabled = { true } defer { coordinator.handleVoiceFramePayload( from: peerID,