From 9aa2519cb882681c29806d405ddf04f8c8d8a114 Mon Sep 17 00:00:00 2001 From: jack Date: Sat, 25 Jul 2026 17:13:10 +0200 Subject: [PATCH] Deliver live transport events synchronously --- bitchat/Services/BLE/BLEService.swift | 30 ++- bitchat/Services/Transport.swift | 7 + .../ViewModels/ChatPeerListCoordinator.swift | 7 +- .../ChatTransportEventCoordinator.swift | 220 ++++++++++++------ bitchat/ViewModels/ChatViewModel.swift | 78 ++++++- .../ChatPeerListCoordinatorContextTests.swift | 14 ++ ...ransportEventCoordinatorContextTests.swift | 50 ++++ bitchatTests/ChatViewModelTests.swift | 74 ++++++ 8 files changed, 405 insertions(+), 75 deletions(-) diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index fd300a3c..82f86b2c 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -4251,16 +4251,40 @@ extension BLEService { private func emitTransportEvent(_ event: TransportEvent) { notifyUI { [weak self] in - self?.deliverTransportEvent(event) + _ = self?.deliverTransportEvent(event) } } @MainActor - private func deliverTransportEvent(_ event: TransportEvent) { + @discardableResult + private func deliverTransportEvent(_ event: TransportEvent) -> Bool { + if case .messageReceived(let message) = event { + if let synchronousDelegate = + eventDelegate as? SynchronousMessageTransportEventDelegate { + return synchronousDelegate + .didReceiveTransportMessageSynchronously(message) + } + if let eventDelegate { + eventDelegate.didReceiveTransportEvent(event) + return false + } + if let synchronousDelegate = + delegate as? SynchronousMessageTransportEventDelegate { + return synchronousDelegate + .didReceiveTransportMessageSynchronously(message) + } + } + if let eventDelegate { eventDelegate.didReceiveTransportEvent(event) + return true } else { - delegate?.receiveTransportEvent(event) + guard let delegate else { return false } + delegate.receiveTransportEvent(event) + if case .messageReceived = event { + return false + } + return true } } diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index fd5c9338..2bb4ddab 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -101,6 +101,13 @@ protocol TransportEventDelegate: AnyObject { @MainActor func didReceiveTransportEvent(_ event: TransportEvent) } +/// Optional typed-event contract for sinks that can synchronously decide +/// whether an inbound message was accepted. +protocol SynchronousMessageTransportEventDelegate: TransportEventDelegate { + @MainActor + func didReceiveTransportMessageSynchronously(_ message: BitchatMessage) -> Bool +} + protocol Transport: AnyObject { // Event sink var delegate: BitchatDelegate? { get set } diff --git a/bitchat/ViewModels/ChatPeerListCoordinator.swift b/bitchat/ViewModels/ChatPeerListCoordinator.swift index bd045939..7646523f 100644 --- a/bitchat/ViewModels/ChatPeerListCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerListCoordinator.swift @@ -100,9 +100,14 @@ final class ChatPeerListCoordinator: @unchecked Sendable { func didUpdatePeerList(_ peers: [PeerID]) { Task { @MainActor [weak self] in - self?.handlePeerListUpdate(peers) + self?.didUpdatePeerListSynchronously(peers) } } + + @MainActor + func didUpdatePeerListSynchronously(_ peers: [PeerID]) { + handlePeerListUpdate(peers) + } } private extension ChatPeerListCoordinator { diff --git a/bitchat/ViewModels/ChatTransportEventCoordinator.swift b/bitchat/ViewModels/ChatTransportEventCoordinator.swift index 5ff2b3aa..72641812 100644 --- a/bitchat/ViewModels/ChatTransportEventCoordinator.swift +++ b/bitchat/ViewModels/ChatTransportEventCoordinator.swift @@ -163,21 +163,20 @@ final class ChatTransportEventCoordinator { } func didReceiveMessage(_ message: BitchatMessage) { - runOnMain { context in - guard !context.isMessageBlocked(message) else { return } - guard !message.content.trimmed.isEmpty || message.isPrivate else { return } - - if message.isPrivate { - context.handlePrivateMessage(message) - } else { - context.handlePublicMessage(message) - } - - context.checkForMentions(message) - context.sendHapticFeedback(for: message) + runOnMain { [self] context in + handleReceivedMessage(message, in: context) } } + /// Typed transport events already arrive on the main actor. Handle them + /// synchronously so observers see the ConversationStore mutation before + /// the transport completes delivery. + @MainActor + @discardableResult + func didReceiveMessageSynchronously(_ message: BitchatMessage) -> Bool { + handleReceivedMessage(message, in: context) + } + func didReceivePublicMessage( from peerID: PeerID, nickname: String, @@ -185,28 +184,36 @@ final class ChatTransportEventCoordinator { timestamp: Date, messageID: String? ) { - runOnMain { context in - let normalized = content.trimmed - let mentions = context.parseMentions(from: normalized) - let message = BitchatMessage( - id: messageID, - sender: nickname, - content: normalized, + runOnMain { [self] context in + handlePublicMessage( + from: peerID, + nickname: nickname, + content: content, timestamp: timestamp, - isRelay: false, - originalSender: nil, - isPrivate: false, - recipientNickname: nil, - senderPeerID: peerID, - mentions: mentions.isEmpty ? nil : mentions + messageID: messageID, + in: context ) - - context.handlePublicMessage(message) - context.checkForMentions(message) - context.sendHapticFeedback(for: message) } } + @MainActor + func didReceivePublicMessageSynchronously( + from peerID: PeerID, + nickname: String, + content: String, + timestamp: Date, + messageID: String? + ) { + handlePublicMessage( + from: peerID, + nickname: nickname, + content: content, + timestamp: timestamp, + messageID: messageID, + in: context + ) + } + func didReceiveNoisePayload( from peerID: PeerID, type: NoisePayloadType, @@ -224,59 +231,134 @@ final class ChatTransportEventCoordinator { } } + @MainActor + func didReceiveNoisePayloadSynchronously( + from peerID: PeerID, + type: NoisePayloadType, + payload: Data, + timestamp: Date + ) { + handleNoisePayload( + from: peerID, + type: type, + payload: payload, + timestamp: timestamp, + in: context + ) + } + func didConnectToPeer(_ peerID: PeerID) { - SecureLogger.debug("🤝 Peer connected: \(peerID)", category: .session) - - runOnMain { context in - context.isConnected = true - context.registerEphemeralSession(peerID: peerID) - context.notifyUIChanged() - - if let peer = context.unifiedPeer(for: peerID) { - let stablePeerID = PeerID(hexData: peer.noisePublicKey) - context.cacheStablePeerID(stablePeerID, for: peerID) - } - - context.flushRouterOutbox(for: peerID) - context.retryCourierDeposits(via: peerID) + runOnMain { [weak self] _ in + self?.didConnectToPeerSynchronously(peerID) } } + @MainActor + func didConnectToPeerSynchronously(_ peerID: PeerID) { + SecureLogger.debug("🤝 Peer connected: \(peerID)", category: .session) + + context.isConnected = true + context.registerEphemeralSession(peerID: peerID) + context.notifyUIChanged() + + if let peer = context.unifiedPeer(for: peerID) { + let stablePeerID = PeerID(hexData: peer.noisePublicKey) + context.cacheStablePeerID(stablePeerID, for: peerID) + } + + context.flushRouterOutbox(for: peerID) + context.retryCourierDeposits(via: peerID) + } + func didDisconnectFromPeer(_ peerID: PeerID) { + runOnMain { [weak self] _ in + self?.didDisconnectFromPeerSynchronously(peerID) + } + } + + @MainActor + func didDisconnectFromPeerSynchronously(_ peerID: PeerID) { SecureLogger.debug("👋 Peer disconnected: \(peerID)", category: .session) - runOnMain { context in - context.removeEphemeralSession(peerID: peerID) + context.removeEphemeralSession(peerID: peerID) - var stablePeerID = context.cachedStablePeerID(for: peerID) - if stablePeerID == nil, - let key = context.noiseSessionPublicKeyData(for: peerID) { - let derivedPeerID = PeerID(hexData: key) - context.cacheStablePeerID(derivedPeerID, for: peerID) - stablePeerID = derivedPeerID - } - - if let currentPeerID = context.selectedPrivateChatPeer, - currentPeerID == peerID, - let stablePeerID { - self.migrateSelectedConversationIfNeeded( - from: peerID, - to: stablePeerID, - in: context - ) - } - - let receiptIDs = context.privateMessages(for: peerID) - .filter { $0.senderPeerID == peerID } - .map(\.id) - context.unmarkReadReceiptsSent(receiptIDs) - - context.notifyUIChanged() + var stablePeerID = context.cachedStablePeerID(for: peerID) + if stablePeerID == nil, + let key = context.noiseSessionPublicKeyData(for: peerID) { + let derivedPeerID = PeerID(hexData: key) + context.cacheStablePeerID(derivedPeerID, for: peerID) + stablePeerID = derivedPeerID } + + if let currentPeerID = context.selectedPrivateChatPeer, + currentPeerID == peerID, + let stablePeerID { + migrateSelectedConversationIfNeeded( + from: peerID, + to: stablePeerID, + in: context + ) + } + + let receiptIDs = context.privateMessages(for: peerID) + .filter { $0.senderPeerID == peerID } + .map(\.id) + context.unmarkReadReceiptsSent(receiptIDs) + + context.notifyUIChanged() } } private extension ChatTransportEventCoordinator { + @MainActor + func handlePublicMessage( + from peerID: PeerID, + nickname: String, + content: String, + timestamp: Date, + messageID: String?, + in context: any ChatTransportEventContext + ) { + let normalized = content.trimmed + let mentions = context.parseMentions(from: normalized) + let message = BitchatMessage( + id: messageID, + sender: nickname, + content: normalized, + timestamp: timestamp, + isRelay: false, + originalSender: nil, + isPrivate: false, + recipientNickname: nil, + senderPeerID: peerID, + mentions: mentions.isEmpty ? nil : mentions + ) + + context.handlePublicMessage(message) + context.checkForMentions(message) + context.sendHapticFeedback(for: message) + } + + @MainActor + @discardableResult + func handleReceivedMessage( + _ message: BitchatMessage, + in context: any ChatTransportEventContext + ) -> Bool { + guard !context.isMessageBlocked(message) else { return false } + guard !message.content.trimmed.isEmpty || message.isPrivate else { return false } + + if message.isPrivate { + context.handlePrivateMessage(message) + } else { + context.handlePublicMessage(message) + } + + context.checkForMentions(message) + context.sendHapticFeedback(for: message) + return true + } + func runOnMain(_ action: @escaping @MainActor (any ChatTransportEventContext) -> Void) { Task { @MainActor [weak context = self.context] in guard let context else { return } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index a70f8f15..0be5a6b1 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -112,7 +112,7 @@ struct PanicNetworkLifecycle { /// Manages the application state and business logic for BitChat. /// Acts as the primary coordinator between UI components and backend services, /// implementing the BitchatDelegate protocol to handle network events. -final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDelegate, CommandContextProvider, GeohashParticipantContext, MessageFormattingContext { +final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessageTransportEventDelegate, CommandContextProvider, GeohashParticipantContext, MessageFormattingContext { // Use MessageFormattingEngine.Patterns for regex matching (shared, precompiled) typealias Patterns = MessageFormattingEngine.Patterns @@ -1701,7 +1701,81 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele @MainActor func didReceiveTransportEvent(_ event: TransportEvent) { - receiveTransportEvent(event) + switch event { + case .messageReceived(let message): + _ = didReceiveTransportMessageSynchronously(message) + + case let .publicMessageReceived( + peerID, + nickname, + content, + timestamp, + messageID + ): + transportEventCoordinator.didReceivePublicMessageSynchronously( + from: peerID, + nickname: nickname, + content: content, + timestamp: timestamp, + messageID: messageID + ) + + case let .noisePayloadReceived(peerID, type, payload, timestamp): + transportEventCoordinator.didReceiveNoisePayloadSynchronously( + from: peerID, + type: type, + payload: payload, + timestamp: timestamp + ) + + case let .groupMessageReceived(payload, timestamp): + groupCoordinator.handleGroupMessagePayload( + payload, + timestamp: timestamp + ) + + case let .publicVoiceFrameReceived( + peerID, + nickname, + payload, + timestamp + ): + liveVoiceCoordinator.handlePublicVoiceFramePayload( + from: peerID, + nickname: nickname, + payload: payload, + timestamp: timestamp + ) + + case .peerConnected(let peerID): + transportEventCoordinator.didConnectToPeerSynchronously(peerID) + + case .peerDisconnected(let peerID): + transportEventCoordinator.didDisconnectFromPeerSynchronously(peerID) + + case .peerListUpdated(let peers): + peerListCoordinator.didUpdatePeerListSynchronously(peers) + // A peer-list update follows every verified announce, which is + // where a peer's `.vouch` capability actually arrives. + vouchCoordinator.peersUpdated(peers) + + case .peerSnapshotsUpdated: + break + + case let .messageDeliveryStatusUpdated(messageID, status): + deliveryCoordinator.didUpdateMessageDeliveryStatus( + messageID, + status: status + ) + + case .bluetoothStateUpdated(let state): + updateBluetoothState(state) + } + } + + @MainActor + func didReceiveTransportMessageSynchronously(_ message: BitchatMessage) -> Bool { + transportEventCoordinator.didReceiveMessageSynchronously(message) } func didReceiveMessage(_ message: BitchatMessage) { diff --git a/bitchatTests/ChatPeerListCoordinatorContextTests.swift b/bitchatTests/ChatPeerListCoordinatorContextTests.swift index 079da987..d7882f0e 100644 --- a/bitchatTests/ChatPeerListCoordinatorContextTests.swift +++ b/bitchatTests/ChatPeerListCoordinatorContextTests.swift @@ -104,6 +104,20 @@ private func makeMessage(id: String, senderPeerID: PeerID? = nil) -> BitchatMess /// no `ChatViewModel`. struct ChatPeerListCoordinatorContextTests { + @Test @MainActor + func synchronousPeerListUpdate_appliesBeforeReturning() { + let context = MockChatPeerListContext() + let coordinator = ChatPeerListCoordinator(context: context) + let peerID = PeerID(str: "0011223344556677") + + coordinator.didUpdatePeerListSynchronously([peerID]) + + #expect(context.isConnected) + #expect(context.registeredEphemeralSessions == [peerID]) + #expect(context.updateEncryptionStatusForPeersCount == 1) + #expect(context.cleanupOldReadReceiptsCount == 1) + } + @Test @MainActor func didUpdatePeerList_updatesConnectionSessionsAndEncryptionStatus() async { let context = MockChatPeerListContext() diff --git a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift index 88d118f3..46bf79d7 100644 --- a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift +++ b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift @@ -242,6 +242,30 @@ struct ChatTransportEventCoordinatorContextTests { #expect(context.hapticMessageIDs == ["pm", "pub"]) } + @Test @MainActor + func synchronousMessageDeliveryReportsAcceptanceForAckGating() { + let context = MockChatTransportEventContext() + let coordinator = ChatTransportEventCoordinator(context: context) + let peerID = PeerID(str: "1122334455667788") + let blocked = makeMessage( + id: "blocked-private-media", + isPrivate: true, + senderPeerID: peerID + ) + context.blockedMessageIDs = [blocked.id] + + #expect(coordinator.didReceiveMessageSynchronously(blocked) == false) + #expect(context.handledPrivateMessages.isEmpty) + + let accepted = makeMessage( + id: "accepted-private-media", + isPrivate: true, + senderPeerID: peerID + ) + #expect(coordinator.didReceiveMessageSynchronously(accepted) == true) + #expect(context.handledPrivateMessages.map(\.id) == [accepted.id]) + } + @Test @MainActor func didReceivePublicMessage_trimsContentAndParsesMentions() async { let context = MockChatTransportEventContext() @@ -295,6 +319,32 @@ struct ChatTransportEventCoordinatorContextTests { #expect(context.notifyUIChangedCount == 2) } + @Test @MainActor + func synchronousConnectAndDisconnect_applyBeforeReturning() { + let context = MockChatTransportEventContext() + let coordinator = ChatTransportEventCoordinator(context: context) + let peerID = PeerID(str: "2233445566778899") + let incoming = makeMessage( + id: "incoming-receipt", + isPrivate: true, + senderPeerID: peerID + ) + context.privateChats[peerID] = [incoming] + + coordinator.didConnectToPeerSynchronously(peerID) + + #expect(context.isConnected) + #expect(context.registeredEphemeralSessions == [peerID]) + #expect(context.flushedOutboxPeerIDs == [peerID]) + #expect(context.courierRetryPeerIDs == [peerID]) + + coordinator.didDisconnectFromPeerSynchronously(peerID) + + #expect(context.removedEphemeralSessions == [peerID]) + #expect(context.unmarkedReadReceiptBatches == [[incoming.id]]) + #expect(context.notifyUIChangedCount == 2) + } + @Test @MainActor func didDisconnect_whileViewingChat_migratesConversationToStablePeerID() async { let context = MockChatTransportEventContext() diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index fa78e0cc..d78b9da7 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -864,6 +864,80 @@ struct ChatViewModelPublicConversationTests { struct ChatViewModelPeerTests { + @Test @MainActor + func typedPeerLifecycleEvents_applyBeforeReturning() { + let (viewModel, _) = makeTestableViewModel() + let peerID = PeerID(str: "1122334455667788") + let incoming = BitchatMessage( + id: "typed-peer-incoming", + sender: "Alice", + content: "Hello", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: viewModel.nickname, + senderPeerID: peerID + ) + viewModel.seedPrivateChat([incoming], for: peerID) + viewModel.sentReadReceipts.insert(incoming.id) + + viewModel.didReceiveTransportEvent(.peerConnected(peerID)) + + #expect(viewModel.isConnected) + + viewModel.didReceiveTransportEvent(.peerDisconnected(peerID)) + + #expect(!viewModel.sentReadReceipts.contains(incoming.id)) + } + + @Test @MainActor + func typedPeerListDeliveryAndBluetoothEvents_applyBeforeReturning() { + let (viewModel, transport) = makeTestableViewModel() + let stalePeer = PeerID(str: "00000000000000a2") + let deliveryPeer = PeerID(str: "0102030405060708") + let messageID = "typed-delivery-status" + let delivered = DeliveryStatus.delivered( + to: "Alice", + at: Date(timeIntervalSince1970: 1_234) + ) + let outgoing = BitchatMessage( + id: messageID, + sender: viewModel.nickname, + content: "On the way", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Alice", + senderPeerID: transport.myPeerID, + deliveryStatus: .sent + ) + viewModel.markPrivateChatUnread(stalePeer) + viewModel.seedPrivateChat([outgoing], for: deliveryPeer) + + viewModel.didReceiveTransportEvent(.peerListUpdated([])) + #expect(!viewModel.unreadPrivateMessages.contains(stalePeer)) + + viewModel.didReceiveTransportEvent( + .messageDeliveryStatusUpdated( + messageID: messageID, + status: delivered + ) + ) + #expect( + viewModel.privateMessages(for: deliveryPeer).first?.deliveryStatus + == delivered + ) + + viewModel.didReceiveTransportEvent(.bluetoothStateUpdated(.poweredOff)) + #expect(viewModel.bluetoothState == .poweredOff) + #expect(viewModel.showBluetoothAlert) + + // Snapshot events belong to TransportPeerEventsDelegate and are + // intentionally ignored at this typed sink. + viewModel.didReceiveTransportEvent(.peerSnapshotsUpdated([])) + #expect(viewModel.bluetoothState == .poweredOff) + } + @Test @MainActor func didConnectToPeer_notifiesDelegate() async { let (_, transport) = makeTestableViewModel()