From 3a995e20b6d4bbb63213fa34b0d4af41febf57ec Mon Sep 17 00:00:00 2001 From: jack Date: Wed, 10 Jun 2026 16:22:52 +0100 Subject: [PATCH] Extract BLE packet handlers and migrate coordinators to narrow contexts BLEService's per-packet-type orchestration moves into owned, tested components (BLEAnnounceHandler, BLEPublicMessageHandler, BLENoisePacketHandler, BLEFileTransferHandler, BLEFragmentHandler), each taking an environment struct of closures so every queue hop stays in BLEService and the handlers are synchronously testable. Behavior is preserved verbatim, including Noise session recovery on decrypt failure and single-block UI event ordering. handleLeave/handleRequestSync stay in place as already-thin delegations. BLEService drops to 3393 lines. Four coordinators (delivery, private conversation, Nostr, public conversation) drop their unowned/weak ChatViewModel back-references for narrow @MainActor context protocols, with ChatViewModel conformances as single shared witnesses for overlapping members. Their true coupling is now an explicit, reviewable surface, and each gains a mock-context test suite covering flows previously testable only through the full view model. Delivery/read acks now also clear the router's retained-send outbox via the delivery context. New LargeTopologyTests exercise production-shaped meshes with the in-memory harness: an 8-peer relay chain with per-hop TTL decay, a 14-peer cyclic mesh with exactly-once delivery, partition/heal, and topology churn. App-layer runtime/model files updated alongside. Co-Authored-By: Claude Fable 5 --- bitchat/App/AppArchitecture.swift | 38 +- bitchat/App/PrivateConversationModels.swift | 1 + bitchat/App/PublicChatModel.swift | 4 +- bitchat/Services/BLE/BLEAnnounceHandler.swift | 209 ++++++ .../Services/BLE/BLEFileTransferHandler.swift | 123 ++++ bitchat/Services/BLE/BLEFragmentHandler.swift | 94 +++ .../Services/BLE/BLENoisePacketHandler.swift | 132 ++++ .../BLE/BLEPublicMessageHandler.swift | 104 +++ bitchat/Services/BLE/BLEService.swift | 690 +++++++----------- .../ViewModels/ChatDeliveryCoordinator.swift | 265 ++++++- .../ChatMediaTransferCoordinator.swift | 28 +- bitchat/ViewModels/ChatNostrCoordinator.swift | 551 +++++++++----- .../ChatPrivateConversationCoordinator.swift | 476 +++++++----- .../ChatPublicConversationCoordinator.swift | 390 +++++++--- .../ChatVerificationCoordinator.swift | 4 +- bitchat/ViewModels/ChatViewModel.swift | 24 +- .../ChatViewModelBootstrapper.swift | 4 +- bitchat/ViewModels/PublicTimelineStore.swift | 59 +- bitchat/Views/MessageListView.swift | 11 +- .../ChatNostrCoordinatorContextTests.swift | 442 +++++++++++ ...eConversationCoordinatorContextTests.swift | 390 ++++++++++ ...cConversationCoordinatorContextTests.swift | 495 +++++++++++++ .../ChatViewModelDeliveryStatusTests.swift | 264 +++++++ .../ChatViewModelExtensionsTests.swift | 20 + bitchatTests/GeohashPresenceTests.swift | 69 ++ .../Integration/LargeTopologyTests.swift | 220 ++++++ bitchatTests/PublicTimelineStoreTests.swift | 43 ++ .../Services/BLEAnnounceHandlerTests.swift | 433 +++++++++++ .../BLEFileTransferHandlerTests.swift | 267 +++++++ .../Services/BLEFragmentHandlerTests.swift | 227 ++++++ .../Services/BLENoisePacketHandlerTests.swift | 310 ++++++++ .../BLEPublicMessageHandlerTests.swift | 251 +++++++ 32 files changed, 5685 insertions(+), 953 deletions(-) create mode 100644 bitchat/Services/BLE/BLEAnnounceHandler.swift create mode 100644 bitchat/Services/BLE/BLEFileTransferHandler.swift create mode 100644 bitchat/Services/BLE/BLEFragmentHandler.swift create mode 100644 bitchat/Services/BLE/BLENoisePacketHandler.swift create mode 100644 bitchat/Services/BLE/BLEPublicMessageHandler.swift create mode 100644 bitchatTests/ChatNostrCoordinatorContextTests.swift create mode 100644 bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift create mode 100644 bitchatTests/ChatPublicConversationCoordinatorContextTests.swift create mode 100644 bitchatTests/Integration/LargeTopologyTests.swift create mode 100644 bitchatTests/Services/BLEAnnounceHandlerTests.swift create mode 100644 bitchatTests/Services/BLEFileTransferHandlerTests.swift create mode 100644 bitchatTests/Services/BLEFragmentHandlerTests.swift create mode 100644 bitchatTests/Services/BLENoisePacketHandlerTests.swift create mode 100644 bitchatTests/Services/BLEPublicMessageHandlerTests.swift diff --git a/bitchat/App/AppArchitecture.swift b/bitchat/App/AppArchitecture.swift index f0e0e44b..829e79ec 100644 --- a/bitchat/App/AppArchitecture.swift +++ b/bitchat/App/AppArchitecture.swift @@ -207,9 +207,14 @@ final class ConversationStore: ObservableObject { private var directHandlesByConversation: [ConversationID: PeerHandle] = [:] func setActiveChannel(_ channelID: ChannelID) { - activeChannel = channelID + if activeChannel != channelID { + activeChannel = channelID + } if selectedPrivatePeerID == nil { - selectedConversationID = ConversationID(channelID: channelID) + let conversationID = ConversationID(channelID: channelID) + if selectedConversationID != conversationID { + selectedConversationID = conversationID + } } } @@ -218,21 +223,33 @@ final class ConversationStore: ObservableObject { activeChannel: ChannelID, identityResolver: IdentityResolver ) { - self.activeChannel = activeChannel - selectedPrivatePeerID = peerID + if self.activeChannel != activeChannel { + self.activeChannel = activeChannel + } + if selectedPrivatePeerID != peerID { + selectedPrivatePeerID = peerID + } if let peerID { - selectedConversationID = directConversationID( + let conversationID = directConversationID( for: peerID, identityResolver: identityResolver ) + if selectedConversationID != conversationID { + selectedConversationID = conversationID + } } else { - selectedConversationID = ConversationID(channelID: activeChannel) + let conversationID = ConversationID(channelID: activeChannel) + if selectedConversationID != conversationID { + selectedConversationID = conversationID + } } } func replaceMessages(_ messages: [BitchatMessage], for conversationID: ConversationID) { - messagesByConversation[conversationID] = normalized(messages) + let normalizedMessages = normalized(messages) + guard messagesByConversation[conversationID] != normalizedMessages else { return } + messagesByConversation[conversationID] = normalizedMessages } func replaceMessages(_ messages: [BitchatMessage], for channelID: ChannelID) { @@ -296,7 +313,7 @@ final class ConversationStore: ObservableObject { let conversationID = ConversationID.direct(handle) liveConversations.insert(conversationID) directHandlesByConversation[conversationID] = handle - messagesByConversation[conversationID] = normalized(messages) + replaceMessages(messages, for: conversationID) } let staleDirectConversations = messagesByConversation.keys.filter { conversationID in @@ -319,10 +336,13 @@ final class ConversationStore: ObservableObject { } } - unreadConversations = unreadPeerIDs.reduce(into: publicUnread) { result, peerID in + let nextUnreadConversations = unreadPeerIDs.reduce(into: publicUnread) { result, peerID in let handle = identityResolver.canonicalHandle(for: peerID) result.insert(.direct(handle)) } + if unreadConversations != nextUnreadConversations { + unreadConversations = nextUnreadConversations + } } func markRead(_ conversationID: ConversationID) { diff --git a/bitchat/App/PrivateConversationModels.swift b/bitchat/App/PrivateConversationModels.swift index 6403e105..412c1ad2 100644 --- a/bitchat/App/PrivateConversationModels.swift +++ b/bitchat/App/PrivateConversationModels.swift @@ -63,6 +63,7 @@ final class PrivateInboxModel: ObservableObject { nextMessagesByPeerID[peerID] = [] } + guard messagesByPeerID != nextMessagesByPeerID else { return } messagesByPeerID = nextMessagesByPeerID } } diff --git a/bitchat/App/PublicChatModel.swift b/bitchat/App/PublicChatModel.swift index 3a5bcfe4..0d225bdb 100644 --- a/bitchat/App/PublicChatModel.swift +++ b/bitchat/App/PublicChatModel.swift @@ -36,6 +36,8 @@ final class PublicChatModel: ObservableObject { } private func refreshMessages() { - messages = conversationStore.messages(for: ConversationID(channelID: activeChannel)) + let nextMessages = conversationStore.messages(for: ConversationID(channelID: activeChannel)) + guard messages != nextMessages else { return } + messages = nextMessages } } diff --git a/bitchat/Services/BLE/BLEAnnounceHandler.swift b/bitchat/Services/BLE/BLEAnnounceHandler.swift new file mode 100644 index 00000000..e69a0d9a --- /dev/null +++ b/bitchat/Services/BLE/BLEAnnounceHandler.swift @@ -0,0 +1,209 @@ +import BitFoundation +import BitLogger +import Foundation + +/// Narrow environment for `BLEAnnounceHandler`. +/// +/// All queue hops (collections barrier, BLE-queue link-state reads, main-actor +/// UI notification, delayed re-announce) live inside the closures supplied by +/// `BLEService`, keeping the handler queue-agnostic and synchronously testable. +struct BLEAnnounceHandlerEnvironment { + /// Local peer identity at the time the announce is handled. + let localPeerID: () -> PeerID + /// TTL value used for direct (non-relayed) packets. + let messageTTL: UInt8 + /// Current time source. + let now: () -> Date + /// Noise public key already recorded for the peer, if any (registry read). + let existingNoisePublicKey: (PeerID) -> Data? + /// Verifies the packet signature against the announced signing key. + let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool + /// Direct link state for the peer (BLE-queue read). + let linkState: (PeerID) -> (hasPeripheral: Bool, hasCentral: Bool) + /// Runs the registry mutation phase under the collections barrier. + let withRegistryBarrier: (() -> Void) -> Void + /// Upserts the verified announce into the peer registry. + /// Must only be called from inside `withRegistryBarrier`. + let upsertVerifiedAnnounce: ( + _ peerID: PeerID, + _ announcement: AnnouncementPacket, + _ isConnected: Bool, + _ now: Date + ) -> BLEPeerAnnounceUpdate + /// Debounced reconnect-log decision. + /// Must only be called from inside `withRegistryBarrier`. + let shouldEmitReconnectLog: (_ peerID: PeerID, _ now: Date) -> Bool + /// Records verified direct-neighbor claims in the mesh topology. + let updateTopology: (_ peerID: PeerID, _ neighbors: [Data]) -> Void + /// Persists the announced cryptographic identity for offline verification. + let persistIdentity: (AnnouncementPacket) -> Void + /// Announce-back dedup check. + let dedupContains: (String) -> Bool + /// Announce-back dedup marking. + let dedupMarkProcessed: (String) -> Void + /// Delivers the announce UI events as one ordered main-actor hop: + /// `.peerConnected` (if flagged) → initial gossip sync scheduling (if + /// flagged) → peer-ID snapshot + data publish + `.peerListUpdated`. + /// A single closure keeps the original in-order delivery guarantee that + /// separate unstructured tasks would not provide. + let deliverAnnounceUIEvents: ( + _ peerID: PeerID, + _ notifyPeerConnected: Bool, + _ scheduleInitialSync: Bool + ) -> Void + /// Tracks the announce packet for gossip sync. + let trackPacketSeen: (BitchatPacket) -> Void + /// Reciprocates the announce for bidirectional discovery. + let sendAnnounceBack: () -> Void + /// Schedules a delayed re-announce (afterglow) after the given delay. + let scheduleAfterglow: (TimeInterval) -> Void +} + +/// Orchestrates inbound announce packets: preflight validation, signature +/// trust, registry/topology updates, identity persistence, UI notification, +/// gossip tracking, and the reciprocal announce response. +final class BLEAnnounceHandler { + private let environment: BLEAnnounceHandlerEnvironment + + init(environment: BLEAnnounceHandlerEnvironment) { + self.environment = environment + } + + func handle(_ packet: BitchatPacket, from peerID: PeerID) { + let env = environment + let now = env.now() + let preflight = BLEAnnouncePreflightPolicy.evaluate( + packet: packet, + from: peerID, + localPeerID: env.localPeerID(), + now: now + ) + + let announcement: AnnouncementPacket + switch preflight { + case .accept(let acceptance): + announcement = acceptance.announcement + case .reject(.malformed): + SecureLogger.error("❌ Failed to decode announce packet from \(peerID.id.prefix(8))…", category: .session) + return + case .reject(.senderMismatch(let derivedFromKey)): + SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))…", category: .security) + return + case .reject(.selfAnnounce): + return + case .reject(.stale(let ageSeconds)): + SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session) + return + } + + // Suppress announce logs to reduce noise + + // Precompute signature verification outside barrier to reduce contention + let existingNoisePublicKey = env.existingNoisePublicKey(peerID) + let hasSignature = packet.signature != nil + let signatureValid: Bool + if hasSignature { + signatureValid = env.verifySignature(packet, announcement.signingPublicKey) + if !signatureValid { + SecureLogger.warning("⚠️ Signature verification for announce failed \(peerID.id.prefix(8))", category: .security) + } + } else { + signatureValid = false + } + let trustDecision = BLEAnnounceTrustPolicy.evaluate( + hasSignature: hasSignature, + signatureValid: signatureValid, + existingNoisePublicKey: existingNoisePublicKey, + announcedNoisePublicKey: announcement.noisePublicKey + ) + if case .reject(.keyMismatch) = trustDecision { + SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security) + } + let verifiedAnnounce = trustDecision.isVerified + + var isNewPeer = false + var isReconnectedPeer = false + let directLinkState = env.linkState(peerID) + let isDirectAnnounce = packet.ttl == env.messageTTL + + env.withRegistryBarrier { + let hasPeripheralConnection = directLinkState.hasPeripheral + let hasCentralSubscription = directLinkState.hasCentral + + // Require verified announce; ignore otherwise (no backward compatibility) + if !verifiedAnnounce { + SecureLogger.warning("❌ Ignoring unverified announce from \(peerID.id.prefix(8))…", category: .security) + // Reset flags to prevent post-barrier code from acting on unverified announces + isNewPeer = false + isReconnectedPeer = false + return + } + + let update = env.upsertVerifiedAnnounce( + peerID, + announcement, + isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription, + now + ) + isNewPeer = update.isNewPeer + isReconnectedPeer = update.wasDisconnected + + // Log connection status only for direct connectivity changes; debounce to reduce spam + if isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription { + let now = env.now() + if update.isNewPeer { + SecureLogger.debug("🆕 New peer: \(announcement.nickname)", category: .session) + } else if update.wasDisconnected { + if env.shouldEmitReconnectLog(peerID, now) { + SecureLogger.debug("🔄 Peer \(announcement.nickname) reconnected", category: .session) + } + } else if let previousNickname = update.previousNickname, previousNickname != announcement.nickname { + SecureLogger.debug("🔄 Peer \(peerID.id.prefix(8))… changed nickname: \(previousNickname) -> \(announcement.nickname)", category: .session) + } + } + } + + // Update topology with verified neighbor claims (only for authenticated announces) + if verifiedAnnounce, let neighbors = announcement.directNeighbors { + env.updateTopology(peerID, neighbors) + } + + // Persist cryptographic identity and signing key for robust offline verification + env.persistIdentity(announcement) + + let announceBackID = "announce-back-\(peerID)" + let shouldSendBack = !env.dedupContains(announceBackID) + if shouldSendBack { + env.dedupMarkProcessed(announceBackID) + } + let responsePlan = BLEAnnounceResponsePolicy.plan( + isDirectAnnounce: isDirectAnnounce, + isNewPeer: isNewPeer, + isReconnectedPeer: isReconnectedPeer, + shouldSendAnnounceBack: shouldSendBack + ) + + // Only notify of connection for new or reconnected peers when it is a + // direct announce; the list update always follows in the same hop. + env.deliverAnnounceUIEvents( + peerID, + responsePlan.shouldNotifyPeerConnected, + responsePlan.shouldNotifyPeerConnected && responsePlan.shouldScheduleInitialSync + ) + + // Track for sync (include our own and others' announces) + env.trackPacketSeen(packet) + + if responsePlan.shouldSendAnnounceBack { + // Reciprocate announce for bidirectional discovery + // Force send to ensure the peer receives our announce + env.sendAnnounceBack() + } + + // Afterglow: on first-seen peers, schedule a short re-announce to push presence one more hop + if responsePlan.shouldScheduleAfterglow { + let delay = Double.random(in: 0.3...0.6) + env.scheduleAfterglow(delay) + } + } +} diff --git a/bitchat/Services/BLE/BLEFileTransferHandler.swift b/bitchat/Services/BLE/BLEFileTransferHandler.swift new file mode 100644 index 00000000..43d13aa9 --- /dev/null +++ b/bitchat/Services/BLE/BLEFileTransferHandler.swift @@ -0,0 +1,123 @@ +import BitFoundation +import BitLogger +import Foundation + +/// Narrow environment for `BLEFileTransferHandler`. +/// +/// All queue hops (collections registry reads/writes, main-actor UI +/// notification) live inside the closures supplied by `BLEService`, keeping +/// the handler queue-agnostic and synchronously testable. +struct BLEFileTransferHandlerEnvironment { + /// Local peer identity at the time the transfer is handled. + let localPeerID: () -> PeerID + /// Local nickname used for sender resolution and collision checks. + let localNickname: () -> String + /// Snapshot of known peers keyed by ID (registry read). + let peersSnapshot: () -> [PeerID: BLEPeerInfo] + /// Resolves a display name from a verified packet signature for peers missing from the registry. + let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String? + /// Tracks the broadcast file packet for gossip sync. + let trackPacketSeen: (BitchatPacket) -> Void + /// Enforces the incoming-media storage quota before saving (BCH-01-002). + let enforceStorageQuota: (_ reservingBytes: Int) -> Void + /// Persists the validated file to the incoming-media store; returns the destination URL. + let saveIncomingFile: ( + _ data: Data, + _ preferredName: String?, + _ subdirectory: String, + _ fallbackExtension: String?, + _ defaultPrefix: String + ) -> URL? + /// Updates the registry last-seen timestamp for the peer (async barrier write). + let updatePeerLastSeen: (PeerID) -> Void + /// Delivers `.messageReceived` to the UI as one main-actor hop. + let deliverMessage: (BitchatMessage) -> Void +} + +/// Orchestrates inbound file transfers: self-echo policy, sender display-name +/// resolution, delivery planning, payload validation, quota-checked storage, +/// and UI delivery. +final class BLEFileTransferHandler { + private let environment: BLEFileTransferHandlerEnvironment + + init(environment: BLEFileTransferHandlerEnvironment) { + self.environment = environment + } + + func handle(_ packet: BitchatPacket, from peerID: PeerID) { + let env = environment + if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return } + + let peersSnapshot = env.peersSnapshot() + guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer( + peerID: peerID, + localPeerID: env.localPeerID(), + localNickname: env.localNickname(), + peers: peersSnapshot, + allowConnectedUnverified: true + ) ?? env.signedSenderDisplayName(packet, peerID) else { + SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security) + return + } + + guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: env.localPeerID()) else { + return + } + if deliveryPlan.shouldTrackForSync { + env.trackPacketSeen(packet) + } + + let filePacket: BitchatFilePacket + let mime: MimeType + switch BLEIncomingFileValidator.validate(payload: packet.payload) { + case .success(let acceptance): + filePacket = acceptance.filePacket + mime = acceptance.mime + case .failure(.malformedPayload): + SecureLogger.error("❌ Failed to decode file transfer payload", category: .session) + return + case .failure(.payloadTooLarge(let bytes)): + SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security) + return + case .failure(.unsupportedMime(let mimeType, let bytes)): + SecureLogger.warning("🚫 MIME REJECT: '\(mimeType ?? "")' not supported. Size=\(bytes)b from \(peerID.id.prefix(8))...", category: .security) + return + case .failure(.magicMismatch(let mime, let bytes, let prefixHex)): + SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(bytes)b prefix=[\(prefixHex)] from \(peerID.id.prefix(8))...", category: .security) + return + } + + // BCH-01-002: Enforce storage quota before saving + env.enforceStorageQuota(filePacket.content.count) + + guard let destination = env.saveIncomingFile( + filePacket.content, + filePacket.fileName, + "\(mime.category.mediaDir)/incoming", + mime.defaultExtension, + mime.category.rawValue + ) else { + return + } + + if deliveryPlan.isPrivateMessage { + env.updatePeerLastSeen(peerID) + } + + let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) + let message = BitchatMessage( + sender: senderNickname, + content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)", + timestamp: ts, + isRelay: false, + originalSender: nil, + isPrivate: deliveryPlan.isPrivateMessage, + recipientNickname: nil, + senderPeerID: peerID + ) + + SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session) + + env.deliverMessage(message) + } +} diff --git a/bitchat/Services/BLE/BLEFragmentHandler.swift b/bitchat/Services/BLE/BLEFragmentHandler.swift new file mode 100644 index 00000000..da65fd36 --- /dev/null +++ b/bitchat/Services/BLE/BLEFragmentHandler.swift @@ -0,0 +1,94 @@ +import BitFoundation +import BitLogger +import Foundation + +/// Narrow environment for `BLEFragmentHandler`. +/// +/// All queue hops (the message-queue entry hop and the collections barrier +/// around the assembly buffer) live on the `BLEService` side — the entry hop +/// in `BLEService.handleFragment`, the barrier inside the supplied closures — +/// keeping the handler queue-agnostic and synchronously testable. +struct BLEFragmentHandlerEnvironment { + /// Local peer identity at the time the fragment is handled. + let localPeerID: () -> PeerID + /// Tracks broadcast fragments for gossip sync. + let trackPacketSeen: (BitchatPacket) -> Void + /// Appends the fragment to the assembly buffer (collections barrier write). + let appendFragment: (BLEFragmentHeader) -> BLEFragmentAssemblyBuffer.AppendResult + /// Ingress acceptance check for the reassembled inner packet. + let isAcceptedIngressPayload: (_ packet: BitchatPacket, _ innerSender: PeerID) -> Bool + /// Re-enters the receive pipeline with the reassembled packet (TTL already zeroed). + let processReassembledPacket: (_ packet: BitchatPacket, _ from: PeerID) -> Void +} + +/// Orchestrates inbound fragments: self-fragment suppression, gossip tracking, +/// assembly-buffer appends, and reassembled-packet validation and re-injection +/// into the receive pipeline. +final class BLEFragmentHandler { + private let environment: BLEFragmentHandlerEnvironment + + init(environment: BLEFragmentHandlerEnvironment) { + self.environment = environment + } + + func handle(_ packet: BitchatPacket, from peerID: PeerID) { + let env = environment + // Don't process our own fragments + if peerID == env.localPeerID() { + return + } + + guard let header = BLEFragmentHeader(packet: packet) else { return } + + if header.isBroadcastFragment { + env.trackPacketSeen(packet) + } + + let assemblyResult = env.appendFragment(header) + + logFragmentAssemblyResult(assemblyResult) + + guard case let .complete(completedHeader, reassembled, _) = assemblyResult else { return } + + // Decode the original packet bytes we reassembled, so flags/compression are preserved + if var originalPacket = BinaryProtocol.decode(reassembled) { + + // Reassembled packet validation + let innerSender = PeerID(hexData: originalPacket.senderID) + if !env.isAcceptedIngressPayload(originalPacket, innerSender) { + // Cleanup below + } else { + SecureLogger.debug("✅ Reassembled packet id=\(completedHeader.idLogString) type=\(originalPacket.type) bytes=\(reassembled.count)", category: .session) + originalPacket.ttl = 0 + env.processReassembledPacket(originalPacket, peerID) + } + } else { + SecureLogger.error("❌ Failed to decode reassembled packet (type=\(completedHeader.originalType), total=\(completedHeader.total))", category: .session) + } + } + + private func logFragmentAssemblyResult(_ result: BLEFragmentAssemblyBuffer.AppendResult) { + func logStartedIfNeeded(header: BLEFragmentHeader, started: Bool) { + if started { + SecureLogger.debug("📦 Started fragment assembly id=\(header.idLogString) total=\(header.total)", category: .session) + } + } + + switch result { + case let .stored(header, started): + logStartedIfNeeded(header: header, started: started) + SecureLogger.debug("📦 Fragment \(header.index + 1)/\(header.total) (len=\(header.fragmentData.count)) for id=\(header.idLogString)", category: .session) + + case let .complete(header, _, started): + logStartedIfNeeded(header: header, started: started) + SecureLogger.debug("📦 Fragment \(header.index + 1)/\(header.total) (len=\(header.fragmentData.count)) for id=\(header.idLogString)", category: .session) + + case let .oversized(header, projectedSize, limit, started): + logStartedIfNeeded(header: header, started: started) + SecureLogger.warning( + "🚫 Fragment assembly exceeds size limit (\(projectedSize) bytes > \(limit)), evicting. Type=\(header.originalType) Index=\(header.index)/\(header.total)", + category: .security + ) + } + } +} diff --git a/bitchat/Services/BLE/BLENoisePacketHandler.swift b/bitchat/Services/BLE/BLENoisePacketHandler.swift new file mode 100644 index 00000000..bddbc053 --- /dev/null +++ b/bitchat/Services/BLE/BLENoisePacketHandler.swift @@ -0,0 +1,132 @@ +import BitFoundation +import BitLogger +import Foundation + +/// Narrow environment for `BLENoisePacketHandler`. +/// +/// All queue hops (collections barrier writes, main-actor UI notification) +/// and every `noiseService.*` crypto call live inside the closures supplied by +/// `BLEService`, keeping the handler queue-agnostic and synchronously testable. +struct BLENoisePacketHandlerEnvironment { + /// Local peer identity at the time the packet is handled. + let localPeerID: () -> PeerID + /// Local peer ID bytes used as the sender of handshake responses. + let localPeerIDData: () -> Data + /// TTL value used for direct (non-relayed) packets. + let messageTTL: UInt8 + /// Current time source. + let now: () -> Date + /// Processes an inbound handshake message, returning an optional response payload (crypto). + let processHandshakeMessage: (_ peerID: PeerID, _ message: Data) throws -> Data? + /// Whether any Noise session (established or pending) exists for the peer (crypto). + let hasNoiseSession: (PeerID) -> Bool + /// Initiates a fresh Noise handshake with the peer (crypto + send). + let initiateHandshake: (PeerID) -> Void + /// Broadcasts a packet on the mesh (caller is already on the message queue). + let broadcastPacket: (BitchatPacket) -> Void + /// Updates the registry last-seen timestamp for the peer (async barrier write). + let updatePeerLastSeen: (PeerID) -> Void + /// Decrypts an encrypted payload from the peer (crypto). + let decrypt: (_ payload: Data, _ peerID: PeerID) throws -> Data + /// Clears the peer's Noise session after an unrecoverable decrypt failure (crypto). + let clearSession: (PeerID) -> Void + /// Delivers `.noisePayloadReceived` to the UI as one main-actor hop. + let deliverNoisePayload: ( + _ peerID: PeerID, + _ type: NoisePayloadType, + _ payload: Data, + _ timestamp: Date + ) -> Void +} + +/// Orchestrates the Noise session domain for inbound packets: handshake +/// processing (with response), encrypted payload decryption and dispatch, +/// and session recovery on decrypt failure. +final class BLENoisePacketHandler { + private let environment: BLENoisePacketHandlerEnvironment + + init(environment: BLENoisePacketHandlerEnvironment) { + self.environment = environment + } + + func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) { + let env = environment + // Use NoiseEncryptionService for handshake processing + if PeerID(hexData: packet.recipientID) == env.localPeerID() { + // Handshake is for us + do { + if let response = try env.processHandshakeMessage(peerID, packet.payload) { + // Send response + let responsePacket = BitchatPacket( + type: MessageType.noiseHandshake.rawValue, + senderID: env.localPeerIDData(), + recipientID: Data(hexString: peerID.id), + timestamp: UInt64(env.now().timeIntervalSince1970 * 1000), + payload: response, + signature: nil, + ttl: env.messageTTL + ) + // We're on messageQueue from delegate callback + env.broadcastPacket(responsePacket) + } + + // Session establishment will trigger onPeerAuthenticated callback + // which will send any pending messages at the right time + } catch { + SecureLogger.error("Failed to process handshake: \(error)") + // Try initiating a new handshake + if !env.hasNoiseSession(peerID) { + env.initiateHandshake(peerID) + } + } + } + } + + func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) { + let env = environment + guard let recipientID = PeerID(hexData: packet.recipientID) else { + SecureLogger.warning("⚠️ Encrypted message has no recipient ID", category: .session) + return + } + + if recipientID != env.localPeerID() { + SecureLogger.debug("🔐 Encrypted message not for me (for \(recipientID.id.prefix(8))…, I am \(env.localPeerID().id.prefix(8))…)", category: .session) + return + } + + // Update lastSeen for the peer we received from (important for private messages) + env.updatePeerLastSeen(peerID) + + do { + let decrypted = try env.decrypt(packet.payload, peerID) + guard decrypted.count > 0 else { return } + + // First byte indicates the payload type + let payloadType = decrypted[0] + let payloadData = decrypted.dropFirst() + + guard let noisePayloadType = NoisePayloadType(rawValue: payloadType) else { + SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)") + return + } + + SecureLogger.debug("🔐 Decrypted noise payload type \(noisePayloadType.description) from \(peerID.id.prefix(8))…", category: .session) + + let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) + env.deliverNoisePayload(peerID, noisePayloadType, Data(payloadData), ts) + } catch NoiseEncryptionError.sessionNotEstablished { + // We received an encrypted message before establishing a session with this peer. + // Trigger a handshake so future messages can be decrypted. + SecureLogger.debug("🔑 Encrypted message from \(peerID.id.prefix(8))… without session; initiating handshake") + if !env.hasNoiseSession(peerID) { + env.initiateHandshake(peerID) + } + } catch { + // Decryption failed - clear the corrupted session and re-initiate handshake + // This handles cases where session state got out of sync (nonce mismatch, etc.) + SecureLogger.error("❌ Failed to decrypt message from \(peerID.id.prefix(8))…: \(error) - clearing session and re-initiating handshake") + env.clearSession(peerID) + env.initiateHandshake(peerID) + } + } +} diff --git a/bitchat/Services/BLE/BLEPublicMessageHandler.swift b/bitchat/Services/BLE/BLEPublicMessageHandler.swift new file mode 100644 index 00000000..434eeaee --- /dev/null +++ b/bitchat/Services/BLE/BLEPublicMessageHandler.swift @@ -0,0 +1,104 @@ +import BitFoundation +import BitLogger +import Foundation + +/// Narrow environment for `BLEPublicMessageHandler`. +/// +/// All queue hops (collections registry reads, BLE-queue link-state reads, +/// main-actor UI notification) live inside the closures supplied by +/// `BLEService`, keeping the handler queue-agnostic and synchronously testable. +struct BLEPublicMessageHandlerEnvironment { + /// Local peer identity at the time the message is handled. + let localPeerID: () -> PeerID + /// Local nickname used for sender resolution and collision checks. + let localNickname: () -> String + /// Current time source. + let now: () -> Date + /// Snapshot of known peers keyed by ID (registry read). + let peersSnapshot: () -> [PeerID: BLEPeerInfo] + /// Resolves a display name from a verified packet signature for peers missing from the registry. + let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String? + /// Tracks the broadcast message packet for gossip sync. + let trackPacketSeen: (BitchatPacket) -> Void + /// Direct link state for the peer (BLE-queue read). + let linkState: (PeerID) -> (hasPeripheral: Bool, hasCentral: Bool) + /// Resolves and consumes the original message ID for our own re-broadcast. + let takeSelfBroadcastMessageID: (BitchatPacket) -> String? + /// Delivers `.publicMessageReceived` to the UI as one main-actor hop. + let deliverPublicMessage: ( + _ peerID: PeerID, + _ nickname: String, + _ content: String, + _ timestamp: Date, + _ messageID: String? + ) -> Void +} + +/// Orchestrates inbound public (broadcast) messages: freshness/self-echo +/// policy, sender display-name resolution, gossip tracking, payload decoding, +/// and UI delivery. +final class BLEPublicMessageHandler { + private let environment: BLEPublicMessageHandlerEnvironment + + init(environment: BLEPublicMessageHandlerEnvironment) { + self.environment = environment + } + + func handle(_ packet: BitchatPacket, from peerID: PeerID) { + let env = environment + let now = env.now() + let messageDecision = BLEPublicMessagePolicy.evaluate( + packet: packet, + from: peerID, + localPeerID: env.localPeerID(), + now: now + ) + + let messagePolicy: BLEPublicMessageAcceptance + switch messageDecision { + case .accept(let acceptance): + messagePolicy = acceptance + case .reject(.selfEcho): + return + case .reject(.staleBroadcast(let ageSeconds)): + SecureLogger.debug("⏰ Ignoring stale broadcast message from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session) + return + } + + // Snapshot peers to avoid concurrent mutation while iterating during nickname collision checks. + let peersSnapshot = env.peersSnapshot() + + guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer( + peerID: peerID, + localPeerID: env.localPeerID(), + localNickname: env.localNickname(), + peers: peersSnapshot, + allowConnectedUnverified: false + ) ?? env.signedSenderDisplayName(packet, peerID) else { + SecureLogger.warning("🚫 Dropping public message from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security) + return + } + + if messagePolicy.shouldTrackForSync { + env.trackPacketSeen(packet) + } + + guard let content = String(data: packet.payload, encoding: .utf8) else { + SecureLogger.error("❌ Failed to decode message payload as UTF-8", category: .session) + return + } + // Determine if we have a direct link to the sender + let directLink = env.linkState(peerID) + let hasDirectLink = directLink.hasPeripheral || directLink.hasCentral + + let pathTag = hasDirectLink ? "direct" : "mesh" + SecureLogger.debug("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)) chars=\(content.count) bytes=\(packet.payload.count)", category: .session) + + let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) + var resolvedSelfMessageID: String? = nil + if peerID == env.localPeerID() { + resolvedSelfMessageID = env.takeSelfBroadcastMessageID(packet) + } + env.deliverPublicMessage(peerID, senderNickname, content, ts, resolvedSelfMessageID) + } +} diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 13cd420e..d5c0635e 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -110,6 +110,16 @@ final class BLEService: NSObject { private var pendingDirectedRelays = BLEDirectedRelaySpool() // Debounce for 'reconnected' logs private var reconnectLogDebouncer = BLEPeerEventDebouncer() + // Announce-packet orchestration (queue hops stay in the environment closures) + private lazy var announceHandler = BLEAnnounceHandler(environment: makeAnnounceHandlerEnvironment()) + // Public-message orchestration (queue hops stay in the environment closures) + private lazy var publicMessageHandler = BLEPublicMessageHandler(environment: makePublicMessageHandlerEnvironment()) + // Noise handshake/encrypted orchestration (queue hops and crypto stay in the environment closures) + private lazy var noisePacketHandler = BLENoisePacketHandler(environment: makeNoisePacketHandlerEnvironment()) + // Fragment-assembly orchestration (queue hops stay in the environment closures) + private lazy var fragmentHandler = BLEFragmentHandler(environment: makeFragmentHandlerEnvironment()) + // File-transfer orchestration (queue hops stay in the environment closures) + private lazy var fileTransferHandler = BLEFileTransferHandler(environment: makeFileTransferHandlerEnvironment()) // MARK: - Gossip Sync private var gossipSyncManager: GossipSyncManager? @@ -195,6 +205,9 @@ final class BLEService: NSObject { // Tag BLE queue for re-entrancy detection bleQueue.setSpecific(key: bleQueueKey, value: ()) + // Link state is owned exclusively by bleQueue; debug builds trap + // any access from another queue (cross-queue reads use readLinkState). + linkStateStore.assumeOwnership(of: bleQueue) if initializeBluetoothManagers { // Initialize BLE on background queue to prevent main thread blocking. @@ -1005,79 +1018,50 @@ final class BLEService: NSObject { } private func handleFileTransfer(_ packet: BitchatPacket, from peerID: PeerID) { - if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: myPeerID) { return } + fileTransferHandler.handle(packet, from: peerID) + } - let peersSnapshot = collectionsQueue.sync { peerRegistry.snapshotByID } - guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer( - peerID: peerID, - localPeerID: myPeerID, - localNickname: myNickname, - peers: peersSnapshot, - allowConnectedUnverified: true - ) ?? signedSenderDisplayName(for: packet, from: peerID) else { - SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security) - return - } - - guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: myPeerID) else { - return - } - if deliveryPlan.shouldTrackForSync { - gossipSyncManager?.onPublicPacketSeen(packet) - } - - let filePacket: BitchatFilePacket - let mime: MimeType - switch BLEIncomingFileValidator.validate(payload: packet.payload) { - case .success(let acceptance): - filePacket = acceptance.filePacket - mime = acceptance.mime - case .failure(.malformedPayload): - SecureLogger.error("❌ Failed to decode file transfer payload", category: .session) - return - case .failure(.payloadTooLarge(let bytes)): - SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security) - return - case .failure(.unsupportedMime(let mimeType, let bytes)): - SecureLogger.warning("🚫 MIME REJECT: '\(mimeType ?? "")' not supported. Size=\(bytes)b from \(peerID.id.prefix(8))...", category: .security) - return - case .failure(.magicMismatch(let mime, let bytes, let prefixHex)): - SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(bytes)b prefix=[\(prefixHex)] from \(peerID.id.prefix(8))...", category: .security) - return - } - - // BCH-01-002: Enforce storage quota before saving - incomingFileStore.enforceQuota(reservingBytes: filePacket.content.count) - - guard let destination = incomingFileStore.save( - data: filePacket.content, - preferredName: filePacket.fileName, - subdirectory: "\(mime.category.mediaDir)/incoming", - fallbackExtension: mime.defaultExtension, - defaultPrefix: mime.category.rawValue - ) else { - return - } - - if deliveryPlan.isPrivateMessage { - updatePeerLastSeen(peerID) - } - - let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) - let message = BitchatMessage( - sender: senderNickname, - content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)", - timestamp: ts, - isRelay: false, - originalSender: nil, - isPrivate: deliveryPlan.isPrivateMessage, - recipientNickname: nil, - senderPeerID: peerID + /// Builds the file-transfer handler environment. All queue hops stay here + /// so `BLEFileTransferHandler` remains queue-agnostic and synchronously + /// testable. + private func makeFileTransferHandlerEnvironment() -> BLEFileTransferHandlerEnvironment { + BLEFileTransferHandlerEnvironment( + localPeerID: { [weak self] in + self?.myPeerID ?? PeerID(str: "") + }, + localNickname: { [weak self] in + self?.myNickname ?? "" + }, + peersSnapshot: { [weak self] in + guard let self = self else { return [:] } + return self.collectionsQueue.sync { self.peerRegistry.snapshotByID } + }, + signedSenderDisplayName: { [weak self] packet, peerID in + self?.signedSenderDisplayName(for: packet, from: peerID) + }, + trackPacketSeen: { [weak self] packet in + self?.gossipSyncManager?.onPublicPacketSeen(packet) + }, + enforceStorageQuota: { [weak self] reservingBytes in + self?.incomingFileStore.enforceQuota(reservingBytes: reservingBytes) + }, + saveIncomingFile: { [weak self] data, preferredName, subdirectory, fallbackExtension, defaultPrefix in + self?.incomingFileStore.save( + data: data, + preferredName: preferredName, + subdirectory: subdirectory, + fallbackExtension: fallbackExtension, + defaultPrefix: defaultPrefix + ) + }, + updatePeerLastSeen: { [weak self] peerID in + self?.updatePeerLastSeen(peerID) + }, + deliverMessage: { [weak self] message in + // Single main-actor hop delivering `.messageReceived`. + self?.emitTransportEvent(.messageReceived(message)) + } ) - - SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session) - - emitTransportEvent(.messageReceived(message)) } func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { @@ -2775,74 +2759,39 @@ extension BLEService { private func handleFragment(_ packet: BitchatPacket, from peerID: PeerID) { if DispatchQueue.getSpecific(key: messageQueueKey) != nil { - _handleFragment(packet, from: peerID) + fragmentHandler.handle(packet, from: peerID) } else { messageQueue.async(flags: .barrier) { [weak self] in - self?._handleFragment(packet, from: peerID) + self?.fragmentHandler.handle(packet, from: peerID) } } } - private func _handleFragment(_ packet: BitchatPacket, from peerID: PeerID) { - // Don't process our own fragments - if peerID == myPeerID { - return - } - - guard let header = BLEFragmentHeader(packet: packet) else { return } - - if header.isBroadcastFragment { - gossipSyncManager?.onPublicPacketSeen(packet) - } - - let assemblyResult = collectionsQueue.sync(flags: .barrier) { - fragmentAssemblyBuffer.append(header, maxInFlightAssemblies: maxInFlightAssemblies) - } - - logFragmentAssemblyResult(assemblyResult) - - guard case let .complete(completedHeader, reassembled, _) = assemblyResult else { return } - - // Decode the original packet bytes we reassembled, so flags/compression are preserved - if var originalPacket = BinaryProtocol.decode(reassembled) { - - // Reassembled packet validation - let innerSender = PeerID(hexData: originalPacket.senderID) - if !isAcceptedIngressPayload(originalPacket, from: innerSender) { - // Cleanup below - } else { - SecureLogger.debug("✅ Reassembled packet id=\(completedHeader.idLogString) type=\(originalPacket.type) bytes=\(reassembled.count)", category: .session) - originalPacket.ttl = 0 - handleReceivedPacket(originalPacket, from: peerID) + /// Builds the fragment handler environment. All queue hops stay here so + /// `BLEFragmentHandler` remains queue-agnostic and synchronously testable. + private func makeFragmentHandlerEnvironment() -> BLEFragmentHandlerEnvironment { + BLEFragmentHandlerEnvironment( + localPeerID: { [weak self] in + self?.myPeerID ?? PeerID(str: "") + }, + trackPacketSeen: { [weak self] packet in + self?.gossipSyncManager?.onPublicPacketSeen(packet) + }, + appendFragment: { [weak self] header in + guard let self = self else { + return .stored(header: header, started: false) + } + return self.collectionsQueue.sync(flags: .barrier) { + self.fragmentAssemblyBuffer.append(header, maxInFlightAssemblies: self.maxInFlightAssemblies) + } + }, + isAcceptedIngressPayload: { [weak self] packet, innerSender in + self?.isAcceptedIngressPayload(packet, from: innerSender) ?? false + }, + processReassembledPacket: { [weak self] packet, peerID in + self?.handleReceivedPacket(packet, from: peerID) } - } else { - SecureLogger.error("❌ Failed to decode reassembled packet (type=\(completedHeader.originalType), total=\(completedHeader.total))", category: .session) - } - } - - private func logFragmentAssemblyResult(_ result: BLEFragmentAssemblyBuffer.AppendResult) { - func logStartedIfNeeded(header: BLEFragmentHeader, started: Bool) { - if started { - SecureLogger.debug("📦 Started fragment assembly id=\(header.idLogString) total=\(header.total)", category: .session) - } - } - - switch result { - case let .stored(header, started): - logStartedIfNeeded(header: header, started: started) - SecureLogger.debug("📦 Fragment \(header.index + 1)/\(header.total) (len=\(header.fragmentData.count)) for id=\(header.idLogString)", category: .session) - - case let .complete(header, _, started): - logStartedIfNeeded(header: header, started: started) - SecureLogger.debug("📦 Fragment \(header.index + 1)/\(header.total) (len=\(header.fragmentData.count)) for id=\(header.idLogString)", category: .session) - - case let .oversized(header, projectedSize, limit, started): - logStartedIfNeeded(header: header, started: started) - SecureLogger.warning( - "🚫 Fragment assembly exceeds size limit (\(projectedSize) bytes > \(limit)), evicting. Type=\(header.originalType) Index=\(header.index)/\(header.total)", - category: .security - ) - } + ) } // MARK: Packet Reception @@ -2963,165 +2912,96 @@ extension BLEService { } private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) { - let now = Date() - let preflight = BLEAnnouncePreflightPolicy.evaluate( - packet: packet, - from: peerID, - localPeerID: myPeerID, - now: now - ) + announceHandler.handle(packet, from: peerID) + } - let announcement: AnnouncementPacket - switch preflight { - case .accept(let acceptance): - announcement = acceptance.announcement - case .reject(.malformed): - SecureLogger.error("❌ Failed to decode announce packet from \(peerID.id.prefix(8))…", category: .session) - return - case .reject(.senderMismatch(let derivedFromKey)): - SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))…", category: .security) - return - case .reject(.selfAnnounce): - return - case .reject(.stale(let ageSeconds)): - SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session) - return - } - - // Suppress announce logs to reduce noise - - // Precompute signature verification outside barrier to reduce contention - let existingPeerForVerify = collectionsQueue.sync { peerRegistry.info(for: peerID) } - let hasSignature = packet.signature != nil - let signatureValid: Bool - if hasSignature { - signatureValid = noiseService.verifyPacketSignature(packet, publicKey: announcement.signingPublicKey) - if !signatureValid { - SecureLogger.warning("⚠️ Signature verification for announce failed \(peerID.id.prefix(8))", category: .security) - } - } else { - signatureValid = false - } - let trustDecision = BLEAnnounceTrustPolicy.evaluate( - hasSignature: hasSignature, - signatureValid: signatureValid, - existingNoisePublicKey: existingPeerForVerify?.noisePublicKey, - announcedNoisePublicKey: announcement.noisePublicKey - ) - if case .reject(.keyMismatch) = trustDecision { - SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security) - } - let verifiedAnnounce = trustDecision.isVerified - - var isNewPeer = false - var isReconnectedPeer = false - let directLinkState = linkState(for: peerID) - let isDirectAnnounce = packet.ttl == messageTTL - - collectionsQueue.sync(flags: .barrier) { - let hasPeripheralConnection = directLinkState.hasPeripheral - let hasCentralSubscription = directLinkState.hasCentral - - // Require verified announce; ignore otherwise (no backward compatibility) - if !verifiedAnnounce { - SecureLogger.warning("❌ Ignoring unverified announce from \(peerID.id.prefix(8))…", category: .security) - // Reset flags to prevent post-barrier code from acting on unverified announces - isNewPeer = false - isReconnectedPeer = false - return - } - - let update = peerRegistry.upsertVerifiedAnnounce( - peerID: peerID, - nickname: announcement.nickname, - noisePublicKey: announcement.noisePublicKey, - signingPublicKey: announcement.signingPublicKey, - isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription, - now: now - ) - isNewPeer = update.isNewPeer - isReconnectedPeer = update.wasDisconnected - - // Log connection status only for direct connectivity changes; debounce to reduce spam - if isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription { - let now = Date() - if update.isNewPeer { - SecureLogger.debug("🆕 New peer: \(announcement.nickname)", category: .session) - } else if update.wasDisconnected { - if reconnectLogDebouncer.shouldEmit( - peerID: peerID, - now: now, - minimumInterval: TransportConfig.bleReconnectLogDebounceSeconds - ) { - SecureLogger.debug("🔄 Peer \(announcement.nickname) reconnected", category: .session) + /// Builds the announce handler environment. All queue hops stay here so + /// `BLEAnnounceHandler` remains queue-agnostic and synchronously testable. + private func makeAnnounceHandlerEnvironment() -> BLEAnnounceHandlerEnvironment { + BLEAnnounceHandlerEnvironment( + localPeerID: { [weak self] in + self?.myPeerID ?? PeerID(str: "") + }, + messageTTL: messageTTL, + now: { Date() }, + existingNoisePublicKey: { [weak self] peerID in + guard let self = self else { return nil } + return self.collectionsQueue.sync { self.peerRegistry.info(for: peerID)?.noisePublicKey } + }, + verifySignature: { [weak self] packet, signingPublicKey in + self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false + }, + linkState: { [weak self] peerID in + self?.linkState(for: peerID) ?? (hasPeripheral: false, hasCentral: false) + }, + withRegistryBarrier: { [weak self] body in + self?.collectionsQueue.sync(flags: .barrier) { body() } + }, + upsertVerifiedAnnounce: { [weak self] peerID, announcement, isConnected, now in + // Called from inside withRegistryBarrier; access registry directly. + self?.peerRegistry.upsertVerifiedAnnounce( + peerID: peerID, + nickname: announcement.nickname, + noisePublicKey: announcement.noisePublicKey, + signingPublicKey: announcement.signingPublicKey, + isConnected: isConnected, + now: now + ) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil) + }, + shouldEmitReconnectLog: { [weak self] peerID, now in + // Called from inside withRegistryBarrier; access debouncer directly. + self?.reconnectLogDebouncer.shouldEmit( + peerID: peerID, + now: now, + minimumInterval: TransportConfig.bleReconnectLogDebounceSeconds + ) ?? false + }, + updateTopology: { [weak self] peerID, neighbors in + self?.meshTopology.updateNeighbors(for: peerID.routingData, neighbors: neighbors) + }, + persistIdentity: { [weak self] announcement in + self?.identityManager.upsertCryptographicIdentity( + fingerprint: announcement.noisePublicKey.sha256Fingerprint(), + noisePublicKey: announcement.noisePublicKey, + signingPublicKey: announcement.signingPublicKey, + claimedNickname: announcement.nickname + ) + }, + dedupContains: { [weak self] id in + self?.messageDeduplicator.contains(id) ?? true + }, + dedupMarkProcessed: { [weak self] id in + self?.messageDeduplicator.markProcessed(id) + }, + deliverAnnounceUIEvents: { [weak self] peerID, notifyPeerConnected, scheduleInitialSync in + // Single main-actor hop so event order is guaranteed: + // .peerConnected → initial sync scheduling → .peerListUpdated. + self?.notifyUI { [weak self] in + guard let self = self else { return } + if notifyPeerConnected { + self.deliverTransportEvent(.peerConnected(peerID)) } - } else if let previousNickname = update.previousNickname, previousNickname != announcement.nickname { - SecureLogger.debug("🔄 Peer \(peerID.id.prefix(8))… changed nickname: \(previousNickname) -> \(announcement.nickname)", category: .session) + if scheduleInitialSync { + self.gossipSyncManager?.scheduleInitialSyncToPeer(peerID, delaySeconds: 1.0) + } + // Get current peer list (after addition) + let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs } + self.requestPeerDataPublish() + self.deliverTransportEvent(.peerListUpdated(currentPeerIDs)) } - } - } - - // Update topology with verified neighbor claims (only for authenticated announces) - if verifiedAnnounce, let neighbors = announcement.directNeighbors { - meshTopology.updateNeighbors(for: peerID.routingData, neighbors: neighbors) - } - - // Persist cryptographic identity and signing key for robust offline verification - identityManager.upsertCryptographicIdentity( - fingerprint: announcement.noisePublicKey.sha256Fingerprint(), - noisePublicKey: announcement.noisePublicKey, - signingPublicKey: announcement.signingPublicKey, - claimedNickname: announcement.nickname - ) - - let announceBackID = "announce-back-\(peerID)" - let shouldSendBack = !messageDeduplicator.contains(announceBackID) - if shouldSendBack { - messageDeduplicator.markProcessed(announceBackID) - } - let responsePlan = BLEAnnounceResponsePolicy.plan( - isDirectAnnounce: isDirectAnnounce, - isNewPeer: isNewPeer, - isReconnectedPeer: isReconnectedPeer, - shouldSendAnnounceBack: shouldSendBack - ) - - // Notify UI on main thread - notifyUI { [weak self] in - guard let self = self else { return } - - // Get current peer list (after addition) - let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs } - - // Only notify of connection for new or reconnected peers when it is a direct announce - if responsePlan.shouldNotifyPeerConnected { - self.deliverTransportEvent(.peerConnected(peerID)) - // Schedule initial unicast sync to this peer - if responsePlan.shouldScheduleInitialSync { - self.gossipSyncManager?.scheduleInitialSyncToPeer(peerID, delaySeconds: 1.0) - } - } - - self.requestPeerDataPublish() - self.deliverTransportEvent(.peerListUpdated(currentPeerIDs)) - } - - // Track for sync (include our own and others' announces) - gossipSyncManager?.onPublicPacketSeen(packet) - - if responsePlan.shouldSendAnnounceBack { - // Reciprocate announce for bidirectional discovery - // Force send to ensure the peer receives our announce - sendAnnounce(forceSend: true) - } - - // Afterglow: on first-seen peers, schedule a short re-announce to push presence one more hop - if responsePlan.shouldScheduleAfterglow { - let delay = Double.random(in: 0.3...0.6) - messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in + }, + trackPacketSeen: { [weak self] packet in + self?.gossipSyncManager?.onPublicPacketSeen(packet) + }, + sendAnnounceBack: { [weak self] in self?.sendAnnounce(forceSend: true) + }, + scheduleAfterglow: { [weak self] delay in + self?.messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in + self?.sendAnnounce(forceSend: true) + } } - } + ) } // Handle REQUEST_SYNC: decode payload and respond with missing packets via sync manager @@ -3136,156 +3016,110 @@ extension BLEService { // Mention parsing moved to ChatViewModel private func handleMessage(_ packet: BitchatPacket, from peerID: PeerID) { - let now = Date() - let messageDecision = BLEPublicMessagePolicy.evaluate( - packet: packet, - from: peerID, - localPeerID: myPeerID, - now: now - ) - - let messagePolicy: BLEPublicMessageAcceptance - switch messageDecision { - case .accept(let acceptance): - messagePolicy = acceptance - case .reject(.selfEcho): - return - case .reject(.staleBroadcast(let ageSeconds)): - SecureLogger.debug("⏰ Ignoring stale broadcast message from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session) - return - } - - // Snapshot peers to avoid concurrent mutation while iterating during nickname collision checks. - let peersSnapshot = collectionsQueue.sync { peerRegistry.snapshotByID } - - guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer( - peerID: peerID, - localPeerID: myPeerID, - localNickname: myNickname, - peers: peersSnapshot, - allowConnectedUnverified: false - ) ?? signedSenderDisplayName(for: packet, from: peerID) else { - SecureLogger.warning("🚫 Dropping public message from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security) - return - } - - if messagePolicy.shouldTrackForSync { - gossipSyncManager?.onPublicPacketSeen(packet) - } - - guard let content = String(data: packet.payload, encoding: .utf8) else { - SecureLogger.error("❌ Failed to decode message payload as UTF-8", category: .session) - return - } - // Determine if we have a direct link to the sender - let directLink = linkState(for: peerID) - let hasDirectLink = directLink.hasPeripheral || directLink.hasCentral - - let pathTag = hasDirectLink ? "direct" : "mesh" - SecureLogger.debug("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)) chars=\(content.count) bytes=\(packet.payload.count)", category: .session) - - let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) - var resolvedSelfMessageID: String? = nil - if peerID == myPeerID { - resolvedSelfMessageID = selfBroadcastTracker.takeMessageID(for: packet) - } - notifyUI { [weak self] in - self?.deliverTransportEvent( - .publicMessageReceived( - peerID: peerID, - nickname: senderNickname, - content: content, - timestamp: ts, - messageID: resolvedSelfMessageID - ) - ) - } + publicMessageHandler.handle(packet, from: peerID) } - - private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) { - // Use NoiseEncryptionService for handshake processing - if PeerID(hexData: packet.recipientID) == myPeerID { - // Handshake is for us - do { - if let response = try noiseService.processHandshakeMessage(from: peerID, message: packet.payload) { - // Send response - let responsePacket = BitchatPacket( - type: MessageType.noiseHandshake.rawValue, - senderID: myPeerIDData, - recipientID: Data(hexString: peerID.id), - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: response, - signature: nil, - ttl: messageTTL + + /// Builds the public-message handler environment. All queue hops stay here + /// so `BLEPublicMessageHandler` remains queue-agnostic and synchronously + /// testable. + private func makePublicMessageHandlerEnvironment() -> BLEPublicMessageHandlerEnvironment { + BLEPublicMessageHandlerEnvironment( + localPeerID: { [weak self] in + self?.myPeerID ?? PeerID(str: "") + }, + localNickname: { [weak self] in + self?.myNickname ?? "" + }, + now: { Date() }, + peersSnapshot: { [weak self] in + guard let self = self else { return [:] } + return self.collectionsQueue.sync { self.peerRegistry.snapshotByID } + }, + signedSenderDisplayName: { [weak self] packet, peerID in + self?.signedSenderDisplayName(for: packet, from: peerID) + }, + trackPacketSeen: { [weak self] packet in + self?.gossipSyncManager?.onPublicPacketSeen(packet) + }, + linkState: { [weak self] peerID in + self?.linkState(for: peerID) ?? (hasPeripheral: false, hasCentral: false) + }, + takeSelfBroadcastMessageID: { [weak self] packet in + // Caller is on messageQueue, where the tracker is owned. + self?.selfBroadcastTracker.takeMessageID(for: packet) + }, + deliverPublicMessage: { [weak self] peerID, nickname, content, timestamp, messageID in + // Single main-actor hop delivering `.publicMessageReceived`. + self?.notifyUI { [weak self] in + self?.deliverTransportEvent( + .publicMessageReceived( + peerID: peerID, + nickname: nickname, + content: content, + timestamp: timestamp, + messageID: messageID + ) ) - // We're on messageQueue from delegate callback - broadcastPacket(responsePacket) - } - - // Session establishment will trigger onPeerAuthenticated callback - // which will send any pending messages at the right time - } catch { - SecureLogger.error("Failed to process handshake: \(error)") - // Try initiating a new handshake - if !noiseService.hasSession(with: peerID) { - initiateNoiseHandshake(with: peerID) } } - } + ) } - + + private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) { + noisePacketHandler.handleHandshake(packet, from: peerID) + } + private func handleNoiseEncrypted(_ packet: BitchatPacket, from peerID: PeerID) { - guard let recipientID = PeerID(hexData: packet.recipientID) else { - SecureLogger.warning("⚠️ Encrypted message has no recipient ID", category: .session) - return - } - - if recipientID != myPeerID { - SecureLogger.debug("🔐 Encrypted message not for me (for \(recipientID.id.prefix(8))…, I am \(myPeerID.id.prefix(8))…)", category: .session) - return - } - - // Update lastSeen for the peer we received from (important for private messages) - updatePeerLastSeen(peerID) - - do { - let decrypted = try noiseService.decrypt(packet.payload, from: peerID) - guard decrypted.count > 0 else { return } - - // First byte indicates the payload type - let payloadType = decrypted[0] - let payloadData = decrypted.dropFirst() + noisePacketHandler.handleEncrypted(packet, from: peerID) + } - guard let noisePayloadType = NoisePayloadType(rawValue: payloadType) else { - SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)") - return + /// Builds the Noise packet handler environment. All queue hops and + /// `noiseService` crypto calls stay here so `BLENoisePacketHandler` + /// remains queue-agnostic and synchronously testable. + private func makeNoisePacketHandlerEnvironment() -> BLENoisePacketHandlerEnvironment { + BLENoisePacketHandlerEnvironment( + localPeerID: { [weak self] in + self?.myPeerID ?? PeerID(str: "") + }, + localPeerIDData: { [weak self] in + self?.myPeerIDData ?? Data() + }, + messageTTL: messageTTL, + now: { Date() }, + processHandshakeMessage: { [weak self] peerID, message in + try self?.noiseService.processHandshakeMessage(from: peerID, message: message) + }, + hasNoiseSession: { [weak self] peerID in + self?.noiseService.hasSession(with: peerID) ?? false + }, + initiateHandshake: { [weak self] peerID in + self?.initiateNoiseHandshake(with: peerID) + }, + broadcastPacket: { [weak self] packet in + self?.broadcastPacket(packet) + }, + updatePeerLastSeen: { [weak self] peerID in + self?.updatePeerLastSeen(peerID) + }, + decrypt: { [weak self] payload, peerID in + guard let self = self else { throw NoiseEncryptionError.sessionNotEstablished } + return try self.noiseService.decrypt(payload, from: peerID) + }, + clearSession: { [weak self] peerID in + self?.noiseService.clearSession(for: peerID) + }, + deliverNoisePayload: { [weak self] peerID, type, payload, timestamp in + // Single main-actor hop delivering `.noisePayloadReceived`. + self?.notifyUI { [weak self] in + self?.deliverTransportEvent(.noisePayloadReceived( + peerID: peerID, + type: type, + payload: payload, + timestamp: timestamp + )) + } } - - SecureLogger.debug("🔐 Decrypted noise payload type \(noisePayloadType.description) from \(peerID.id.prefix(8))…", category: .session) - - let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) - notifyUI { [weak self] in - self?.deliverTransportEvent(.noisePayloadReceived( - peerID: peerID, - type: noisePayloadType, - payload: Data(payloadData), - timestamp: ts - )) - } - } catch NoiseEncryptionError.sessionNotEstablished { - // We received an encrypted message before establishing a session with this peer. - // Trigger a handshake so future messages can be decrypted. - SecureLogger.debug("🔑 Encrypted message from \(peerID.id.prefix(8))… without session; initiating handshake") - if !noiseService.hasSession(with: peerID) { - initiateNoiseHandshake(with: peerID) - } - } catch { - // Decryption failed - clear the corrupted session and re-initiate handshake - // This handles cases where session state got out of sync (nonce mismatch, etc.) - SecureLogger.error("❌ Failed to decrypt message from \(peerID.id.prefix(8))…: \(error) - clearing session and re-initiating handshake") - noiseService.clearSession(for: peerID) - initiateNoiseHandshake(with: peerID) - } + ) } // MARK: Helper Functions diff --git a/bitchat/ViewModels/ChatDeliveryCoordinator.swift b/bitchat/ViewModels/ChatDeliveryCoordinator.swift index 19f3a333..fc9373bf 100644 --- a/bitchat/ViewModels/ChatDeliveryCoordinator.swift +++ b/bitchat/ViewModels/ChatDeliveryCoordinator.swift @@ -2,29 +2,65 @@ import BitFoundation import BitLogger import Foundation -final class ChatDeliveryCoordinator { - private unowned let viewModel: ChatViewModel +/// The narrow surface `ChatDeliveryCoordinator` needs from its owner. +/// +/// Coordinators should depend on the minimal context they actually use rather +/// than holding an `unowned` back-reference to the whole `ChatViewModel`. This +/// keeps the coordinator independently testable (see +/// `ChatDeliveryCoordinatorContextTests`) and makes its true dependencies +/// explicit. This protocol is the exemplar for migrating the other +/// coordinators off their `unowned let viewModel: ChatViewModel` back-refs. +@MainActor +protocol ChatDeliveryContext: AnyObject { + var messages: [BitchatMessage] { get set } + var privateChats: [PeerID: [BitchatMessage]] { get set } + var sentReadReceipts: Set { get set } + var isStartupPhase: Bool { get } + /// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`). + func notifyUIChanged() + /// Confirms receipt so the message router stops retaining the message for resend. + func markMessageDelivered(_ messageID: String) +} - init(viewModel: ChatViewModel) { - self.viewModel = viewModel +extension ChatViewModel: ChatDeliveryContext { + func notifyUIChanged() { + objectWillChange.send() + } + + func markMessageDelivered(_ messageID: String) { + messageRouter.markDelivered(messageID) + } +} + +final class ChatDeliveryCoordinator { + private unowned let context: any ChatDeliveryContext + private var messageLocationIndex: [String: Set] = [:] + private var indexedPublicMessageCount = 0 + private var indexedPublicTailMessageID: String? + private var indexedPrivateMessageCounts: [PeerID: Int] = [:] + private var indexedPrivateTailMessageIDs: [PeerID: String] = [:] + private var hasBuiltMessageLocationIndex = false + + init(context: any ChatDeliveryContext) { + self.context = context } @MainActor func cleanupOldReadReceipts() { - guard !viewModel.isStartupPhase, !viewModel.privateChats.isEmpty else { + guard !context.isStartupPhase, !context.privateChats.isEmpty else { return } let validMessageIDs = Set( - viewModel.privateChats.values.flatMap { messages in + context.privateChats.values.flatMap { messages in messages.map(\.id) } ) - let oldCount = viewModel.sentReadReceipts.count - viewModel.sentReadReceipts = viewModel.sentReadReceipts.intersection(validMessageIDs) + let oldCount = context.sentReadReceipts.count + context.sentReadReceipts = context.sentReadReceipts.intersection(validMessageIDs) - let removedCount = oldCount - viewModel.sentReadReceipts.count + let removedCount = oldCount - context.sentReadReceipts.count if removedCount > 0 { SecureLogger.debug("🧹 Cleaned up \(removedCount) old read receipts", category: .session) } @@ -45,48 +81,65 @@ final class ChatDeliveryCoordinator { @MainActor func deliveryStatus(for messageID: String) -> DeliveryStatus? { - if let message = viewModel.messages.first(where: { $0.id == messageID }) { - return message.deliveryStatus + withValidLocations(for: messageID) { locations in + locations.lazy.compactMap { self.deliveryStatus(at: $0) }.first } - - for messages in viewModel.privateChats.values { - if let message = messages.first(where: { $0.id == messageID }) { - return message.deliveryStatus - } - } - - return nil } @MainActor @discardableResult func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool { - var didUpdateStatus = false + switch status { + case .delivered, .read: + // Confirmed receipt — stop retaining the message for resend. + context.markMessageDelivered(messageID) + default: + break + } - if let index = viewModel.messages.firstIndex(where: { $0.id == messageID }) { - let currentStatus = viewModel.messages[index].deliveryStatus + var didUpdateStatus = false + var didUpdatePrivateStatus = false + let locations = withValidLocations(for: messageID) { $0 } + guard !locations.isEmpty else { return false } + + for location in locations { + guard case .publicTimeline(let index) = location, + index < context.messages.count, + context.messages[index].id == messageID else { + continue + } + + let currentStatus = context.messages[index].deliveryStatus if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) { - viewModel.messages[index].deliveryStatus = status + context.messages[index].deliveryStatus = status didUpdateStatus = true } } - var privateChats = viewModel.privateChats - for (peerID, chatMessages) in privateChats { - guard let index = chatMessages.firstIndex(where: { $0.id == messageID }) else { continue } + var privateChats = context.privateChats + for location in locations { + guard case .privateChat(let peerID, let index) = location, + let chatMessages = privateChats[peerID], + index < chatMessages.count, + chatMessages[index].id == messageID else { + continue + } let currentStatus = chatMessages[index].deliveryStatus guard !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) else { continue } - let updatedMessages = chatMessages - updatedMessages[index].deliveryStatus = status - privateChats[peerID] = updatedMessages + chatMessages[index].deliveryStatus = status + privateChats[peerID] = chatMessages didUpdateStatus = true + didUpdatePrivateStatus = true + } + + if didUpdatePrivateStatus { + context.privateChats = privateChats } if didUpdateStatus { - viewModel.privateChats = privateChats - viewModel.objectWillChange.send() + context.notifyUIChanged() } return didUpdateStatus @@ -94,8 +147,14 @@ final class ChatDeliveryCoordinator { } private extension ChatDeliveryCoordinator { + enum MessageLocation: Hashable { + case publicTimeline(index: Int) + case privateChat(peerID: PeerID, index: Int) + } + func shouldSkipUpdate(currentStatus: DeliveryStatus?, newStatus: DeliveryStatus) -> Bool { guard let currentStatus else { return false } + if currentStatus == newStatus { return true } switch (currentStatus, newStatus) { case (.read, .delivered), (.read, .sent): @@ -104,4 +163,148 @@ private extension ChatDeliveryCoordinator { return false } } + + @MainActor + func withValidLocations( + for messageID: String, + _ body: (Set) -> T + ) -> T { + let didRebuildIndex = refreshMessageLocationIndexForGrowth() + + if let locations = messageLocationIndex[messageID], + locations.allSatisfy({ isLocation($0, validFor: messageID) }) { + return body(locations) + } + + guard !didRebuildIndex else { + return body(messageLocationIndex[messageID] ?? []) + } + + if messageLocationIndex[messageID] == nil { + return body([]) + } + + rebuildMessageLocationIndex() + return body(messageLocationIndex[messageID] ?? []) + } + + @MainActor + func deliveryStatus(at location: MessageLocation) -> DeliveryStatus? { + switch location { + case .publicTimeline(let index): + guard index < context.messages.count else { return nil } + return context.messages[index].deliveryStatus + case .privateChat(let peerID, let index): + guard let messages = context.privateChats[peerID], + index < messages.count else { + return nil + } + return messages[index].deliveryStatus + } + } + + @MainActor + func isLocation(_ location: MessageLocation, validFor messageID: String) -> Bool { + switch location { + case .publicTimeline(let index): + return index < context.messages.count + && context.messages[index].id == messageID + case .privateChat(let peerID, let index): + guard let messages = context.privateChats[peerID], + index < messages.count else { + return false + } + return messages[index].id == messageID + } + } + + @MainActor + @discardableResult + func refreshMessageLocationIndexForGrowth() -> Bool { + guard hasBuiltMessageLocationIndex else { + rebuildMessageLocationIndex() + return true + } + + if context.messages.count < indexedPublicMessageCount { + rebuildMessageLocationIndex() + return true + } + + if context.messages.count == indexedPublicMessageCount, + context.messages.last?.id != indexedPublicTailMessageID { + rebuildMessageLocationIndex() + return true + } + + if context.messages.count > indexedPublicMessageCount { + for index in indexedPublicMessageCount.. indexedCount else { continue } + for index in indexedCount.. geohash. + var geoSamplingSubs: [String: String] { get set } + /// Per-geohash notification cooldown: geohash -> last notify time. + var lastGeoNotificationAt: [String: Date] { get set } + var nostrRelayManager: NostrRelayManager? { get } - init(viewModel: ChatViewModel) { - self.viewModel = viewModel + // MARK: Public timeline & pipeline + var messages: [BitchatMessage] { get } + func resetPublicMessagePipeline() + func updatePublicMessagePipelineChannel(_ channel: ChannelID) + func refreshVisibleMessages(from channel: ChannelID?) + func addPublicSystemMessage(_ content: String) + func drainPendingGeohashSystemMessages() -> [String] + func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool + func synchronizePublicConversationStore(forGeohash geohash: String) + + // MARK: Inbound public messages + func handlePublicMessage(_ message: BitchatMessage) + func checkForMentions(_ message: BitchatMessage) + func sendHapticFeedback(for message: BitchatMessage) + func parseMentions(from content: String) -> [String] + + // MARK: Inbound private (geohash DM) payloads + var selectedPrivateChatPeer: PeerID? { get } + var nostrKeyMapping: [PeerID: String] { get set } + func handlePrivateMessage( + _ payload: NoisePayload, + senderPubkey: String, + convKey: PeerID, + id: NostrIdentity, + messageTimestamp: Date + ) + func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) + func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) + func startPrivateChat(with peerID: PeerID) + + // MARK: Nostr identity & blocking (shared with `ChatPrivateConversationContext`) + func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity + func currentNostrIdentity() -> NostrIdentity? + func isNostrBlocked(pubkeyHexLowercased: String) -> Bool + func displayNameForNostrPubkey(_ pubkeyHex: String) -> String + + // MARK: Event dedup + func hasProcessedNostrEvent(_ eventID: String) -> Bool + func recordProcessedNostrEvent(_ eventID: String) + func clearProcessedNostrEvents() + + // MARK: Geo participants & presence + var geoNicknames: [String: String] { get } + var teleportedGeoCount: Int { get } + func startGeoParticipantRefreshTimer() + func stopGeoParticipantRefreshTimer() + func setActiveParticipantGeohash(_ geohash: String?) + func recordGeoParticipant(pubkeyHex: String) + func recordGeoParticipant(pubkeyHex: String, geohash: String) + func geoParticipantCount(for geohash: String) -> Int + func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String) + func markGeoTeleported(_ pubkeyHexLowercased: String) + func clearGeoTeleported(_ pubkeyHexLowercased: String) + func clearTeleportedGeo() + func clearGeoNicknames() + func visibleGeohashPeople() -> [GeoPerson] + + // MARK: Location channels + var isTeleported: Bool { get } + /// True when regional channels are known and the geohash is not one of them. + func isGeohashOutsideRegionalChannels(_ geohash: String) -> Bool + + // MARK: Routing & acknowledgements (shared with `ChatPrivateConversationContext`) + func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool) + func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) + func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) +} + +extension ChatViewModel: ChatNostrContext { + // `activeChannel`, `selectedPrivateChatPeer`, `nostrKeyMapping`, + // `messages`, `geoNicknames`, the Nostr identity/blocking members, and the + // routing/ack members are shared requirements with `ChatDeliveryContext` / + // `ChatPrivateConversationContext`; their witnesses already exist. The + // members below flatten nested service accesses into intent-named calls. + + func resetPublicMessagePipeline() { + publicMessagePipeline.reset() + } + + func updatePublicMessagePipelineChannel(_ channel: ChannelID) { + publicMessagePipeline.updateActiveChannel(channel) + } + + func drainPendingGeohashSystemMessages() -> [String] { + timelineStore.drainPendingGeohashSystemMessages() + } + + func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool { + timelineStore.appendIfAbsent(message, toGeohash: geohash) + } + + func hasProcessedNostrEvent(_ eventID: String) -> Bool { + deduplicationService.hasProcessedNostrEvent(eventID) + } + + func recordProcessedNostrEvent(_ eventID: String) { + deduplicationService.recordNostrEvent(eventID) + } + + func clearProcessedNostrEvents() { + deduplicationService.clearNostrCaches() + } + + var teleportedGeoCount: Int { + locationPresenceStore.teleportedGeo.count + } + + func startGeoParticipantRefreshTimer() { + participantTracker.startRefreshTimer() + } + + func stopGeoParticipantRefreshTimer() { + participantTracker.stopRefreshTimer() + } + + func setActiveParticipantGeohash(_ geohash: String?) { + participantTracker.setActiveGeohash(geohash) + } + + func recordGeoParticipant(pubkeyHex: String) { + participantTracker.recordParticipant(pubkeyHex: pubkeyHex) + } + + func recordGeoParticipant(pubkeyHex: String, geohash: String) { + participantTracker.recordParticipant(pubkeyHex: pubkeyHex, geohash: geohash) + } + + func geoParticipantCount(for geohash: String) -> Int { + participantTracker.participantCount(for: geohash) + } + + func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String) { + locationPresenceStore.setNickname(nickname, for: pubkeyHex) + } + + func markGeoTeleported(_ pubkeyHexLowercased: String) { + locationPresenceStore.markTeleported(pubkeyHexLowercased) + } + + func clearGeoTeleported(_ pubkeyHexLowercased: String) { + locationPresenceStore.clearTeleported(pubkeyHexLowercased) + } + + func clearTeleportedGeo() { + locationPresenceStore.clearTeleportedGeo() + } + + func clearGeoNicknames() { + locationPresenceStore.clearGeoNicknames() + } + + var isTeleported: Bool { + locationManager.teleported + } + + func isGeohashOutsideRegionalChannels(_ geohash: String) -> Bool { + let channels = locationManager.availableChannels + return !channels.isEmpty && !channels.contains { $0.geohash == geohash } + } +} + +final class ChatNostrCoordinator { + private weak var context: (any ChatNostrContext)? + private var recentGeoSamplingEventIDs = Set() + private var recentGeoSamplingEventIDOrder: [String] = [] + + init(context: any ChatNostrContext) { + self.context = context } @MainActor func resubscribeCurrentGeohash() { - guard case .location(let channel) = viewModel.activeChannel else { return } - guard let subID = viewModel.geoSubscriptionID else { - switchLocationChannel(to: viewModel.activeChannel) + guard let context else { return } + guard case .location(let channel) = context.activeChannel else { return } + guard let subID = context.geoSubscriptionID else { + switchLocationChannel(to: context.activeChannel) return } - viewModel.participantTracker.startRefreshTimer() + context.startGeoParticipantRefreshTimer() NostrRelayManager.shared.unsubscribe(id: subID) let filter = NostrFilter.geohashEphemeral( channel.geohash, @@ -36,14 +222,14 @@ final class ChatNostrCoordinator { } } - if let dmSub = viewModel.geoDmSubscriptionID { + if let dmSub = context.geoDmSubscriptionID { NostrRelayManager.shared.unsubscribe(id: dmSub) - viewModel.geoDmSubscriptionID = nil + context.geoDmSubscriptionID = nil } - if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) { + if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) { let dmSub = "geo-dm-\(channel.geohash)" - viewModel.geoDmSubscriptionID = dmSub + context.geoDmSubscriptionID = dmSub let dmFilter = NostrFilter.giftWrapsFor( pubkey: identity.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) @@ -58,18 +244,19 @@ final class ChatNostrCoordinator { @MainActor func subscribeNostrEvent(_ event: NostrEvent) { + guard let context else { return } guard event.isValidSignature() else { return } guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue), - !viewModel.deduplicationService.hasProcessedNostrEvent(event.id) + !context.hasProcessedNostrEvent(event.id) else { return } - viewModel.deduplicationService.recordNostrEvent(event.id) + context.recordProcessedNostrEvent(event.id) - if let gh = viewModel.currentGeohash, - let myGeoIdentity = try? viewModel.idBridge.deriveIdentity(forGeohash: gh), + if let gh = context.currentGeohash, + let myGeoIdentity = try? context.deriveNostrIdentity(forGeohash: gh), myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() { let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) if Date().timeIntervalSince(eventTime) < 15 { @@ -79,12 +266,12 @@ final class ChatNostrCoordinator { if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 { let nick = nickTag[1].trimmed - viewModel.locationPresenceStore.setNickname(nick, for: event.pubkey) + context.setGeoNickname(nick, forPubkey: event.pubkey) } - viewModel.nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey - viewModel.nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey - viewModel.participantTracker.recordParticipant(pubkeyHex: event.pubkey) + context.nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey + context.nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey + context.recordGeoParticipant(pubkeyHex: event.pubkey) if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue { return @@ -97,24 +284,24 @@ final class ChatNostrCoordinator { if hasTeleportTag { let key = event.pubkey.lowercased() let isSelf: Bool = { - if let gh = viewModel.currentGeohash, - let myIdentity = try? viewModel.idBridge.deriveIdentity(forGeohash: gh) { + if let gh = context.currentGeohash, + let myIdentity = try? context.deriveNostrIdentity(forGeohash: gh) { return myIdentity.publicKeyHex.lowercased() == key } return false }() if !isSelf { - Task { @MainActor [weak viewModel] in - viewModel?.locationPresenceStore.markTeleported(key) + Task { @MainActor [weak context] in + context?.markGeoTeleported(key) } } } - let senderName = viewModel.displayNameForNostrPubkey(event.pubkey) + let senderName = context.displayNameForNostrPubkey(event.pubkey) let content = event.content.trimmed let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at)) let timestamp = min(rawTs, Date()) - let mentions = viewModel.parseMentions(from: content) + let mentions = context.parseMentions(from: content) let message = BitchatMessage( id: event.id, sender: senderName, @@ -125,22 +312,23 @@ final class ChatNostrCoordinator { mentions: mentions.isEmpty ? nil : mentions ) - Task { @MainActor [weak viewModel] in - guard let viewModel else { return } - let isBlocked = viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) - viewModel.handlePublicMessage(message) + Task { @MainActor [weak context] in + guard let context else { return } + let isBlocked = context.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) + context.handlePublicMessage(message) if !isBlocked { - viewModel.checkForMentions(message) - viewModel.sendHapticFeedback(for: message) + context.checkForMentions(message) + context.sendHapticFeedback(for: message) } } } @MainActor func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { + guard let context else { return } guard giftWrap.isValidSignature() else { return } - guard !viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id) else { return } - viewModel.deduplicationService.recordNostrEvent(giftWrap.id) + guard !context.hasProcessedNostrEvent(giftWrap.id) else { return } + context.recordProcessedNostrEvent(giftWrap.id) guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage( giftWrap: giftWrap, @@ -155,11 +343,11 @@ final class ChatNostrCoordinator { let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs)) let convKey = PeerID(nostr_: senderPubkey) - viewModel.nostrKeyMapping[convKey] = senderPubkey + context.nostrKeyMapping[convKey] = senderPubkey switch noisePayload.type { case .privateMessage: - viewModel.handlePrivateMessage( + context.handlePrivateMessage( noisePayload, senderPubkey: senderPubkey, convKey: convKey, @@ -167,9 +355,9 @@ final class ChatNostrCoordinator { messageTimestamp: messageTimestamp ) case .delivered: - viewModel.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey) + context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey) case .readReceipt: - viewModel.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey) + context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey) case .verifyChallenge, .verifyResponse: break } @@ -177,67 +365,66 @@ final class ChatNostrCoordinator { @MainActor func switchLocationChannel(to channel: ChannelID) { - viewModel.publicMessagePipeline.reset() - viewModel.activeChannel = channel - viewModel.publicMessagePipeline.updateActiveChannel(channel) + guard let context else { return } + context.resetPublicMessagePipeline() + context.activeChannel = channel + context.updatePublicMessagePipelineChannel(channel) - viewModel.deduplicationService.clearNostrCaches() + context.clearProcessedNostrEvents() switch channel { case .mesh: - viewModel.refreshVisibleMessages(from: .mesh) - let emptyMesh = viewModel.messages.filter { $0.content.trimmed.isEmpty }.count + context.refreshVisibleMessages(from: .mesh) + let emptyMesh = context.messages.filter { $0.content.trimmed.isEmpty }.count if emptyMesh > 0 { SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session) } - viewModel.participantTracker.stopRefreshTimer() - viewModel.participantTracker.setActiveGeohash(nil) - viewModel.locationPresenceStore.clearTeleportedGeo() + context.stopGeoParticipantRefreshTimer() + context.setActiveParticipantGeohash(nil) + context.clearTeleportedGeo() case .location: - viewModel.refreshVisibleMessages(from: channel) + context.refreshVisibleMessages(from: channel) } if case .location = channel { - for content in viewModel.timelineStore.drainPendingGeohashSystemMessages() { - viewModel.addPublicSystemMessage(content) + for content in context.drainPendingGeohashSystemMessages() { + context.addPublicSystemMessage(content) } } - if let sub = viewModel.geoSubscriptionID { + if let sub = context.geoSubscriptionID { NostrRelayManager.shared.unsubscribe(id: sub) - viewModel.geoSubscriptionID = nil + context.geoSubscriptionID = nil } - if let dmSub = viewModel.geoDmSubscriptionID { + if let dmSub = context.geoDmSubscriptionID { NostrRelayManager.shared.unsubscribe(id: dmSub) - viewModel.geoDmSubscriptionID = nil + context.geoDmSubscriptionID = nil } - viewModel.currentGeohash = nil - viewModel.participantTracker.setActiveGeohash(nil) - viewModel.locationPresenceStore.clearGeoNicknames() + context.currentGeohash = nil + context.setActiveParticipantGeohash(nil) + context.clearGeoNicknames() guard case .location(let channel) = channel else { return } - viewModel.currentGeohash = channel.geohash - viewModel.participantTracker.setActiveGeohash(channel.geohash) + context.currentGeohash = channel.geohash + context.setActiveParticipantGeohash(channel.geohash) - if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) { - viewModel.participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex) - let hasRegional = !viewModel.locationManager.availableChannels.isEmpty - let inRegional = viewModel.locationManager.availableChannels.contains { $0.geohash == channel.geohash } + if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) { + context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex) let key = identity.publicKeyHex.lowercased() - if viewModel.locationManager.teleported && hasRegional && !inRegional { - viewModel.locationPresenceStore.markTeleported(key) + if context.isTeleported && context.isGeohashOutsideRegionalChannels(channel.geohash) { + context.markGeoTeleported(key) SecureLogger.info( - "GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(viewModel.locationPresenceStore.teleportedGeo.count)", + "GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(context.teleportedGeoCount)", category: .session ) } else { - viewModel.locationPresenceStore.clearTeleported(key) + context.clearGeoTeleported(key) } } let subID = "geo-\(channel.geohash)" - viewModel.geoSubscriptionID = subID - viewModel.participantTracker.startRefreshTimer() + context.geoSubscriptionID = subID + context.startGeoParticipantRefreshTimer() let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds) let filter = NostrFilter.geohashEphemeral(channel.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit) let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5) @@ -252,6 +439,7 @@ final class ChatNostrCoordinator { @MainActor func handleNostrEvent(_ event: NostrEvent) { + guard let context else { return } guard event.isValidSignature() else { return } guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) @@ -259,13 +447,13 @@ final class ChatNostrCoordinator { return } - if viewModel.deduplicationService.hasProcessedNostrEvent(event.id) { return } - viewModel.deduplicationService.recordNostrEvent(event.id) + if context.hasProcessedNostrEvent(event.id) { return } + context.recordProcessedNostrEvent(event.id) let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",") SecureLogger.debug("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", category: .session) - if viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey) { + if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey) { return } @@ -274,8 +462,8 @@ final class ChatNostrCoordinator { } let isSelf: Bool = { - if let gh = viewModel.currentGeohash, - let my = try? viewModel.idBridge.deriveIdentity(forGeohash: gh) { + if let gh = context.currentGeohash, + let my = try? context.deriveNostrIdentity(forGeohash: gh) { return my.publicKeyHex.lowercased() == event.pubkey.lowercased() } return false @@ -283,17 +471,17 @@ final class ChatNostrCoordinator { if hasTeleportTag, !isSelf { let key = event.pubkey.lowercased() - Task { @MainActor [weak viewModel] in - guard let viewModel else { return } - viewModel.locationPresenceStore.markTeleported(key) + Task { @MainActor [weak context] in + guard let context else { return } + context.markGeoTeleported(key) SecureLogger.info( - "GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(viewModel.locationPresenceStore.teleportedGeo.count)", + "GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(context.teleportedGeoCount)", category: .session ) } } - viewModel.participantTracker.recordParticipant(pubkeyHex: event.pubkey) + context.recordGeoParticipant(pubkeyHex: event.pubkey) if isSelf { let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) @@ -303,17 +491,17 @@ final class ChatNostrCoordinator { } if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 { - viewModel.locationPresenceStore.setNickname(nickTag[1].trimmed, for: event.pubkey) + context.setGeoNickname(nickTag[1].trimmed, forPubkey: event.pubkey) } - viewModel.nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey - viewModel.nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey + context.nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey + context.nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue { return } - let senderName = viewModel.displayNameForNostrPubkey(event.pubkey) + let senderName = context.displayNameForNostrPubkey(event.pubkey) let content = event.content if let teleTag = event.tags.first(where: { $0.first == "t" }), @@ -324,7 +512,7 @@ final class ChatNostrCoordinator { } let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at)) - let mentions = viewModel.parseMentions(from: content) + let mentions = context.parseMentions(from: content) let message = BitchatMessage( id: event.id, sender: senderName, @@ -335,20 +523,21 @@ final class ChatNostrCoordinator { mentions: mentions.isEmpty ? nil : mentions ) - Task { @MainActor [weak viewModel] in - guard let viewModel else { return } - viewModel.handlePublicMessage(message) - viewModel.checkForMentions(message) - viewModel.sendHapticFeedback(for: message) + Task { @MainActor [weak context] in + guard let context else { return } + context.handlePublicMessage(message) + context.checkForMentions(message) + context.sendHapticFeedback(for: message) } } @MainActor func subscribeToGeoChat(_ channel: GeohashChannel) { - guard let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) else { return } + guard let context else { return } + guard let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) else { return } let dmSub = "geo-dm-\(channel.geohash)" - viewModel.geoDmSubscriptionID = dmSub + context.geoDmSubscriptionID = dmSub if TorManager.shared.isReady { SecureLogger.debug("GeoDM: subscribing DMs pub=\(identity.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session) } @@ -365,11 +554,12 @@ final class ChatNostrCoordinator { @MainActor func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { + guard let context else { return } guard giftWrap.isValidSignature() else { return } - if viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id) { + if context.hasProcessedNostrEvent(giftWrap.id) { return } - viewModel.deduplicationService.recordNostrEvent(giftWrap.id) + context.recordProcessedNostrEvent(giftWrap.id) guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage( giftWrap: giftWrap, @@ -392,12 +582,12 @@ final class ChatNostrCoordinator { } let convKey = PeerID(nostr_: senderPubkey) - viewModel.nostrKeyMapping[convKey] = senderPubkey + context.nostrKeyMapping[convKey] = senderPubkey switch payload.type { case .privateMessage: let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs)) - viewModel.handlePrivateMessage( + context.handlePrivateMessage( payload, senderPubkey: senderPubkey, convKey: convKey, @@ -405,19 +595,20 @@ final class ChatNostrCoordinator { messageTimestamp: messageTimestamp ) case .delivered: - viewModel.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey) + context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey) case .readReceipt: - viewModel.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey) + context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey) case .verifyChallenge, .verifyResponse: break } } @MainActor - func sendGeohash(context: ChatViewModel.GeoOutgoingContext) { - let channel = context.channel - let event = context.event - let identity = context.identity + func sendGeohash(context geoContext: ChatViewModel.GeoOutgoingContext) { + guard let context else { return } + let channel = geoContext.channel + let event = geoContext.event + let identity = geoContext.identity let targetRelays = GeoRelayDirectory.shared.closestRelays( toGeohash: channel.geohash, @@ -430,42 +621,41 @@ final class ChatNostrCoordinator { NostrRelayManager.shared.sendEvent(event, to: targetRelays) } - viewModel.participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex) - viewModel.nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = identity.publicKeyHex + context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex) + context.nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = identity.publicKeyHex SecureLogger.debug( - "GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(context.teleported)", + "GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(geoContext.teleported)", category: .session ) - let hasRegional = !viewModel.locationManager.availableChannels.isEmpty - let inRegional = viewModel.locationManager.availableChannels.contains { $0.geohash == channel.geohash } - if context.teleported && hasRegional && !inRegional { + if geoContext.teleported && context.isGeohashOutsideRegionalChannels(channel.geohash) { let key = identity.publicKeyHex.lowercased() - viewModel.locationPresenceStore.markTeleported(key) + context.markGeoTeleported(key) SecureLogger.info( - "GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(viewModel.locationPresenceStore.teleportedGeo.count)", + "GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(context.teleportedGeoCount)", category: .session ) } - viewModel.deduplicationService.recordNostrEvent(event.id) + context.recordProcessedNostrEvent(event.id) } @MainActor func beginGeohashSampling(for geohashes: [String]) { + guard let context else { return } if !TorManager.shared.isForeground() { endGeohashSampling() return } let desired = Set(geohashes) - let current = Set(viewModel.geoSamplingSubs.values) + let current = Set(context.geoSamplingSubs.values) let toAdd = desired.subtracting(current) let toRemove = current.subtracting(desired) - for (subID, gh) in viewModel.geoSamplingSubs where toRemove.contains(gh) { + for (subID, gh) in context.geoSamplingSubs where toRemove.contains(gh) { NostrRelayManager.shared.unsubscribe(id: subID) - viewModel.geoSamplingSubs.removeValue(forKey: subID) + context.geoSamplingSubs.removeValue(forKey: subID) } for gh in toAdd { @@ -475,8 +665,9 @@ final class ChatNostrCoordinator { @MainActor func subscribe(_ gh: String) { + guard let context else { return } let subID = "geo-sample-\(gh)" - viewModel.geoSamplingSubs[subID] = gh + context.geoSamplingSubs[subID] = gh let filter = NostrFilter.geohashEphemeral( gh, since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds), @@ -492,19 +683,21 @@ final class ChatNostrCoordinator { @MainActor func subscribeNostrEvent(_ event: NostrEvent, gh: String) { - guard event.isValidSignature() else { return } + guard let context else { return } guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return } + guard event.isValidSignature() else { return } + guard shouldProcessGeoSamplingEvent(event.id) else { return } - let existingCount = viewModel.participantTracker.participantCount(for: gh) - viewModel.participantTracker.recordParticipant(pubkeyHex: event.pubkey, geohash: gh) + let existingCount = context.geoParticipantCount(for: gh) + context.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh) guard let content = event.content.trimmedOrNilIfEmpty else { return } - if viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return } - if let my = try? viewModel.idBridge.deriveIdentity(forGeohash: gh), + if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return } + if let my = try? context.deriveNostrIdentity(forGeohash: gh), my.publicKeyHex.lowercased() == event.pubkey.lowercased() { return } @@ -515,10 +708,10 @@ final class ChatNostrCoordinator { #if os(iOS) guard UIApplication.shared.applicationState == .active else { return } - if case .location(let channel) = viewModel.activeChannel, channel.geohash == gh { return } + if case .location(let channel) = context.activeChannel, channel.geohash == gh { return } #elseif os(macOS) guard NSApplication.shared.isActive else { return } - if case .location(let channel) = viewModel.activeChannel, channel.geohash == gh { return } + if case .location(let channel) = context.activeChannel, channel.geohash == gh { return } #endif cooldownPerGeohash(gh, content: content, event: event) @@ -526,8 +719,9 @@ final class ChatNostrCoordinator { @MainActor func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) { + guard let context else { return } let now = Date() - let last = viewModel.lastGeoNotificationAt[gh] ?? .distantPast + let last = context.lastGeoNotificationAt[gh] ?? .distantPast if now.timeIntervalSince(last) < TransportConfig.uiGeoNotifyCooldownSeconds { return } let preview: String = { @@ -537,16 +731,16 @@ final class ChatNostrCoordinator { return String(content[.. Bool { + guard !eventID.isEmpty else { return true } + guard recentGeoSamplingEventIDs.insert(eventID).inserted else { + return false + } + recentGeoSamplingEventIDOrder.append(eventID) + + let cap = TransportConfig.geoSamplingEventLRUCap + if recentGeoSamplingEventIDOrder.count > cap { + let removeCount = recentGeoSamplingEventIDOrder.count - cap + for staleID in recentGeoSamplingEventIDOrder.prefix(removeCount) { + recentGeoSamplingEventIDs.remove(staleID) + } + recentGeoSamplingEventIDOrder.removeFirst(removeCount) + } + return true + } + + private func clearGeoSamplingEventDedup() { + recentGeoSamplingEventIDs.removeAll() + recentGeoSamplingEventIDOrder.removeAll() } @MainActor func setupNostrMessageHandling() { - guard let currentIdentity = try? viewModel.idBridge.getCurrentNostrIdentity() else { + guard let context else { return } + guard let currentIdentity = context.currentNostrIdentity() else { SecureLogger.warning("⚠️ No Nostr identity available for message handling", category: .session) return } @@ -588,7 +808,7 @@ final class ChatNostrCoordinator { since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) ) - viewModel.nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in + context.nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in Task { @MainActor [weak self] in self?.handleNostrMessage(event) } @@ -597,8 +817,10 @@ final class ChatNostrCoordinator { @MainActor func handleNostrMessage(_ giftWrap: NostrEvent) { - if viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id) { return } - viewModel.deduplicationService.recordNostrEvent(giftWrap.id) + guard let context else { return } + guard giftWrap.isValidSignature() else { return } + if context.hasProcessedNostrEvent(giftWrap.id) { return } + context.recordProcessedNostrEvent(giftWrap.id) Task.detached(priority: .userInitiated) { [weak self] in await self?.processNostrMessage(giftWrap) @@ -607,8 +829,9 @@ final class ChatNostrCoordinator { func processNostrMessage(_ giftWrap: NostrEvent) async { guard giftWrap.isValidSignature() else { return } + guard let context else { return } let currentIdentity: NostrIdentity? = await MainActor.run { - try? viewModel.idBridge.getCurrentNostrIdentity() + context.currentNostrIdentity() } guard let currentIdentity else { return } @@ -640,11 +863,11 @@ final class ChatNostrCoordinator { let payload = NoisePayload.decode(packet.payload) { let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp)) await MainActor.run { - viewModel.nostrKeyMapping[targetPeerID] = senderPubkey + context.nostrKeyMapping[targetPeerID] = senderPubkey switch payload.type { case .privateMessage: - viewModel.handlePrivateMessage( + context.handlePrivateMessage( payload, senderPubkey: senderPubkey, convKey: targetPeerID, @@ -652,9 +875,9 @@ final class ChatNostrCoordinator { messageTimestamp: messageTimestamp ) case .delivered: - viewModel.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID) + context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID) case .readReceipt: - viewModel.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID) + context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID) case .verifyChallenge, .verifyResponse: break } @@ -711,33 +934,26 @@ final class ChatNostrCoordinator { senderPubkey: String, key: Data? ) { + guard let context else { return } if let _ = key { - if let identity = try? viewModel.idBridge.getCurrentNostrIdentity() { - let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge) - transport.senderPeerID = viewModel.meshService.myPeerID - transport.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: identity) + if let identity = context.currentNostrIdentity() { + context.sendGeohashDeliveryAck(for: message.id, toRecipientHex: senderPubkey, from: identity) } - } else if let identity = try? viewModel.idBridge.getCurrentNostrIdentity() { - let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge) - transport.senderPeerID = viewModel.meshService.myPeerID - transport.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: identity) + } else if let identity = context.currentNostrIdentity() { + context.sendGeohashDeliveryAck(for: message.id, toRecipientHex: senderPubkey, from: identity) SecureLogger.debug( "Sent DELIVERED ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…", category: .session ) } - if !wasReadBefore && viewModel.selectedPrivateChatPeer == message.senderPeerID { + if !wasReadBefore && context.selectedPrivateChatPeer == message.senderPeerID { if let _ = key { - if let identity = try? viewModel.idBridge.getCurrentNostrIdentity() { - let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge) - transport.senderPeerID = viewModel.meshService.myPeerID - transport.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: identity) + if let identity = context.currentNostrIdentity() { + context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity) } - } else if let identity = try? viewModel.idBridge.getCurrentNostrIdentity() { - let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge) - transport.senderPeerID = viewModel.meshService.myPeerID - transport.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: identity) + } else if let identity = context.currentNostrIdentity() { + context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity) SecureLogger.debug( "Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…", category: .session @@ -798,6 +1014,7 @@ final class ChatNostrCoordinator { @MainActor func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) { + guard let context else { return } guard let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey), relationship.peerNostrPublicKey != nil else { SecureLogger.warning("⚠️ Cannot send favorite notification - no Nostr key for peer", category: .session) @@ -805,15 +1022,16 @@ final class ChatNostrCoordinator { } let peerID = PeerID(hexData: noisePublicKey) - viewModel.messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) + context.routeFavoriteNotification(to: peerID, isFavorite: isFavorite) } @MainActor func nostrPubkeyForDisplayName(_ name: String) -> String? { - for person in viewModel.visibleGeohashPeople() where person.displayName == name { + guard let context else { return nil } + for person in context.visibleGeohashPeople() where person.displayName == name { return person.id } - for (pub, nick) in viewModel.geoNicknames where nick == name { + for (pub, nick) in context.geoNicknames where nick == name { return pub } return nil @@ -821,22 +1039,25 @@ final class ChatNostrCoordinator { @MainActor func startGeohashDM(withPubkeyHex hex: String) { + guard let context else { return } let convKey = PeerID(nostr_: hex) - viewModel.nostrKeyMapping[convKey] = hex - viewModel.startPrivateChat(with: convKey) + context.nostrKeyMapping[convKey] = hex + context.startPrivateChat(with: convKey) } @MainActor func fullNostrHex(forSenderPeerID senderID: PeerID) -> String? { - viewModel.nostrKeyMapping[senderID] + guard let context else { return nil } + return context.nostrKeyMapping[senderID] } @MainActor func geohashDisplayName(for convKey: PeerID) -> String { - guard let full = viewModel.nostrKeyMapping[convKey] else { + guard let context else { return convKey.bare } + guard let full = context.nostrKeyMapping[convKey] else { return convKey.bare } - return viewModel.displayNameForNostrPubkey(full) + return context.displayNameForNostrPubkey(full) } } diff --git a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift index bd7ea131..66dc766b 100644 --- a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift @@ -2,20 +2,174 @@ import BitFoundation import BitLogger import Foundation +/// The narrow surface `ChatPrivateConversationCoordinator` needs from its owner. +/// +/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the +/// minimal context it actually uses instead of holding an `unowned` back-ref +/// to the whole `ChatViewModel`. This keeps the coordinator independently +/// testable (see `ChatPrivateConversationCoordinatorContextTests`) and makes +/// its true dependencies explicit. The surface is intentionally large — it +/// documents the coordinator's real coupling to private-chat state, peer +/// identity, and the routing/ack transports. +@MainActor +protocol ChatPrivateConversationContext: AnyObject { + // MARK: Conversation state + var privateChats: [PeerID: [BitchatMessage]] { get set } + var sentReadReceipts: Set { get set } + var sentGeoDeliveryAcks: Set { get set } + var unreadPrivateMessages: Set { get set } + var selectedPrivateChatPeer: PeerID? { get set } + var nickname: String { get } + var activeChannel: ChannelID { get } + var nostrKeyMapping: [PeerID: String] { get } + /// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`). + func notifyUIChanged() + + // MARK: Peers & identity + var myPeerID: PeerID { get } + func peerNickname(for peerID: PeerID) -> String? + func isPeerConnected(_ peerID: PeerID) -> Bool + func isPeerReachable(_ peerID: PeerID) -> Bool + func isPeerBlocked(_ peerID: PeerID) -> Bool + func noisePublicKey(for peerID: PeerID) -> Data? + /// Resolves the ephemeral (short) peer ID for a known Noise public key, if connected. + func ephemeralPeerID(forNoiseKey noiseKey: Data) -> PeerID? + func getPeerIDForNickname(_ nickname: String) -> PeerID? + func getFingerprint(for peerID: PeerID) -> String? + func storedFingerprint(for peerID: PeerID) -> String? + func clearStoredFingerprint(for peerID: PeerID) + + // MARK: Nostr identity + func isNostrBlocked(pubkeyHexLowercased: String) -> Bool + func displayNameForNostrPubkey(_ pubkeyHex: String) -> String + func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity + func currentNostrIdentity() -> NostrIdentity? + + // MARK: Routing & acknowledgements + func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) + func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) + func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool) + func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) + func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) + func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) + func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) + func sendDeliveryAckViaNostrEmbedded(_ message: BitchatMessage, wasReadBefore: Bool, senderPubkey: String, key: Data?) + + // MARK: System messages & chat hygiene + func addSystemMessage(_ content: String) + func addMeshOnlySystemMessage(_ content: String) + func sanitizeChat(for peerID: PeerID) +} + +extension ChatViewModel: ChatPrivateConversationContext { + // `privateChats`, `sentReadReceipts`, and `notifyUIChanged()` are shared + // requirements with `ChatDeliveryContext`; the remaining state members are + // satisfied by existing `ChatViewModel` properties and methods. + + var myPeerID: PeerID { meshService.myPeerID } + + func peerNickname(for peerID: PeerID) -> String? { + meshService.peerNickname(peerID: peerID) + } + + func isPeerConnected(_ peerID: PeerID) -> Bool { + meshService.isPeerConnected(peerID) + } + + func isPeerReachable(_ peerID: PeerID) -> Bool { + meshService.isPeerReachable(peerID) + } + + func noisePublicKey(for peerID: PeerID) -> Data? { + unifiedPeerService.getPeer(by: peerID)?.noisePublicKey + } + + func ephemeralPeerID(forNoiseKey noiseKey: Data) -> PeerID? { + unifiedPeerService.peers.first(where: { $0.noisePublicKey == noiseKey })?.peerID + } + + func storedFingerprint(for peerID: PeerID) -> String? { + peerIDToPublicKeyFingerprint[peerID] + } + + func clearStoredFingerprint(for peerID: PeerID) { + peerIdentityStore.setFingerprint(nil, for: peerID) + } + + func isNostrBlocked(pubkeyHexLowercased: String) -> Bool { + identityManager.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased) + } + + func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity { + try idBridge.deriveIdentity(forGeohash: geohash) + } + + func currentNostrIdentity() -> NostrIdentity? { + try? idBridge.getCurrentNostrIdentity() + } + + func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { + messageRouter.sendPrivate(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) + } + + func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { + messageRouter.sendReadReceipt(receipt, to: peerID) + } + + func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { + messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) + } + + func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { + meshService.sendReadReceipt(receipt, to: peerID) + } + + func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) { + makeGeohashNostrTransport().sendPrivateMessageGeohash( + content: content, + toRecipientHex: recipientHex, + from: identity, + messageID: messageID + ) + } + + func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { + makeGeohashNostrTransport().sendDeliveryAckGeohash(for: messageID, toRecipientHex: recipientHex, from: identity) + } + + func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { + makeGeohashNostrTransport().sendReadReceiptGeohash(messageID, toRecipientHex: recipientHex, from: identity) + } + + func addSystemMessage(_ content: String) { + addSystemMessage(content, timestamp: Date()) + } + + func sanitizeChat(for peerID: PeerID) { + privateChatManager.sanitizeChat(for: peerID) + } + + private func makeGeohashNostrTransport() -> NostrTransport { + let transport = NostrTransport(keychain: keychain, idBridge: idBridge) + transport.senderPeerID = meshService.myPeerID + return transport + } +} + @MainActor final class ChatPrivateConversationCoordinator { - private unowned let viewModel: ChatViewModel + private unowned let context: any ChatPrivateConversationContext - init(viewModel: ChatViewModel) { - self.viewModel = viewModel + init(context: any ChatPrivateConversationContext) { + self.context = context } func sendPrivateMessage(_ content: String, to peerID: PeerID) { guard !content.isEmpty else { return } - if viewModel.unifiedPeerService.isBlocked(peerID) { - let nickname = viewModel.meshService.peerNickname(peerID: peerID) ?? "user" - viewModel.addSystemMessage( + if context.isPeerBlocked(peerID) { + let nickname = context.peerNickname(for: peerID) ?? "user" + context.addSystemMessage( String( format: String(localized: "system.dm.blocked_recipient", comment: "System message when attempting to message a blocked user"), locale: .current, @@ -31,13 +185,13 @@ final class ChatPrivateConversationCoordinator { } guard let noiseKey = Data(hexString: peerID.id) else { return } - let isConnected = viewModel.meshService.isPeerConnected(peerID) - let isReachable = viewModel.meshService.isPeerReachable(peerID) + let isConnected = context.isPeerConnected(peerID) + let isReachable = context.isPeerReachable(peerID) let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) let isMutualFavorite = favoriteStatus?.isMutual ?? false let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil - var recipientNickname = viewModel.meshService.peerNickname(peerID: peerID) + var recipientNickname = context.peerNickname(for: peerID) if recipientNickname == nil && favoriteStatus != nil { recipientNickname = favoriteStatus?.peerNickname } @@ -46,42 +200,42 @@ final class ChatPrivateConversationCoordinator { let messageID = UUID().uuidString let message = BitchatMessage( id: messageID, - sender: viewModel.nickname, + sender: context.nickname, content: content, timestamp: Date(), isRelay: false, originalSender: nil, isPrivate: true, recipientNickname: recipientNickname, - senderPeerID: viewModel.meshService.myPeerID, + senderPeerID: context.myPeerID, mentions: nil, deliveryStatus: .sending ) - if viewModel.privateChats[peerID] == nil { - viewModel.privateChats[peerID] = [] + if context.privateChats[peerID] == nil { + context.privateChats[peerID] = [] } - viewModel.privateChats[peerID]?.append(message) - viewModel.objectWillChange.send() + context.privateChats[peerID]?.append(message) + context.notifyUIChanged() if isConnected || isReachable || (isMutualFavorite && hasNostrKey) { - viewModel.messageRouter.sendPrivate( + context.routePrivateMessage( content, to: peerID, recipientNickname: recipientNickname ?? "user", messageID: messageID ) - if let idx = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { - viewModel.privateChats[peerID]?[idx].deliveryStatus = .sent + if let idx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { + context.privateChats[peerID]?[idx].deliveryStatus = .sent } } else { - if let index = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { - viewModel.privateChats[peerID]?[index].deliveryStatus = .failed( + if let index = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { + context.privateChats[peerID]?[index].deliveryStatus = .failed( reason: String(localized: "content.delivery.reason.unreachable", comment: "Failure reason when a peer is unreachable") ) } let name = recipientNickname ?? "user" - viewModel.addSystemMessage( + context.addSystemMessage( String( format: String(localized: "system.dm.unreachable", comment: "System message when a recipient is unreachable"), locale: .current, @@ -92,8 +246,8 @@ final class ChatPrivateConversationCoordinator { } func sendGeohashDM(_ content: String, to peerID: PeerID) { - guard case .location(let channel) = viewModel.activeChannel else { - viewModel.addSystemMessage( + guard case .location(let channel) = context.activeChannel else { + context.addSystemMessage( String(localized: "system.location.not_in_channel", comment: "System message when attempting to send without being in a location channel") ) return @@ -102,49 +256,49 @@ final class ChatPrivateConversationCoordinator { let messageID = UUID().uuidString let message = BitchatMessage( id: messageID, - sender: viewModel.nickname, + sender: context.nickname, content: content, timestamp: Date(), isRelay: false, isPrivate: true, - recipientNickname: viewModel.nickname, - senderPeerID: viewModel.meshService.myPeerID, + recipientNickname: context.nickname, + senderPeerID: context.myPeerID, deliveryStatus: .sending ) - if viewModel.privateChats[peerID] == nil { - viewModel.privateChats[peerID] = [] + if context.privateChats[peerID] == nil { + context.privateChats[peerID] = [] } - viewModel.privateChats[peerID]?.append(message) - viewModel.objectWillChange.send() + context.privateChats[peerID]?.append(message) + context.notifyUIChanged() - guard let recipientHex = viewModel.nostrKeyMapping[peerID] else { - if let msgIdx = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { - viewModel.privateChats[peerID]?[msgIdx].deliveryStatus = .failed( + guard let recipientHex = context.nostrKeyMapping[peerID] else { + if let msgIdx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { + context.privateChats[peerID]?[msgIdx].deliveryStatus = .failed( reason: String(localized: "content.delivery.reason.unknown_recipient", comment: "Failure reason when the recipient is unknown") ) } return } - if viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: recipientHex) { - if let msgIdx = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { - viewModel.privateChats[peerID]?[msgIdx].deliveryStatus = .failed( + if context.isNostrBlocked(pubkeyHexLowercased: recipientHex) { + if let msgIdx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { + context.privateChats[peerID]?[msgIdx].deliveryStatus = .failed( reason: String(localized: "content.delivery.reason.blocked", comment: "Failure reason when the user is blocked") ) } - viewModel.addSystemMessage( + context.addSystemMessage( String(localized: "system.dm.blocked_generic", comment: "System message when sending fails because user is blocked") ) return } do { - let identity = try viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) + let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash) if recipientHex.lowercased() == identity.publicKeyHex.lowercased() { - if let idx = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { - viewModel.privateChats[peerID]?[idx].deliveryStatus = .failed( + if let idx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { + context.privateChats[peerID]?[idx].deliveryStatus = .failed( reason: String(localized: "content.delivery.reason.self", comment: "Failure reason when attempting to message yourself") ) } @@ -155,20 +309,18 @@ final class ChatPrivateConversationCoordinator { "GeoDM: local send mid=\(messageID.prefix(8))… to=\(recipientHex.prefix(8))… conv=\(peerID)", category: .session ) - let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge) - transport.senderPeerID = viewModel.meshService.myPeerID - transport.sendPrivateMessageGeohash( - content: content, + context.sendGeohashPrivateMessage( + content, toRecipientHex: recipientHex, from: identity, messageID: messageID ) - if let msgIdx = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { - viewModel.privateChats[peerID]?[msgIdx].deliveryStatus = .sent + if let msgIdx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { + context.privateChats[peerID]?[msgIdx].deliveryStatus = .sent } } catch { - if let idx = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { - viewModel.privateChats[peerID]?[idx].deliveryStatus = .failed( + if let idx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { + context.privateChats[peerID]?[idx].deliveryStatus = .failed( reason: String(localized: "content.delivery.reason.send_error", comment: "Failure reason for a generic send error") ) } @@ -189,16 +341,16 @@ final class ChatPrivateConversationCoordinator { sendDeliveryAckIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id) - if viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: senderPubkey) { + if context.isNostrBlocked(pubkeyHexLowercased: senderPubkey) { return } - if viewModel.privateChats[convKey]?.contains(where: { $0.id == messageId }) == true { return } - for (_, arr) in viewModel.privateChats where arr.contains(where: { $0.id == messageId }) { + if context.privateChats[convKey]?.contains(where: { $0.id == messageId }) == true { return } + for (_, arr) in context.privateChats where arr.contains(where: { $0.id == messageId }) { return } - let senderName = viewModel.displayNameForNostrPubkey(senderPubkey) + let senderName = context.displayNameForNostrPubkey(senderPubkey) let message = BitchatMessage( id: messageId, sender: senderName, @@ -206,22 +358,22 @@ final class ChatPrivateConversationCoordinator { timestamp: messageTimestamp, isRelay: false, isPrivate: true, - recipientNickname: viewModel.nickname, + recipientNickname: context.nickname, senderPeerID: convKey, - deliveryStatus: .delivered(to: viewModel.nickname, at: Date()) + deliveryStatus: .delivered(to: context.nickname, at: Date()) ) - if viewModel.privateChats[convKey] == nil { - viewModel.privateChats[convKey] = [] + if context.privateChats[convKey] == nil { + context.privateChats[convKey] = [] } - viewModel.privateChats[convKey]?.append(message) + context.privateChats[convKey]?.append(message) - let isViewing = viewModel.selectedPrivateChatPeer == convKey - let wasReadBefore = viewModel.sentReadReceipts.contains(messageId) + let isViewing = context.selectedPrivateChatPeer == convKey + let wasReadBefore = context.sentReadReceipts.contains(messageId) let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30 let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage if shouldMarkUnread { - viewModel.unreadPrivateMessages.insert(convKey) + context.unreadPrivateMessages.insert(convKey) } if isViewing { @@ -236,18 +388,18 @@ final class ChatPrivateConversationCoordinator { ) } - viewModel.objectWillChange.send() + context.notifyUIChanged() } func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) { guard let messageID = String(data: payload.data, encoding: .utf8) else { return } - if let idx = viewModel.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) { - viewModel.privateChats[convKey]?[idx].deliveryStatus = .delivered( - to: viewModel.displayNameForNostrPubkey(senderPubkey), + if let idx = context.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) { + context.privateChats[convKey]?[idx].deliveryStatus = .delivered( + to: context.displayNameForNostrPubkey(senderPubkey), at: Date() ) - viewModel.objectWillChange.send() + context.notifyUIChanged() SecureLogger.info( "GeoDM: recv DELIVERED for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", category: .session @@ -260,12 +412,12 @@ final class ChatPrivateConversationCoordinator { func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) { guard let messageID = String(data: payload.data, encoding: .utf8) else { return } - if let idx = viewModel.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) { - viewModel.privateChats[convKey]?[idx].deliveryStatus = .read( - by: viewModel.displayNameForNostrPubkey(senderPubkey), + if let idx = context.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) { + context.privateChats[convKey]?[idx].deliveryStatus = .read( + by: context.displayNameForNostrPubkey(senderPubkey), at: Date() ) - viewModel.objectWillChange.send() + context.notifyUIChanged() SecureLogger.info("GeoDM: recv READ for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", category: .session) } else { SecureLogger.warning("GeoDM: read ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session) @@ -273,19 +425,15 @@ final class ChatPrivateConversationCoordinator { } func sendDeliveryAckIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) { - guard !viewModel.sentGeoDeliveryAcks.contains(messageId) else { return } - let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge) - transport.senderPeerID = viewModel.meshService.myPeerID - transport.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubKey, from: id) - viewModel.sentGeoDeliveryAcks.insert(messageId) + guard !context.sentGeoDeliveryAcks.contains(messageId) else { return } + context.sendGeohashDeliveryAck(for: messageId, toRecipientHex: senderPubKey, from: id) + context.sentGeoDeliveryAcks.insert(messageId) } func sendReadReceiptIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) { - guard !viewModel.sentReadReceipts.contains(messageId) else { return } - let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge) - transport.senderPeerID = viewModel.meshService.myPeerID - transport.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubKey, from: id) - viewModel.sentReadReceipts.insert(messageId) + guard !context.sentReadReceipts.contains(messageId) else { return } + context.sendGeohashReadReceipt(messageId, toRecipientHex: senderPubKey, from: id) + context.sentReadReceipts.insert(messageId) } func handlePrivateMessage( @@ -315,15 +463,15 @@ final class ChatPrivateConversationCoordinator { return } - let wasReadBefore = viewModel.sentReadReceipts.contains(messageId) + let wasReadBefore = context.sentReadReceipts.contains(messageId) var isViewingThisChat = false - if viewModel.selectedPrivateChatPeer == targetPeerID { + if context.selectedPrivateChatPeer == targetPeerID { isViewingThisChat = true - } else if let selectedPeer = viewModel.selectedPrivateChatPeer, - let selectedPeerData = viewModel.unifiedPeerService.getPeer(by: selectedPeer), + } else if let selectedPeer = context.selectedPrivateChatPeer, + let selectedPeerNoiseKey = context.noisePublicKey(for: selectedPeer), let key = actualSenderNoiseKey, - selectedPeerData.noisePublicKey == key { + selectedPeerNoiseKey == key { isViewingThisChat = true } @@ -337,15 +485,15 @@ final class ChatPrivateConversationCoordinator { timestamp: messageTimestamp, isRelay: false, isPrivate: true, - recipientNickname: viewModel.nickname, + recipientNickname: context.nickname, senderPeerID: targetPeerID, - deliveryStatus: .delivered(to: viewModel.nickname, at: Date()) + deliveryStatus: .delivered(to: context.nickname, at: Date()) ) addMessageToPrivateChatsIfNeeded(message, targetPeerID: targetPeerID) mirrorToEphemeralIfNeeded(message, targetPeerID: targetPeerID, key: actualSenderNoiseKey) - viewModel.sendDeliveryAckViaNostrEmbedded( + context.sendDeliveryAckViaNostrEmbedded( message, wasReadBefore: wasReadBefore, senderPubkey: senderPubkey, @@ -372,12 +520,12 @@ final class ChatPrivateConversationCoordinator { ) } - viewModel.objectWillChange.send() + context.notifyUIChanged() } func handlePrivateMessage(_ message: BitchatMessage) { SecureLogger.debug("📥 handlePrivateMessage called for message from \(message.sender)", category: .session) - let senderPeerID = message.senderPeerID ?? viewModel.getPeerIDForNickname(message.sender) + let senderPeerID = message.senderPeerID ?? context.getPeerIDForNickname(message.sender) guard let peerID = senderPeerID else { SecureLogger.warning("⚠️ Could not get peer ID for sender \(message.sender)", category: .session) @@ -391,22 +539,22 @@ final class ChatPrivateConversationCoordinator { migratePrivateChatsIfNeeded(for: peerID, senderNickname: message.sender) - if peerID.id.count == 16, let peer = viewModel.unifiedPeerService.getPeer(by: peerID) { - let stableKeyHex = PeerID(hexData: peer.noisePublicKey) + if peerID.id.count == 16, let peerNoiseKey = context.noisePublicKey(for: peerID) { + let stableKeyHex = PeerID(hexData: peerNoiseKey) if stableKeyHex != peerID, - let nostrMessages = viewModel.privateChats[stableKeyHex], + let nostrMessages = context.privateChats[stableKeyHex], !nostrMessages.isEmpty { - if viewModel.privateChats[peerID] == nil { - viewModel.privateChats[peerID] = [] + if context.privateChats[peerID] == nil { + context.privateChats[peerID] = [] } - let existingMessageIds = Set(viewModel.privateChats[peerID]?.map { $0.id } ?? []) + let existingMessageIds = Set(context.privateChats[peerID]?.map { $0.id } ?? []) for nostrMessage in nostrMessages where !existingMessageIds.contains(nostrMessage.id) { - viewModel.privateChats[peerID]?.append(nostrMessage) + context.privateChats[peerID]?.append(nostrMessage) } - viewModel.privateChats[peerID]?.sort { $0.timestamp < $1.timestamp } - viewModel.privateChats.removeValue(forKey: stableKeyHex) + context.privateChats[peerID]?.sort { $0.timestamp < $1.timestamp } + context.privateChats.removeValue(forKey: stableKeyHex) SecureLogger.info( "📥 Consolidated \(nostrMessages.count) Nostr messages from stable key to ephemeral peer \(peerID)", @@ -420,20 +568,20 @@ final class ChatPrivateConversationCoordinator { } addMessageToPrivateChatsIfNeeded(message, targetPeerID: peerID) - let noiseKey = peerID.noiseKey ?? viewModel.unifiedPeerService.getPeer(by: peerID)?.noisePublicKey + let noiseKey = peerID.noiseKey ?? context.noisePublicKey(for: peerID) mirrorToEphemeralIfNeeded(message, targetPeerID: peerID, key: noiseKey) - let isViewing = viewModel.selectedPrivateChatPeer == peerID + let isViewing = context.selectedPrivateChatPeer == peerID if isViewing { let receipt = ReadReceipt( originalMessageID: message.id, - readerID: viewModel.meshService.myPeerID, - readerNickname: viewModel.nickname + readerID: context.myPeerID, + readerNickname: context.nickname ) - viewModel.meshService.sendReadReceipt(receipt, to: peerID) - viewModel.sentReadReceipts.insert(message.id) + context.sendMeshReadReceipt(receipt, to: peerID) + context.sentReadReceipts.insert(message.id) } else { - viewModel.unreadPrivateMessages.insert(peerID) + context.unreadPrivateMessages.insert(peerID) NotificationService.shared.sendPrivateMessageNotification( from: message.sender, message: message.content, @@ -441,48 +589,48 @@ final class ChatPrivateConversationCoordinator { ) } - viewModel.objectWillChange.send() + context.notifyUIChanged() } func isDuplicateMessage(_ messageId: String, targetPeerID: PeerID) -> Bool { - if viewModel.privateChats[targetPeerID]?.contains(where: { $0.id == messageId }) == true { + if context.privateChats[targetPeerID]?.contains(where: { $0.id == messageId }) == true { return true } - for (_, messages) in viewModel.privateChats where messages.contains(where: { $0.id == messageId }) { + for (_, messages) in context.privateChats where messages.contains(where: { $0.id == messageId }) { return true } return false } func addMessageToPrivateChatsIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID) { - if viewModel.privateChats[targetPeerID] == nil { - viewModel.privateChats[targetPeerID] = [] + if context.privateChats[targetPeerID] == nil { + context.privateChats[targetPeerID] = [] } - if let idx = viewModel.privateChats[targetPeerID]?.firstIndex(where: { $0.id == message.id }) { - viewModel.privateChats[targetPeerID]?[idx] = message + if let idx = context.privateChats[targetPeerID]?.firstIndex(where: { $0.id == message.id }) { + context.privateChats[targetPeerID]?[idx] = message } else { - viewModel.privateChats[targetPeerID]?.append(message) + context.privateChats[targetPeerID]?.append(message) } - viewModel.privateChatManager.sanitizeChat(for: targetPeerID) + context.sanitizeChat(for: targetPeerID) } func mirrorToEphemeralIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID, key: Data?) { guard let key, - let ephemeralPeerID = viewModel.unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID, + let ephemeralPeerID = context.ephemeralPeerID(forNoiseKey: key), ephemeralPeerID != targetPeerID else { return } - if viewModel.privateChats[ephemeralPeerID] == nil { - viewModel.privateChats[ephemeralPeerID] = [] + if context.privateChats[ephemeralPeerID] == nil { + context.privateChats[ephemeralPeerID] = [] } - if let idx = viewModel.privateChats[ephemeralPeerID]?.firstIndex(where: { $0.id == message.id }) { - viewModel.privateChats[ephemeralPeerID]?[idx] = message + if let idx = context.privateChats[ephemeralPeerID]?.firstIndex(where: { $0.id == message.id }) { + context.privateChats[ephemeralPeerID]?[idx] = message } else { - viewModel.privateChats[ephemeralPeerID]?.append(message) + context.privateChats[ephemeralPeerID]?.append(message) } - viewModel.privateChatManager.sanitizeChat(for: ephemeralPeerID) + context.sanitizeChat(for: ephemeralPeerID) } func handleViewingThisChat( @@ -491,27 +639,25 @@ final class ChatPrivateConversationCoordinator { key: Data?, senderPubkey: String ) { - viewModel.unreadPrivateMessages.remove(targetPeerID) + context.unreadPrivateMessages.remove(targetPeerID) if let key, - let ephemeralPeerID = viewModel.unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID { - viewModel.unreadPrivateMessages.remove(ephemeralPeerID) + let ephemeralPeerID = context.ephemeralPeerID(forNoiseKey: key) { + context.unreadPrivateMessages.remove(ephemeralPeerID) } - guard !viewModel.sentReadReceipts.contains(message.id) else { return } + guard !context.sentReadReceipts.contains(message.id) else { return } if let key { let receipt = ReadReceipt( originalMessageID: message.id, - readerID: viewModel.meshService.myPeerID, - readerNickname: viewModel.nickname + readerID: context.myPeerID, + readerNickname: context.nickname ) SecureLogger.debug("Viewing chat; sending READ ack for \(message.id.prefix(8))… via router", category: .session) - viewModel.messageRouter.sendReadReceipt(receipt, to: PeerID(hexData: key)) - viewModel.sentReadReceipts.insert(message.id) - } else if let identity = try? viewModel.idBridge.getCurrentNostrIdentity() { - let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge) - transport.senderPeerID = viewModel.meshService.myPeerID - transport.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: identity) - viewModel.sentReadReceipts.insert(message.id) + context.routeReadReceipt(receipt, to: PeerID(hexData: key)) + context.sentReadReceipts.insert(message.id) + } else if let identity = context.currentNostrIdentity() { + context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity) + context.sentReadReceipts.insert(message.id) SecureLogger.debug( "Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…", category: .session @@ -529,11 +675,11 @@ final class ChatPrivateConversationCoordinator { ) { guard shouldMarkAsUnread else { return } - viewModel.unreadPrivateMessages.insert(targetPeerID) + context.unreadPrivateMessages.insert(targetPeerID) if let key, - let ephemeralPeerID = viewModel.unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID, + let ephemeralPeerID = context.ephemeralPeerID(forNoiseKey: key), ephemeralPeerID != targetPeerID { - viewModel.unreadPrivateMessages.insert(ephemeralPeerID) + context.unreadPrivateMessages.insert(ephemeralPeerID) } if isRecentMessage { NotificationService.shared.sendPrivateMessageNotification( @@ -554,7 +700,7 @@ final class ChatPrivateConversationCoordinator { SecureLogger.info("📝 Received Nostr npub in favorite notification: \(nostrPubkey ?? "none")", category: .session) } - let noiseKey = peerID.noiseKey ?? viewModel.unifiedPeerService.getPeer(by: peerID)?.noisePublicKey + let noiseKey = peerID.noiseKey ?? context.noisePublicKey(for: peerID) guard let finalNoiseKey = noiseKey else { SecureLogger.warning("⚠️ Cannot get Noise key for peer \(peerID)", category: .session) return @@ -577,7 +723,7 @@ final class ChatPrivateConversationCoordinator { if prior != isFavorite { let action = isFavorite ? "favorited" : "unfavorited" - viewModel.addMeshOnlySystemMessage("\(senderNickname) \(action) you") + context.addMeshOnlySystemMessage("\(senderNickname) \(action) you") } } @@ -606,15 +752,15 @@ final class ChatPrivateConversationCoordinator { } func migratePrivateChatsIfNeeded(for peerID: PeerID, senderNickname: String) { - let currentFingerprint = viewModel.getFingerprint(for: peerID) + let currentFingerprint = context.getFingerprint(for: peerID) - if viewModel.privateChats[peerID] == nil || viewModel.privateChats[peerID]?.isEmpty == true { + if context.privateChats[peerID] == nil || context.privateChats[peerID]?.isEmpty == true { var migratedMessages: [BitchatMessage] = [] var oldPeerIDsToRemove: [PeerID] = [] let cutoffTime = Date().addingTimeInterval(-TransportConfig.uiMigrationCutoffSeconds) - for (oldPeerID, messages) in viewModel.privateChats where oldPeerID != peerID { - let oldFingerprint = viewModel.peerIDToPublicKeyFingerprint[oldPeerID] + for (oldPeerID, messages) in context.privateChats where oldPeerID != peerID { + let oldFingerprint = context.storedFingerprint(for: oldPeerID) let recentMessages = messages.filter { $0.timestamp > cutoffTime } guard !recentMessages.isEmpty else { continue } @@ -637,8 +783,8 @@ final class ChatPrivateConversationCoordinator { ) } else if currentFingerprint == nil || oldFingerprint == nil { let isRelevantChat = recentMessages.contains { msg in - (msg.sender == senderNickname && msg.sender != viewModel.nickname) - || (msg.sender == viewModel.nickname && msg.recipientNickname == senderNickname) + (msg.sender == senderNickname && msg.sender != context.nickname) + || (msg.sender == context.nickname && msg.recipientNickname == senderNickname) } if isRelevantChat { @@ -656,27 +802,27 @@ final class ChatPrivateConversationCoordinator { } if !oldPeerIDsToRemove.isEmpty { - let needsSelectedUpdate = oldPeerIDsToRemove.contains { viewModel.selectedPrivateChatPeer == $0 } + let needsSelectedUpdate = oldPeerIDsToRemove.contains { context.selectedPrivateChatPeer == $0 } for oldID in oldPeerIDsToRemove { - viewModel.privateChats.removeValue(forKey: oldID) - viewModel.unreadPrivateMessages.remove(oldID) - viewModel.peerIdentityStore.setFingerprint(nil, for: oldID) + context.privateChats.removeValue(forKey: oldID) + context.unreadPrivateMessages.remove(oldID) + context.clearStoredFingerprint(for: oldID) } if needsSelectedUpdate { - viewModel.selectedPrivateChatPeer = peerID + context.selectedPrivateChatPeer = peerID } } if !migratedMessages.isEmpty { - if viewModel.privateChats[peerID] == nil { - viewModel.privateChats[peerID] = [] + if context.privateChats[peerID] == nil { + context.privateChats[peerID] = [] } - viewModel.privateChats[peerID]?.append(contentsOf: migratedMessages) - viewModel.privateChats[peerID]?.sort { $0.timestamp < $1.timestamp } - viewModel.privateChatManager.sanitizeChat(for: peerID) - viewModel.objectWillChange.send() + context.privateChats[peerID]?.append(contentsOf: migratedMessages) + context.privateChats[peerID]?.sort { $0.timestamp < $1.timestamp } + context.sanitizeChat(for: peerID) + context.notifyUIChanged() } } } @@ -686,26 +832,26 @@ final class ChatPrivateConversationCoordinator { if let hexKey = Data(hexString: peerID.id) { noiseKey = hexKey - } else if let peer = viewModel.unifiedPeerService.getPeer(by: peerID) { - noiseKey = peer.noisePublicKey + } else if let peerNoiseKey = context.noisePublicKey(for: peerID) { + noiseKey = peerNoiseKey } - if viewModel.meshService.isPeerConnected(peerID) { - viewModel.messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) + if context.isPeerConnected(peerID) { + context.routeFavoriteNotification(to: peerID, isFavorite: isFavorite) SecureLogger.debug("📤 Sent favorite notification via BLE to \(peerID)", category: .session) } else if let key = noiseKey { - viewModel.messageRouter.sendFavoriteNotification(to: PeerID(hexData: key), isFavorite: isFavorite) + context.routeFavoriteNotification(to: PeerID(hexData: key), isFavorite: isFavorite) } else { SecureLogger.warning("⚠️ Cannot send favorite notification - peer not connected and no Nostr pubkey", category: .session) } } func isMessageBlocked(_ message: BitchatMessage) -> Bool { - if let peerID = message.senderPeerID ?? viewModel.getPeerIDForNickname(message.sender) { - if viewModel.isPeerBlocked(peerID) { return true } + if let peerID = message.senderPeerID ?? context.getPeerIDForNickname(message.sender) { + if context.isPeerBlocked(peerID) { return true } if peerID.isGeoChat || peerID.isGeoDM, - let full = viewModel.nostrKeyMapping[peerID]?.lowercased(), - viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: full) { + let full = context.nostrKeyMapping[peerID]?.lowercased(), + context.isNostrBlocked(pubkeyHexLowercased: full) { return true } } diff --git a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift index fcd1d448..f140922e 100644 --- a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift @@ -7,16 +7,190 @@ import SwiftUI import UIKit #endif +/// The narrow surface `ChatPublicConversationCoordinator` needs from its owner. +/// +/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the +/// minimal context it actually uses instead of holding an `unowned` back-ref +/// to the whole `ChatViewModel`. This keeps the coordinator independently +/// testable (see `ChatPublicConversationCoordinatorContextTests`) and makes +/// its true dependencies explicit. The surface is intentionally large — it +/// documents the coordinator's real coupling to the public timeline, the +/// conversation stores, geohash participants, and the inbound public message +/// pipeline. +@MainActor +protocol ChatPublicConversationContext: AnyObject { + // MARK: Channel & visible timeline state + var messages: [BitchatMessage] { get set } + var activeChannel: ChannelID { get } + var currentGeohash: String? { get } + var nickname: String { get } + var myPeerID: PeerID { get } + var isBatchingPublic: Bool { get set } + /// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`). + func notifyUIChanged() + func trimMessagesIfNeeded() + + // MARK: Public timeline store + func timelineMessages(for channel: ChannelID) -> [BitchatMessage] + func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID) + func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool + func removeTimelineMessage(withID id: String) -> BitchatMessage? + func removeGeohashTimelineMessages(in geohash: String, where predicate: (BitchatMessage) -> Bool) + func clearTimeline(for channel: ChannelID) + func timelineGeohashKeys() -> [String] + /// Queues a system message for the next geohash channel visit. + func queueGeohashSystemMessage(_ content: String) + + // MARK: Conversation stores + func setConversationActiveChannel(_ channel: ChannelID) + func replaceConversationMessages(_ messages: [BitchatMessage], for channelID: ChannelID) + func replaceConversationMessages(_ messages: [BitchatMessage], for conversationID: ConversationID) + func synchronizePrivateConversationStore() + func synchronizeConversationSelectionStore() + + // MARK: Private chats (block cleanup & message removal) + var privateChats: [PeerID: [BitchatMessage]] { get set } + var unreadPrivateMessages: Set { get set } + func cleanupLocalFile(forMessage message: BitchatMessage) + + // MARK: Geohash participants & presence + var geoNicknames: [String: String] { get } + var isTeleported: Bool { get } + var nostrKeyMapping: [PeerID: String] { get set } + func visibleGeoPeople() -> [GeoPerson] + func geoParticipantCount(for geohash: String) -> Int + func removeGeoParticipant(pubkeyHex: String) + + // MARK: Nostr identity & blocking (shared with the other contexts) + func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity + func isNostrBlocked(pubkeyHexLowercased: String) -> Bool + func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) + + // MARK: Mesh transport + func meshPeerNicknames() -> [PeerID: String] + func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) + + // MARK: Inbound public message processing + func processActionMessage(_ message: BitchatMessage) -> BitchatMessage + func isMessageBlocked(_ message: BitchatMessage) -> Bool + func allowPublicMessage(senderKey: String, contentKey: String) -> Bool + func enqueuePublicMessage(_ message: BitchatMessage) + func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID? + + // MARK: Content dedup & formatting + func normalizedContentKey(_ content: String) -> String + func contentTimestamp(forKey key: String) -> Date? + func recordContentKey(_ key: String, timestamp: Date) + /// Pre-renders the message so the formatting cache is warm before display. + func prewarmMessageFormatting(_ message: BitchatMessage) +} + +extension ChatViewModel: ChatPublicConversationContext { + // `messages`, `privateChats`, `unreadPrivateMessages`, `nostrKeyMapping`, + // `nickname`, `activeChannel`, `currentGeohash`, `geoNicknames`, + // `myPeerID`, `isTeleported`, `isBatchingPublic`, `notifyUIChanged()`, + // `geoParticipantCount(for:)`, `isNostrBlocked(pubkeyHexLowercased:)`, + // `deriveNostrIdentity(forGeohash:)`, and + // `appendGeohashMessageIfAbsent(_:toGeohash:)` are shared requirements + // with `ChatDeliveryContext` / `ChatPrivateConversationContext` / + // `ChatNostrContext`; their witnesses already exist. The members below + // flatten nested service accesses into intent-named calls. + + func timelineMessages(for channel: ChannelID) -> [BitchatMessage] { + timelineStore.messages(for: channel) + } + + func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID) { + timelineStore.append(message, to: channel) + } + + func removeTimelineMessage(withID id: String) -> BitchatMessage? { + timelineStore.removeMessage(withID: id) + } + + func removeGeohashTimelineMessages(in geohash: String, where predicate: (BitchatMessage) -> Bool) { + timelineStore.removeMessages(in: geohash, where: predicate) + } + + func clearTimeline(for channel: ChannelID) { + timelineStore.clear(channel: channel) + } + + func timelineGeohashKeys() -> [String] { + timelineStore.geohashKeys() + } + + func queueGeohashSystemMessage(_ content: String) { + timelineStore.queueGeohashSystemMessage(content) + } + + func setConversationActiveChannel(_ channel: ChannelID) { + conversationStore.setActiveChannel(channel) + } + + func replaceConversationMessages(_ messages: [BitchatMessage], for channelID: ChannelID) { + conversationStore.replaceMessages(messages, for: channelID) + } + + func replaceConversationMessages(_ messages: [BitchatMessage], for conversationID: ConversationID) { + conversationStore.replaceMessages(messages, for: conversationID) + } + + func visibleGeoPeople() -> [GeoPerson] { + participantTracker.getVisiblePeople() + } + + func removeGeoParticipant(pubkeyHex: String) { + participantTracker.removeParticipant(pubkeyHex: pubkeyHex) + } + + func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) { + identityManager.setNostrBlocked(pubkeyHexLowercased, isBlocked: isBlocked) + } + + func meshPeerNicknames() -> [PeerID: String] { + meshService.getPeerNicknames() + } + + func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) { + meshService.sendMessage(content, mentions: mentions, messageID: messageID, timestamp: timestamp) + } + + func allowPublicMessage(senderKey: String, contentKey: String) -> Bool { + publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey) + } + + func enqueuePublicMessage(_ message: BitchatMessage) { + publicMessagePipeline.enqueue(message) + } + + func normalizedContentKey(_ content: String) -> String { + deduplicationService.normalizedContentKey(content) + } + + func contentTimestamp(forKey key: String) -> Date? { + deduplicationService.contentTimestamp(forKey: key) + } + + func recordContentKey(_ key: String, timestamp: Date) { + deduplicationService.recordContentKey(key, timestamp: timestamp) + } + + func prewarmMessageFormatting(_ message: BitchatMessage) { + _ = formatMessageAsText(message, colorScheme: currentColorScheme) + } +} + @MainActor final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { - private unowned let viewModel: ChatViewModel + private unowned let context: any ChatPublicConversationContext - init(viewModel: ChatViewModel) { - self.viewModel = viewModel + init(context: any ChatPublicConversationContext) { + self.context = context } func visibleGeohashPeople() -> [GeoPerson] { - viewModel.participantTracker.getVisiblePeople() + context.visibleGeoPeople() } func getVisibleGeoParticipants() -> [CommandGeoParticipant] { @@ -24,7 +198,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { } func geohashParticipantCount(for geohash: String) -> Int { - viewModel.participantTracker.participantCount(for: geohash) + context.geoParticipantCount(for: geohash) } func displayNameForPubkey(_ pubkeyHex: String) -> String { @@ -32,49 +206,49 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { } func isBlocked(_ pubkeyHexLowercased: String) -> Bool { - viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased) + context.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased) } func isGeohashUserBlocked(pubkeyHexLowercased: String) -> Bool { - viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased) + context.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased) } func blockGeohashUser(pubkeyHexLowercased: String, displayName: String) { let hex = pubkeyHexLowercased.lowercased() - viewModel.identityManager.setNostrBlocked(hex, isBlocked: true) - viewModel.participantTracker.removeParticipant(pubkeyHex: hex) + context.setNostrBlocked(hex, isBlocked: true) + context.removeGeoParticipant(pubkeyHex: hex) - if let gh = viewModel.currentGeohash { - let predicate: (BitchatMessage) -> Bool = { [unowned viewModel] message in + if let gh = context.currentGeohash { + let predicate: (BitchatMessage) -> Bool = { [unowned context] message in guard let senderPeerID = message.senderPeerID, senderPeerID.isGeoDM || senderPeerID.isGeoChat else { return false } - if let full = viewModel.nostrKeyMapping[senderPeerID]?.lowercased() { + if let full = context.nostrKeyMapping[senderPeerID]?.lowercased() { return full == hex } return false } - viewModel.timelineStore.removeMessages(in: gh, where: predicate) + context.removeGeohashTimelineMessages(in: gh, where: predicate) synchronizePublicConversationStore(forGeohash: gh) - if case .location = viewModel.activeChannel { - viewModel.messages.removeAll(where: predicate) + if case .location = context.activeChannel { + context.messages.removeAll(where: predicate) } } let conversationPeerID = PeerID(nostr_: hex) - if viewModel.privateChats[conversationPeerID] != nil { - var privateChats = viewModel.privateChats + if context.privateChats[conversationPeerID] != nil { + var privateChats = context.privateChats privateChats.removeValue(forKey: conversationPeerID) - viewModel.privateChats = privateChats + context.privateChats = privateChats - var unread = viewModel.unreadPrivateMessages + var unread = context.unreadPrivateMessages unread.remove(conversationPeerID) - viewModel.unreadPrivateMessages = unread + context.unreadPrivateMessages = unread } - for (key, value) in viewModel.nostrKeyMapping where value.lowercased() == hex { - viewModel.nostrKeyMapping.removeValue(forKey: key) + for (key, value) in context.nostrKeyMapping where value.lowercased() == hex { + context.nostrKeyMapping.removeValue(forKey: key) } addSystemMessage( @@ -90,7 +264,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { } func unblockGeohashUser(pubkeyHexLowercased: String, displayName: String) { - viewModel.identityManager.setNostrBlocked(pubkeyHexLowercased, isBlocked: false) + context.setNostrBlocked(pubkeyHexLowercased, isBlocked: false) addSystemMessage( String( format: String( @@ -105,24 +279,24 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { func displayNameForNostrPubkey(_ pubkeyHex: String) -> String { let suffix = String(pubkeyHex.suffix(4)) - if let geohash = viewModel.currentGeohash, - let myGeoIdentity = try? viewModel.idBridge.deriveIdentity(forGeohash: geohash), + if let geohash = context.currentGeohash, + let myGeoIdentity = try? context.deriveNostrIdentity(forGeohash: geohash), myGeoIdentity.publicKeyHex.lowercased() == pubkeyHex.lowercased() { - return viewModel.nickname + "#" + suffix + return context.nickname + "#" + suffix } - if let nick = viewModel.geoNicknames[pubkeyHex.lowercased()], !nick.isEmpty { + if let nick = context.geoNicknames[pubkeyHex.lowercased()], !nick.isEmpty { return nick + "#" + suffix } return "anon#\(suffix)" } func currentPublicSender() -> (name: String, peerID: PeerID) { - var displaySender = viewModel.nickname - var senderPeerID = viewModel.meshService.myPeerID - if case .location(let channel) = viewModel.activeChannel, - let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) { + var displaySender = context.nickname + var senderPeerID = context.myPeerID + if case .location(let channel) = context.activeChannel, + let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) { let suffix = String(identity.publicKeyHex.suffix(4)) - displaySender = viewModel.nickname + "#" + suffix + displaySender = context.nickname + "#" + suffix senderPeerID = PeerID(nostr: identity.publicKeyHex) } return (displaySender, senderPeerID) @@ -131,16 +305,16 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { func removeMessage(withID messageID: String, cleanupFile: Bool = false) { var removedMessage: BitchatMessage? - if let index = viewModel.messages.firstIndex(where: { $0.id == messageID }) { - removedMessage = viewModel.messages.remove(at: index) + if let index = context.messages.firstIndex(where: { $0.id == messageID }) { + removedMessage = context.messages.remove(at: index) } - if let storeRemoved = viewModel.timelineStore.removeMessage(withID: messageID) { + if let storeRemoved = context.removeTimelineMessage(withID: messageID) { removedMessage = removedMessage ?? storeRemoved synchronizeAllPublicConversationStores() } - var chats = viewModel.privateChats + var chats = context.privateChats for (peerID, items) in chats { let filtered = items.filter { $0.id != messageID } if filtered.count != items.count { @@ -154,55 +328,55 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { } } } - viewModel.privateChats = chats + context.privateChats = chats if cleanupFile, let removedMessage { - viewModel.cleanupLocalFile(forMessage: removedMessage) + context.cleanupLocalFile(forMessage: removedMessage) } - viewModel.objectWillChange.send() + context.notifyUIChanged() } func initializeConversationStore() { - viewModel.conversationStore.setActiveChannel(viewModel.activeChannel) - synchronizePublicConversationStore(for: viewModel.activeChannel) - viewModel.synchronizePrivateConversationStore() - viewModel.synchronizeConversationSelectionStore() + context.setConversationActiveChannel(context.activeChannel) + synchronizePublicConversationStore(for: context.activeChannel) + context.synchronizePrivateConversationStore() + context.synchronizeConversationSelectionStore() } func synchronizePublicConversationStore(for channel: ChannelID) { - let publicMessages = viewModel.timelineStore.messages(for: channel) - viewModel.conversationStore.replaceMessages(publicMessages, for: channel) - if channel == viewModel.activeChannel { - viewModel.conversationStore.setActiveChannel(viewModel.activeChannel) + let publicMessages = context.timelineMessages(for: channel) + context.replaceConversationMessages(publicMessages, for: channel) + if channel == context.activeChannel { + context.setConversationActiveChannel(context.activeChannel) } } func synchronizePublicConversationStore(forGeohash geohash: String) { let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash)) - let publicMessages = viewModel.timelineStore.messages(for: channel) - viewModel.conversationStore.replaceMessages(publicMessages, for: .geohash(geohash.lowercased())) + let publicMessages = context.timelineMessages(for: channel) + context.replaceConversationMessages(publicMessages, for: .geohash(geohash.lowercased())) } func synchronizeAllPublicConversationStores() { synchronizePublicConversationStore(for: .mesh) - for geohash in viewModel.timelineStore.geohashKeys() { + for geohash in context.timelineGeohashKeys() { synchronizePublicConversationStore(forGeohash: geohash) } } func refreshVisibleMessages(from channel: ChannelID? = nil) { - let target = channel ?? viewModel.activeChannel - viewModel.messages = viewModel.timelineStore.messages(for: target) - viewModel.conversationStore.replaceMessages(viewModel.messages, for: target) - if target == viewModel.activeChannel { - viewModel.conversationStore.setActiveChannel(viewModel.activeChannel) + let target = channel ?? context.activeChannel + context.messages = context.timelineMessages(for: target) + context.replaceConversationMessages(context.messages, for: target) + if target == context.activeChannel { + context.setConversationActiveChannel(context.activeChannel) } } func clearCurrentPublicTimeline() { - viewModel.messages.removeAll() - viewModel.timelineStore.clear(channel: viewModel.activeChannel) + context.messages.removeAll() + context.clearTimeline(for: context.activeChannel) Task.detached(priority: .utility) { do { @@ -242,7 +416,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { timestamp: timestamp, isRelay: false ) - viewModel.messages.append(systemMessage) + context.messages.append(systemMessage) } func addMeshOnlySystemMessage(_ content: String) { @@ -252,11 +426,11 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { timestamp: Date(), isRelay: false ) - viewModel.timelineStore.append(systemMessage, to: .mesh) + context.appendTimelineMessage(systemMessage, to: .mesh) synchronizePublicConversationStore(for: .mesh) refreshVisibleMessages() - viewModel.trimMessagesIfNeeded() - viewModel.objectWillChange.send() + context.trimMessagesIfNeeded() + context.notifyUIChanged() } func addPublicSystemMessage(_ content: String) { @@ -266,34 +440,34 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { timestamp: Date(), isRelay: false ) - viewModel.timelineStore.append(systemMessage, to: viewModel.activeChannel) - refreshVisibleMessages(from: viewModel.activeChannel) - let contentKey = viewModel.deduplicationService.normalizedContentKey(systemMessage.content) - viewModel.deduplicationService.recordContentKey(contentKey, timestamp: systemMessage.timestamp) - viewModel.trimMessagesIfNeeded() - viewModel.objectWillChange.send() + context.appendTimelineMessage(systemMessage, to: context.activeChannel) + refreshVisibleMessages(from: context.activeChannel) + let contentKey = context.normalizedContentKey(systemMessage.content) + context.recordContentKey(contentKey, timestamp: systemMessage.timestamp) + context.trimMessagesIfNeeded() + context.notifyUIChanged() } func addGeohashOnlySystemMessage(_ content: String) { - if case .location = viewModel.activeChannel { + if case .location = context.activeChannel { addPublicSystemMessage(content) } else { - viewModel.timelineStore.queueGeohashSystemMessage(content) + context.queueGeohashSystemMessage(content) } } func sendPublicRaw(_ content: String) { - if case .location(let channel) = viewModel.activeChannel { - Task { @MainActor [weak viewModel] in - guard let viewModel else { return } + if case .location(let channel) = context.activeChannel { + Task { @MainActor [weak context] in + guard let context else { return } do { - let identity = try viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) + let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash) let event = try NostrProtocol.createEphemeralGeohashEvent( content: content, geohash: channel.geohash, senderIdentity: identity, - nickname: viewModel.nickname, - teleported: viewModel.locationManager.teleported + nickname: context.nickname, + teleported: context.isTeleported ) let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5) if targetRelays.isEmpty { @@ -308,7 +482,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { return } - viewModel.meshService.sendMessage( + context.sendMeshMessage( content, mentions: [], messageID: UUID().uuidString, @@ -317,15 +491,15 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { } func handlePublicMessage(_ message: BitchatMessage) { - let finalMessage = viewModel.processActionMessage(message) - if viewModel.isMessageBlocked(finalMessage) { return } + let finalMessage = context.processActionMessage(message) + if context.isMessageBlocked(finalMessage) { return } let isGeo = finalMessage.senderPeerID?.isGeoChat == true let shouldRateLimit = finalMessage.sender != "system" || finalMessage.senderPeerID != nil if shouldRateLimit { let senderKey = normalizedSenderKey(for: finalMessage) - let contentKey = viewModel.deduplicationService.normalizedContentKey(finalMessage.content) - if !viewModel.publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey) { + let contentKey = context.normalizedContentKey(finalMessage.content) + if !context.allowPublicMessage(senderKey: senderKey, contentKey: contentKey) { return } } @@ -333,19 +507,19 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { if finalMessage.sender != "system" && finalMessage.content.count > 16000 { return } if !isGeo && finalMessage.sender != "system" { - viewModel.timelineStore.append(finalMessage, to: .mesh) + context.appendTimelineMessage(finalMessage, to: .mesh) synchronizePublicConversationStore(for: .mesh) } if isGeo && finalMessage.sender != "system", - let geohash = viewModel.currentGeohash, - viewModel.timelineStore.appendIfAbsent(finalMessage, toGeohash: geohash) { + let geohash = context.currentGeohash, + context.appendGeohashMessageIfAbsent(finalMessage, toGeohash: geohash) { synchronizePublicConversationStore(forGeohash: geohash) } let isSystem = finalMessage.sender == "system" let channelMatches: Bool = { - switch viewModel.activeChannel { + switch context.activeChannel { case .mesh: return !isGeo || isSystem case .location: return isGeo || isSystem } @@ -354,22 +528,22 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { guard channelMatches else { return } if !finalMessage.content.trimmed.isEmpty, - !viewModel.messages.contains(where: { $0.id == finalMessage.id }) { - viewModel.publicMessagePipeline.enqueue(finalMessage) + !context.messages.contains(where: { $0.id == finalMessage.id }) { + context.enqueuePublicMessage(finalMessage) } } func checkForMentions(_ message: BitchatMessage) { - var myTokens: Set = [viewModel.nickname] - let meshPeers = viewModel.meshService.getPeerNicknames() - let collisions = meshPeers.values.filter { $0.hasPrefix(viewModel.nickname + "#") } + var myTokens: Set = [context.nickname] + let meshPeers = context.meshPeerNicknames() + let collisions = meshPeers.values.filter { $0.hasPrefix(context.nickname + "#") } if !collisions.isEmpty { - let suffix = "#" + String(viewModel.meshService.myPeerID.id.prefix(4)) - myTokens = [viewModel.nickname + suffix] + let suffix = "#" + String(context.myPeerID.id.prefix(4)) + myTokens = [context.nickname + suffix] } let isMentioned = message.mentions?.contains(where: myTokens.contains) ?? false - if isMentioned && message.sender != viewModel.nickname { + if isMentioned && message.sender != context.nickname { SecureLogger.info("🔔 Mention from \(message.sender)", category: .session) NotificationService.shared.sendMentionNotification(from: message.sender, message: message.content) } @@ -379,11 +553,11 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { #if os(iOS) guard UIApplication.shared.applicationState == .active else { return } - var tokens: [String] = [viewModel.nickname] - switch viewModel.activeChannel { + var tokens: [String] = [context.nickname] + switch context.activeChannel { case .location(let channel): - if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) { - tokens.append(viewModel.nickname + "#" + String(identity.publicKeyHex.suffix(4))) + if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) { + tokens.append(context.nickname + "#" + String(identity.publicKeyHex.suffix(4))) } case .mesh: break @@ -394,7 +568,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { let isHugForMe = message.content.contains("🫂") && hugsMe let isSlapForMe = message.content.contains("🐟") && slapsMe - if isHugForMe && message.sender != viewModel.nickname { + if isHugForMe && message.sender != context.nickname { let impactFeedback = UIImpactFeedbackGenerator(style: .medium) impactFeedback.prepare() @@ -405,7 +579,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { impactFeedback.impactOccurred() } } - } else if isSlapForMe && message.sender != viewModel.nickname { + } else if isSlapForMe && message.sender != context.nickname { let impactFeedback = UIImpactFeedbackGenerator(style: .heavy) impactFeedback.prepare() impactFeedback.impactOccurred() @@ -414,35 +588,35 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { } func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage] { - viewModel.messages + context.messages } func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage]) { - viewModel.messages = messages + context.messages = messages } func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String { - viewModel.deduplicationService.normalizedContentKey(content) + context.normalizedContentKey(content) } func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date? { - viewModel.deduplicationService.contentTimestamp(forKey: key) + context.contentTimestamp(forKey: key) } func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) { - viewModel.deduplicationService.recordContentKey(key, timestamp: timestamp) + context.recordContentKey(key, timestamp: timestamp) } func pipelineTrimMessages(_ pipeline: PublicMessagePipeline) { - viewModel.trimMessagesIfNeeded() + context.trimMessagesIfNeeded() } func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage) { - _ = viewModel.formatMessageAsText(message, colorScheme: viewModel.currentColorScheme) + context.prewarmMessageFormatting(message) } func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) { - viewModel.isBatchingPublic = isBatching + context.isBatchingPublic = isBatching } } @@ -450,10 +624,10 @@ private extension ChatPublicConversationCoordinator { func normalizedSenderKey(for message: BitchatMessage) -> String { if let senderPeerID = message.senderPeerID { if senderPeerID.isGeoChat || senderPeerID.isGeoDM { - let full = (viewModel.nostrKeyMapping[senderPeerID] ?? senderPeerID.bare).lowercased() + let full = (context.nostrKeyMapping[senderPeerID] ?? senderPeerID.bare).lowercased() return "nostr:" + full } else if senderPeerID.id.count == 16, - let full = viewModel.cachedStablePeerID(for: senderPeerID)?.id.lowercased() { + let full = context.cachedStablePeerID(for: senderPeerID)?.id.lowercased() { return "noise:" + full } else { return "mesh:" + senderPeerID.id.lowercased() diff --git a/bitchat/ViewModels/ChatVerificationCoordinator.swift b/bitchat/ViewModels/ChatVerificationCoordinator.swift index af95b7b2..8ef7f3d1 100644 --- a/bitchat/ViewModels/ChatVerificationCoordinator.swift +++ b/bitchat/ViewModels/ChatVerificationCoordinator.swift @@ -66,7 +66,7 @@ final class ChatVerificationCoordinator { let noiseService = viewModel.meshService.getNoiseService() noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in - DispatchQueue.main.async { + DispatchQueue.main.async { [weak self] in guard let self else { return } SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security) @@ -103,7 +103,7 @@ final class ChatVerificationCoordinator { } noiseService.onHandshakeRequired = { [weak self] peerID in - DispatchQueue.main.async { + DispatchQueue.main.async { [weak self] in guard let self else { return } self.viewModel.peerIdentityStore.setEncryptionStatus(.noiseHandshaking, for: peerID) self.viewModel.invalidateEncryptionCache(for: peerID) diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index c37362b1..090a8afd 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -152,11 +152,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele private lazy var peerListCoordinator = ChatPeerListCoordinator(viewModel: self) private lazy var messageFormatter = ChatMessageFormatter(viewModel: self) lazy var peerIdentityCoordinator = ChatPeerIdentityCoordinator(viewModel: self) - lazy var deliveryCoordinator = ChatDeliveryCoordinator(viewModel: self) + lazy var deliveryCoordinator = ChatDeliveryCoordinator(context: self) lazy var composerCoordinator = ChatComposerCoordinator(viewModel: self) - lazy var publicConversationCoordinator = ChatPublicConversationCoordinator(viewModel: self) - lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(viewModel: self) - lazy var nostrCoordinator = ChatNostrCoordinator(viewModel: self) + lazy var publicConversationCoordinator = ChatPublicConversationCoordinator(context: self) + lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(context: self) + lazy var nostrCoordinator = ChatNostrCoordinator(context: self) lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(viewModel: self) lazy var verificationCoordinator = ChatVerificationCoordinator(viewModel: self) @@ -168,7 +168,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele get { privateChatManager.privateChats } set { privateChatManager.privateChats = newValue - synchronizePrivateConversationStore() + schedulePrivateConversationStoreSynchronization() } } var selectedPrivateChatPeer: PeerID? { @@ -187,7 +187,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele get { privateChatManager.unreadMessages } set { privateChatManager.unreadMessages = newValue - synchronizePrivateConversationStore() + schedulePrivateConversationStoreSynchronization() } } @@ -372,6 +372,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // MARK: - Message Delivery Tracking var cancellables = Set() + private var pendingPrivateConversationStoreSyncTask: Task? var transferIdToMessageIDs: [String: [String]] { mediaTransferCoordinator.transferIdToMessageIDs @@ -967,6 +968,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele publicConversationCoordinator.synchronizeAllPublicConversationStores() } + @MainActor + func schedulePrivateConversationStoreSynchronization() { + guard pendingPrivateConversationStoreSyncTask == nil else { return } + pendingPrivateConversationStoreSyncTask = Task { @MainActor [weak self] in + await Task.yield() + guard let self else { return } + self.pendingPrivateConversationStoreSyncTask = nil + self.synchronizePrivateConversationStore() + } + } + @MainActor func synchronizePrivateConversationStore() { conversationStore.synchronizePrivateChats( diff --git a/bitchat/ViewModels/ChatViewModelBootstrapper.swift b/bitchat/ViewModels/ChatViewModelBootstrapper.swift index 2d7a8a03..fa23bb52 100644 --- a/bitchat/ViewModels/ChatViewModelBootstrapper.swift +++ b/bitchat/ViewModels/ChatViewModelBootstrapper.swift @@ -93,7 +93,7 @@ private extension ChatViewModelBootstrapper { .receive(on: DispatchQueue.main) .sink { [weak viewModel] _ in Task { @MainActor [weak viewModel] in - viewModel?.synchronizePrivateConversationStore() + viewModel?.schedulePrivateConversationStoreSynchronization() } } .store(in: &viewModel.cancellables) @@ -102,7 +102,7 @@ private extension ChatViewModelBootstrapper { .receive(on: DispatchQueue.main) .sink { [weak viewModel] _ in Task { @MainActor [weak viewModel] in - viewModel?.synchronizePrivateConversationStore() + viewModel?.schedulePrivateConversationStoreSynchronization() } } .store(in: &viewModel.cancellables) diff --git a/bitchat/ViewModels/PublicTimelineStore.swift b/bitchat/ViewModels/PublicTimelineStore.swift index 6e03382f..ae613f2b 100644 --- a/bitchat/ViewModels/PublicTimelineStore.swift +++ b/bitchat/ViewModels/PublicTimelineStore.swift @@ -10,7 +10,9 @@ import Foundation struct PublicTimelineStore { private var meshTimeline: [BitchatMessage] = [] + private var meshMessageIDs: Set = [] private var geohashTimelines: [String: [BitchatMessage]] = [:] + private var geohashMessageIDs: [String: Set] = [:] private var pendingGeohashSystemMessages: [String] = [] private let meshCap: Int @@ -24,8 +26,9 @@ struct PublicTimelineStore { mutating func append(_ message: BitchatMessage, to channel: ChannelID) { switch channel { case .mesh: - guard !meshTimeline.contains(where: { $0.id == message.id }) else { return } + guard !meshMessageIDs.contains(message.id) else { return } meshTimeline.append(message) + meshMessageIDs.insert(message.id) trimMeshTimelineIfNeeded() case .location(let channel): append(message, toGeohash: channel.geohash) @@ -33,21 +36,12 @@ struct PublicTimelineStore { } mutating func append(_ message: BitchatMessage, toGeohash geohash: String) { - var timeline = geohashTimelines[geohash] ?? [] - guard !timeline.contains(where: { $0.id == message.id }) else { return } - timeline.append(message) - trimGeohashTimelineIfNeeded(&timeline) - geohashTimelines[geohash] = timeline + _ = appendGeohashMessageIfAbsent(message, geohash: geohash) } /// Append message if absent, returning true when stored. mutating func appendIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool { - var timeline = geohashTimelines[geohash] ?? [] - guard !timeline.contains(where: { $0.id == message.id }) else { return false } - timeline.append(message) - trimGeohashTimelineIfNeeded(&timeline) - geohashTimelines[geohash] = timeline - return true + appendGeohashMessageIfAbsent(message, geohash: geohash) } mutating func messages(for channel: ChannelID) -> [BitchatMessage] { @@ -56,7 +50,7 @@ struct PublicTimelineStore { return meshTimeline case .location(let channel): let cleaned = geohashTimelines[channel.geohash]?.cleanedAndDeduped() ?? [] - geohashTimelines[channel.geohash] = cleaned + replaceGeohashTimeline(cleaned, for: channel.geohash, keepEmpty: true) return cleaned } } @@ -65,22 +59,26 @@ struct PublicTimelineStore { switch channel { case .mesh: meshTimeline.removeAll() + meshMessageIDs.removeAll() case .location(let channel): geohashTimelines[channel.geohash] = [] + geohashMessageIDs[channel.geohash] = [] } } @discardableResult mutating func removeMessage(withID id: String) -> BitchatMessage? { if let index = meshTimeline.firstIndex(where: { $0.id == id }) { - return meshTimeline.remove(at: index) + let removed = meshTimeline.remove(at: index) + meshMessageIDs.remove(id) + return removed } for key in Array(geohashTimelines.keys) { var timeline = geohashTimelines[key] ?? [] if let index = timeline.firstIndex(where: { $0.id == id }) { let removed = timeline.remove(at: index) - geohashTimelines[key] = timeline.isEmpty ? nil : timeline + replaceGeohashTimeline(timeline, for: key, keepEmpty: false) return removed } } @@ -91,13 +89,13 @@ struct PublicTimelineStore { mutating func removeMessages(in geohash: String, where predicate: (BitchatMessage) -> Bool) { var timeline = geohashTimelines[geohash] ?? [] timeline.removeAll(where: predicate) - geohashTimelines[geohash] = timeline.isEmpty ? nil : timeline + replaceGeohashTimeline(timeline, for: geohash, keepEmpty: false) } mutating func mutateGeohash(_ geohash: String, _ transform: (inout [BitchatMessage]) -> Void) { var timeline = geohashTimelines[geohash] ?? [] transform(&timeline) - geohashTimelines[geohash] = timeline.isEmpty ? nil : timeline + replaceGeohashTimeline(timeline, for: geohash, keepEmpty: false) } mutating func queueGeohashSystemMessage(_ content: String) { @@ -116,10 +114,35 @@ struct PublicTimelineStore { private mutating func trimMeshTimelineIfNeeded() { guard meshTimeline.count > meshCap else { return } meshTimeline = Array(meshTimeline.suffix(meshCap)) + meshMessageIDs = Set(meshTimeline.map(\.id)) } - private func trimGeohashTimelineIfNeeded(_ timeline: inout [BitchatMessage]) { + private mutating func appendGeohashMessageIfAbsent(_ message: BitchatMessage, geohash: String) -> Bool { + var timeline = geohashTimelines[geohash] ?? [] + var messageIDs = geohashMessageIDs[geohash] ?? Set(timeline.map(\.id)) + guard messageIDs.insert(message.id).inserted else { return false } + + timeline.append(message) + trimGeohashTimelineIfNeeded(&timeline, messageIDs: &messageIDs) + geohashTimelines[geohash] = timeline + geohashMessageIDs[geohash] = messageIDs + return true + } + + private func trimGeohashTimelineIfNeeded(_ timeline: inout [BitchatMessage], messageIDs: inout Set) { guard timeline.count > geohashCap else { return } timeline = Array(timeline.suffix(geohashCap)) + messageIDs = Set(timeline.map(\.id)) + } + + private mutating func replaceGeohashTimeline(_ timeline: [BitchatMessage], for geohash: String, keepEmpty: Bool) { + if timeline.isEmpty && !keepEmpty { + geohashTimelines[geohash] = nil + geohashMessageIDs[geohash] = nil + return + } + + geohashTimelines[geohash] = timeline + geohashMessageIDs[geohash] = Set(timeline.map(\.id)) } } diff --git a/bitchat/Views/MessageListView.swift b/bitchat/Views/MessageListView.swift index 0719b248..cf3c9778 100644 --- a/bitchat/Views/MessageListView.swift +++ b/bitchat/Views/MessageListView.swift @@ -305,10 +305,10 @@ private extension MessageListView { var targetPeerID: String? { if let peer = privatePeer, - let last = privateInboxModel.messages(for: peer).suffix(300).last?.id { + let last = privateInboxModel.messages(for: peer).last?.id { return "dm:\(peer)|\(last)" } - if let last = publicChatModel.messages.suffix(300).last?.id { + if let last = publicChatModel.messages.last?.id { return "\(locationChannelsModel.selectedChannel.contextKey)|\(last)" } return nil @@ -329,7 +329,7 @@ private extension MessageListView { func scrollIfNeeded(date: Date) { lastScrollTime = date let contextKey = locationChannelsModel.selectedChannel.contextKey - if let target = messages.suffix(windowCountPublic).last.map({ "\(contextKey)|\($0.id)" }) { + if let target = messages.last.map({ "\(contextKey)|\($0.id)" }) { proxy.scrollTo(target, anchor: .bottom) } } @@ -368,8 +368,7 @@ private extension MessageListView { func scrollIfNeeded(date: Date) { lastScrollTime = date let contextKey = "dm:\(peerID)" - let count = windowCountPrivate[peerID] ?? 300 - if let target = messages.suffix(count).last.map({ "\(contextKey)|\($0.id)" }){ + if let target = messages.last.map({ "\(contextKey)|\($0.id)" }) { proxy.scrollTo(target, anchor: .bottom) } } @@ -399,7 +398,7 @@ private extension MessageListView { isAtBottom = true windowCountPublic = TransportConfig.uiWindowInitialCountPublic let contextKey = "geo:\(ch.geohash)" - if let target = publicChatModel.messages.suffix(windowCountPublic).last?.id.map({ "\(contextKey)|\($0)" }) { + if let target = publicChatModel.messages.last?.id.map({ "\(contextKey)|\($0)" }) { proxy.scrollTo(target, anchor: .bottom) } } diff --git a/bitchatTests/ChatNostrCoordinatorContextTests.swift b/bitchatTests/ChatNostrCoordinatorContextTests.swift new file mode 100644 index 00000000..73ad3b96 --- /dev/null +++ b/bitchatTests/ChatNostrCoordinatorContextTests.swift @@ -0,0 +1,442 @@ +// +// ChatNostrCoordinatorContextTests.swift +// bitchatTests +// +// Exercises `ChatNostrCoordinator` against a mock `ChatNostrContext` — +// proving the coordinator works without a `ChatViewModel`, following the +// `ChatDeliveryCoordinatorContextTests` / `ChatPrivateConversationCoordinatorContextTests` +// exemplars. +// + +import Testing +import Foundation +import BitFoundation +@testable import bitchat + +// MARK: - Mock Context + +/// Lightweight stand-in for `ChatNostrContext` proving that +/// `ChatNostrCoordinator` is testable without a `ChatViewModel`. +@MainActor +private final class MockChatNostrContext: ChatNostrContext { + // Channel & subscription state + var activeChannel: ChannelID = .mesh + var currentGeohash: String? + var geoSubscriptionID: String? + var geoDmSubscriptionID: String? + var geoSamplingSubs: [String: String] = [:] + var lastGeoNotificationAt: [String: Date] = [:] + var nostrRelayManager: NostrRelayManager? { nil } + + // Public timeline & pipeline + var messages: [BitchatMessage] = [] + private(set) var pipelineResetCount = 0 + private(set) var pipelineChannelUpdates: [ChannelID] = [] + private(set) var refreshedChannels: [ChannelID?] = [] + private(set) var publicSystemMessages: [String] = [] + var pendingGeohashSystemMessages: [String] = [] + private(set) var appendedGeohashMessages: [(message: BitchatMessage, geohash: String)] = [] + private(set) var synchronizedGeohashes: [String] = [] + + func resetPublicMessagePipeline() { pipelineResetCount += 1 } + func updatePublicMessagePipelineChannel(_ channel: ChannelID) { pipelineChannelUpdates.append(channel) } + func refreshVisibleMessages(from channel: ChannelID?) { refreshedChannels.append(channel) } + func addPublicSystemMessage(_ content: String) { publicSystemMessages.append(content) } + + func drainPendingGeohashSystemMessages() -> [String] { + defer { pendingGeohashSystemMessages.removeAll() } + return pendingGeohashSystemMessages + } + + func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool { + guard !appendedGeohashMessages.contains(where: { $0.message.id == message.id && $0.geohash == geohash }) else { + return false + } + appendedGeohashMessages.append((message, geohash)) + return true + } + + func synchronizePublicConversationStore(forGeohash geohash: String) { synchronizedGeohashes.append(geohash) } + + // Inbound public messages + private(set) var handledPublicMessages: [BitchatMessage] = [] + private(set) var mentionCheckedMessageIDs: [String] = [] + private(set) var hapticMessageIDs: [String] = [] + + func handlePublicMessage(_ message: BitchatMessage) { handledPublicMessages.append(message) } + func checkForMentions(_ message: BitchatMessage) { mentionCheckedMessageIDs.append(message.id) } + func sendHapticFeedback(for message: BitchatMessage) { hapticMessageIDs.append(message.id) } + func parseMentions(from content: String) -> [String] { [] } + + // Inbound private (geohash DM) payloads + var selectedPrivateChatPeer: PeerID? + var nostrKeyMapping: [PeerID: String] = [:] + private(set) var handledPrivateMessages: [(payload: NoisePayload, senderPubkey: String, convKey: PeerID, timestamp: Date)] = [] + private(set) var handledDelivered: [(senderPubkey: String, convKey: PeerID)] = [] + private(set) var handledReadReceipts: [(senderPubkey: String, convKey: PeerID)] = [] + private(set) var startedPrivateChats: [PeerID] = [] + + func handlePrivateMessage( + _ payload: NoisePayload, + senderPubkey: String, + convKey: PeerID, + id: NostrIdentity, + messageTimestamp: Date + ) { + handledPrivateMessages.append((payload, senderPubkey, convKey, messageTimestamp)) + } + + func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) { + handledDelivered.append((senderPubkey, convKey)) + } + + func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) { + handledReadReceipts.append((senderPubkey, convKey)) + } + + func startPrivateChat(with peerID: PeerID) { startedPrivateChats.append(peerID) } + + // Nostr identity & blocking + var geohashIdentities: [String: NostrIdentity] = [:] + var nostrIdentity: NostrIdentity? + var blockedNostrPubkeys: Set = [] + var displayNamesByPubkey: [String: String] = [:] + + private struct NoIdentity: Error {} + + func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity { + guard let identity = geohashIdentities[geohash] else { throw NoIdentity() } + return identity + } + + func currentNostrIdentity() -> NostrIdentity? { nostrIdentity } + + func isNostrBlocked(pubkeyHexLowercased: String) -> Bool { + blockedNostrPubkeys.contains(pubkeyHexLowercased.lowercased()) + } + + func displayNameForNostrPubkey(_ pubkeyHex: String) -> String { + displayNamesByPubkey[pubkeyHex] ?? "anon" + } + + // Event dedup + private(set) var recordedNostrEventIDs: [String] = [] + private var processedNostrEventIDs: Set = [] + private(set) var clearProcessedNostrEventsCount = 0 + + func hasProcessedNostrEvent(_ eventID: String) -> Bool { processedNostrEventIDs.contains(eventID) } + + func recordProcessedNostrEvent(_ eventID: String) { + processedNostrEventIDs.insert(eventID) + recordedNostrEventIDs.append(eventID) + } + + func clearProcessedNostrEvents() { + processedNostrEventIDs.removeAll() + clearProcessedNostrEventsCount += 1 + } + + // Geo participants & presence + var geoNicknames: [String: String] = [:] + private(set) var teleportedKeys: Set = [] + var teleportedGeoCount: Int { teleportedKeys.count } + private(set) var refreshTimerStartCount = 0 + private(set) var refreshTimerStopCount = 0 + private(set) var activeParticipantGeohashes: [String?] = [] + private(set) var recordedParticipants: [String] = [] + private(set) var recordedSampledParticipants: [(pubkeyHex: String, geohash: String)] = [] + private(set) var clearTeleportedGeoCount = 0 + private(set) var clearGeoNicknamesCount = 0 + var visiblePeople: [GeoPerson] = [] + + func startGeoParticipantRefreshTimer() { refreshTimerStartCount += 1 } + func stopGeoParticipantRefreshTimer() { refreshTimerStopCount += 1 } + func setActiveParticipantGeohash(_ geohash: String?) { activeParticipantGeohashes.append(geohash) } + func recordGeoParticipant(pubkeyHex: String) { recordedParticipants.append(pubkeyHex) } + + func recordGeoParticipant(pubkeyHex: String, geohash: String) { + recordedSampledParticipants.append((pubkeyHex, geohash)) + } + + func geoParticipantCount(for geohash: String) -> Int { + recordedSampledParticipants.filter { $0.geohash == geohash }.count + } + + func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String) { geoNicknames[pubkeyHex.lowercased()] = nickname } + func markGeoTeleported(_ pubkeyHexLowercased: String) { teleportedKeys.insert(pubkeyHexLowercased) } + func clearGeoTeleported(_ pubkeyHexLowercased: String) { teleportedKeys.remove(pubkeyHexLowercased) } + + func clearTeleportedGeo() { + teleportedKeys.removeAll() + clearTeleportedGeoCount += 1 + } + + func clearGeoNicknames() { + geoNicknames.removeAll() + clearGeoNicknamesCount += 1 + } + + func visibleGeohashPeople() -> [GeoPerson] { visiblePeople } + + // Location channels + var isTeleported = false + var regionalGeohashes: Set = [] + + func isGeohashOutsideRegionalChannels(_ geohash: String) -> Bool { + !regionalGeohashes.isEmpty && !regionalGeohashes.contains(geohash) + } + + // Routing & acknowledgements + private(set) var routedFavoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = [] + private(set) var geoDeliveryAcks: [(messageID: String, recipientHex: String)] = [] + private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = [] + + func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { + routedFavoriteNotifications.append((peerID, isFavorite)) + } + + func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { + geoDeliveryAcks.append((messageID, recipientHex)) + } + + func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { + geoReadReceipts.append((messageID, recipientHex)) + } +} + +// MARK: - Helpers + +/// Let the inner `Task { @MainActor in ... }` hops the coordinator schedules +/// run to completion. +@MainActor +private func drainMainQueue() async { + for _ in 0..<5 { + await Task.yield() + } +} + +// MARK: - Coordinator Tests Against Mock Context + +/// Exercises `ChatNostrCoordinator` against `MockChatNostrContext` with no +/// `ChatViewModel`. Scoped to the inbound event pipeline (dedup, presence, +/// public-message ingest), gift-wrap DM ingest, key mapping, channel-switch +/// teardown, and embedded ack flows. Flows that hit live singletons +/// (`NostrRelayManager.shared` subscriptions, `TorManager`, +/// `FavoritesPersistenceService`) remain covered by the full view-model tests. +struct ChatNostrCoordinatorContextTests { + + @Test @MainActor + func handleNostrEvent_ingestsPublicMessageOnceAndDeduplicates() async throws { + let context = MockChatNostrContext() + let coordinator = ChatNostrCoordinator(context: context) + + let sender = try NostrIdentity.generate() + let event = try NostrProtocol.createEphemeralGeohashEvent( + content: "hello geohash", + geohash: "u4pruyd", + senderIdentity: sender, + nickname: "alice" + ) + context.displayNamesByPubkey[event.pubkey] = "alice#1234" + + coordinator.handleNostrEvent(event) + await drainMainQueue() + + // Dedup recorded exactly once, presence and key mapping updated. + #expect(context.recordedNostrEventIDs == [event.id]) + #expect(context.geoNicknames[event.pubkey.lowercased()] == "alice") + #expect(context.recordedParticipants == [event.pubkey]) + #expect(context.nostrKeyMapping[PeerID(nostr: event.pubkey)] == event.pubkey) + #expect(context.nostrKeyMapping[PeerID(nostr_: event.pubkey)] == event.pubkey) + + // The message reached the public ingest path with the resolved name. + #expect(context.handledPublicMessages.map(\.id) == [event.id]) + #expect(context.handledPublicMessages.first?.sender == "alice#1234") + #expect(context.handledPublicMessages.first?.content == "hello geohash") + #expect(context.mentionCheckedMessageIDs == [event.id]) + #expect(context.hapticMessageIDs == [event.id]) + + // A replay of the same event is dropped before any processing. + coordinator.handleNostrEvent(event) + await drainMainQueue() + #expect(context.recordedNostrEventIDs == [event.id]) + #expect(context.handledPublicMessages.count == 1) + #expect(context.recordedParticipants.count == 1) + } + + @Test @MainActor + func handleNostrEvent_marksTeleportedPeerWithoutIngestingEmptyContent() async throws { + let context = MockChatNostrContext() + let coordinator = ChatNostrCoordinator(context: context) + + let sender = try NostrIdentity.generate() + let event = try NostrProtocol.createEphemeralGeohashEvent( + content: "", + geohash: "u4pruyd", + senderIdentity: sender, + teleported: true + ) + + coordinator.handleNostrEvent(event) + await drainMainQueue() + + // Teleport detection fires even though the empty message is dropped. + #expect(context.teleportedKeys == [event.pubkey.lowercased()]) + #expect(context.recordedParticipants == [event.pubkey]) + #expect(context.handledPublicMessages.isEmpty) + } + + @Test @MainActor + func handleNostrEvent_skipsBlockedSender() async throws { + let context = MockChatNostrContext() + let coordinator = ChatNostrCoordinator(context: context) + + let sender = try NostrIdentity.generate() + let event = try NostrProtocol.createEphemeralGeohashEvent( + content: "spam", + geohash: "u4pruyd", + senderIdentity: sender + ) + context.blockedNostrPubkeys.insert(event.pubkey.lowercased()) + + coordinator.handleNostrEvent(event) + await drainMainQueue() + + // The event is still recorded for dedup but nothing else happens. + #expect(context.recordedNostrEventIDs == [event.id]) + #expect(context.recordedParticipants.isEmpty) + #expect(context.handledPublicMessages.isEmpty) + } + + @Test @MainActor + func handleGiftWrap_routesEmbeddedPrivateMessageAndDeduplicates() async throws { + let context = MockChatNostrContext() + let coordinator = ChatNostrCoordinator(context: context) + + let recipient = try NostrIdentity.generate() + let sender = try NostrIdentity.generate() + let embedded = try #require(NostrEmbeddedBitChat.encodePMForNostrNoRecipient( + content: "psst", + messageID: "gm-1", + senderPeerID: PeerID(str: "aabbccddeeff0011") + )) + let giftWrap = try NostrProtocol.createPrivateMessage( + content: embedded, + recipientPubkey: recipient.publicKeyHex, + senderIdentity: sender + ) + + coordinator.handleGiftWrap(giftWrap, id: recipient) + + let convKey = PeerID(nostr_: sender.publicKeyHex) + #expect(context.recordedNostrEventIDs == [giftWrap.id]) + #expect(context.nostrKeyMapping[convKey] == sender.publicKeyHex) + #expect(context.handledPrivateMessages.count == 1) + #expect(context.handledPrivateMessages.first?.senderPubkey == sender.publicKeyHex) + #expect(context.handledPrivateMessages.first?.convKey == convKey) + + // The embedded Noise payload survives the round trip intact. + let payload = try #require(context.handledPrivateMessages.first?.payload) + #expect(payload.type == .privateMessage) + let pm = try #require(PrivateMessagePacket.decode(from: payload.data)) + #expect(pm.messageID == "gm-1") + #expect(pm.content == "psst") + + // The same gift wrap is dropped on replay. + coordinator.handleGiftWrap(giftWrap, id: recipient) + #expect(context.recordedNostrEventIDs == [giftWrap.id]) + #expect(context.handledPrivateMessages.count == 1) + } + + @Test @MainActor + func switchLocationChannel_toMesh_tearsDownGeohashState() async { + let context = MockChatNostrContext() + let coordinator = ChatNostrCoordinator(context: context) + context.activeChannel = .mesh + context.currentGeohash = "u4pruyd" + context.geoNicknames = ["abcd": "alice"] + + coordinator.switchLocationChannel(to: .mesh) + + #expect(context.pipelineResetCount == 1) + #expect(context.activeChannel == .mesh) + #expect(context.pipelineChannelUpdates == [.mesh]) + #expect(context.clearProcessedNostrEventsCount == 1) + #expect(context.refreshedChannels == [.mesh]) + #expect(context.refreshTimerStopCount == 1) + #expect(context.clearTeleportedGeoCount == 1) + // Cleared once in the mesh branch, once in the shared teardown. + #expect(context.activeParticipantGeohashes == [nil, nil]) + #expect(context.currentGeohash == nil) + #expect(context.geoSubscriptionID == nil) + #expect(context.geoDmSubscriptionID == nil) + #expect(context.clearGeoNicknamesCount == 1) + #expect(context.geoNicknames.isEmpty) + // Mesh never starts a geohash subscription or refresh timer. + #expect(context.refreshTimerStartCount == 0) + } + + @Test @MainActor + func sendDeliveryAckViaNostrEmbedded_sendsReadReceiptOnlyWhenViewingUnread() async throws { + let context = MockChatNostrContext() + let coordinator = ChatNostrCoordinator(context: context) + context.nostrIdentity = try NostrIdentity.generate() + let senderPubkey = "feedface00112233" + let convKey = PeerID(nostr_: senderPubkey) + + let message = BitchatMessage( + id: "mid-1", + sender: "alice#1234", + content: "hi", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "me", + senderPeerID: convKey + ) + + // Not viewing the chat: delivery ack only. + coordinator.sendDeliveryAckViaNostrEmbedded(message, wasReadBefore: false, senderPubkey: senderPubkey, key: nil) + #expect(context.geoDeliveryAcks.map(\.messageID) == ["mid-1"]) + #expect(context.geoDeliveryAcks.first?.recipientHex == senderPubkey) + #expect(context.geoReadReceipts.isEmpty) + + // Viewing the chat: delivery ack plus read receipt. + context.selectedPrivateChatPeer = convKey + coordinator.sendDeliveryAckViaNostrEmbedded(message, wasReadBefore: false, senderPubkey: senderPubkey, key: Data([0x01])) + #expect(context.geoDeliveryAcks.count == 2) + #expect(context.geoReadReceipts.map(\.messageID) == ["mid-1"]) + + // Already read: no further read receipt. + coordinator.sendDeliveryAckViaNostrEmbedded(message, wasReadBefore: true, senderPubkey: senderPubkey, key: nil) + #expect(context.geoDeliveryAcks.count == 3) + #expect(context.geoReadReceipts.count == 1) + } + + @Test @MainActor + func geohashDMKeyMappingHelpers_resolveAndStartChats() async { + let context = MockChatNostrContext() + let coordinator = ChatNostrCoordinator(context: context) + let hex = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" + let convKey = PeerID(nostr_: hex) + context.displayNamesByPubkey[hex] = "bob#eeff" + + coordinator.startGeohashDM(withPubkeyHex: hex) + #expect(context.nostrKeyMapping[convKey] == hex) + #expect(context.startedPrivateChats == [convKey]) + + #expect(coordinator.fullNostrHex(forSenderPeerID: convKey) == hex) + #expect(coordinator.geohashDisplayName(for: convKey) == "bob#eeff") + + // Unmapped conversation keys fall back to the bare peer ID. + let unknown = PeerID(nostr_: "ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100") + #expect(coordinator.geohashDisplayName(for: unknown) == unknown.bare) + + // Display-name lookup prefers visible people, then nicknames. + context.visiblePeople = [GeoPerson(id: "aa11", displayName: "carol#aa11", lastSeen: Date())] + context.geoNicknames = ["bb22": "dave"] + #expect(coordinator.nostrPubkeyForDisplayName("carol#aa11") == "aa11") + #expect(coordinator.nostrPubkeyForDisplayName("dave") == "bb22") + #expect(coordinator.nostrPubkeyForDisplayName("nobody") == nil) + } +} diff --git a/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift b/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift new file mode 100644 index 00000000..dc55fdca --- /dev/null +++ b/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift @@ -0,0 +1,390 @@ +// +// ChatPrivateConversationCoordinatorContextTests.swift +// bitchatTests +// +// Exercises `ChatPrivateConversationCoordinator` against a mock +// `ChatPrivateConversationContext` — proving the coordinator works without a +// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` exemplar. +// + +import Testing +import Foundation +import BitFoundation +@testable import bitchat + +// MARK: - Mock Context + +/// Lightweight stand-in for `ChatPrivateConversationContext` proving that +/// `ChatPrivateConversationCoordinator` is testable without a `ChatViewModel`. +@MainActor +private final class MockChatPrivateConversationContext: ChatPrivateConversationContext { + // Conversation state + var privateChats: [PeerID: [BitchatMessage]] = [:] + var sentReadReceipts: Set = [] + var sentGeoDeliveryAcks: Set = [] + var unreadPrivateMessages: Set = [] + var selectedPrivateChatPeer: PeerID? + var nickname = "me" + var activeChannel: ChannelID = .mesh + var nostrKeyMapping: [PeerID: String] = [:] + private(set) var notifyUIChangedCount = 0 + + func notifyUIChanged() { + notifyUIChangedCount += 1 + } + + // Peers & identity + var myPeerID = PeerID(str: "0011223344556677") + var nicknamesByPeerID: [PeerID: String] = [:] + var connectedPeers: Set = [] + var reachablePeers: Set = [] + var blockedPeers: Set = [] + var noiseKeysByPeerID: [PeerID: Data] = [:] + var ephemeralPeerIDsByNoiseKey: [Data: PeerID] = [:] + var peerIDsByNickname: [String: PeerID] = [:] + var fingerprintsByPeerID: [PeerID: String] = [:] + private(set) var clearedFingerprints: [PeerID] = [] + + func peerNickname(for peerID: PeerID) -> String? { nicknamesByPeerID[peerID] } + func isPeerConnected(_ peerID: PeerID) -> Bool { connectedPeers.contains(peerID) } + func isPeerReachable(_ peerID: PeerID) -> Bool { reachablePeers.contains(peerID) } + func isPeerBlocked(_ peerID: PeerID) -> Bool { blockedPeers.contains(peerID) } + func noisePublicKey(for peerID: PeerID) -> Data? { noiseKeysByPeerID[peerID] } + func ephemeralPeerID(forNoiseKey noiseKey: Data) -> PeerID? { ephemeralPeerIDsByNoiseKey[noiseKey] } + func getPeerIDForNickname(_ nickname: String) -> PeerID? { peerIDsByNickname[nickname] } + func getFingerprint(for peerID: PeerID) -> String? { fingerprintsByPeerID[peerID] } + func storedFingerprint(for peerID: PeerID) -> String? { fingerprintsByPeerID[peerID] } + + func clearStoredFingerprint(for peerID: PeerID) { + fingerprintsByPeerID.removeValue(forKey: peerID) + clearedFingerprints.append(peerID) + } + + // Nostr identity + var blockedNostrPubkeys: Set = [] + var displayNamesByPubkey: [String: String] = [:] + + func isNostrBlocked(pubkeyHexLowercased: String) -> Bool { + blockedNostrPubkeys.contains(pubkeyHexLowercased) + } + + func displayNameForNostrPubkey(_ pubkeyHex: String) -> String { + displayNamesByPubkey[pubkeyHex] ?? "anon" + } + + func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity { Self.dummyIdentity } + func currentNostrIdentity() -> NostrIdentity? { Self.dummyIdentity } + + // Routing & acknowledgements + private(set) var routedPrivateMessages: [(content: String, peerID: PeerID, messageID: String)] = [] + private(set) var routedReadReceipts: [(messageID: String, peerID: PeerID)] = [] + private(set) var routedFavoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = [] + private(set) var meshReadReceipts: [(messageID: String, peerID: PeerID)] = [] + private(set) var geoPrivateMessages: [(content: String, recipientHex: String, messageID: String)] = [] + private(set) var geoDeliveryAcks: [(messageID: String, recipientHex: String)] = [] + private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = [] + private(set) var embeddedDeliveryAckMessageIDs: [String] = [] + + func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { + routedPrivateMessages.append((content, peerID, messageID)) + } + + func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { + routedReadReceipts.append((receipt.originalMessageID, peerID)) + } + + func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { + routedFavoriteNotifications.append((peerID, isFavorite)) + } + + func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { + meshReadReceipts.append((receipt.originalMessageID, peerID)) + } + + func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) { + geoPrivateMessages.append((content, recipientHex, messageID)) + } + + func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { + geoDeliveryAcks.append((messageID, recipientHex)) + } + + func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { + geoReadReceipts.append((messageID, recipientHex)) + } + + func sendDeliveryAckViaNostrEmbedded(_ message: BitchatMessage, wasReadBefore: Bool, senderPubkey: String, key: Data?) { + embeddedDeliveryAckMessageIDs.append(message.id) + } + + // System messages & chat hygiene + private(set) var systemMessages: [String] = [] + private(set) var meshOnlySystemMessages: [String] = [] + private(set) var sanitizedPeerIDs: [PeerID] = [] + + func addSystemMessage(_ content: String) { + systemMessages.append(content) + } + + func addMeshOnlySystemMessage(_ content: String) { + meshOnlySystemMessages.append(content) + } + + func sanitizeChat(for peerID: PeerID) { + sanitizedPeerIDs.append(peerID) + } + + static let dummyIdentity = NostrIdentity( + privateKey: Data(repeating: 0x11, count: 32), + publicKey: Data(repeating: 0x22, count: 32), + npub: "npub1mock", + createdAt: Date(timeIntervalSince1970: 0) + ) +} + +// MARK: - Helpers + +@MainActor +private func makeIncomingMessage( + id: String, + sender: String = "alice", + content: String = "hello", + timestamp: Date = Date(), + senderPeerID: PeerID? = nil, + recipientNickname: String? = "me" +) -> BitchatMessage { + BitchatMessage( + id: id, + sender: sender, + content: content, + timestamp: timestamp, + isRelay: false, + isPrivate: true, + recipientNickname: recipientNickname, + senderPeerID: senderPeerID, + deliveryStatus: .delivered(to: "me", at: timestamp) + ) +} + +private func isDelivered(_ status: DeliveryStatus?, to expected: String) -> Bool { + if case .delivered(let to, _) = status { return to == expected } + return false +} + +private func isRead(_ status: DeliveryStatus?, by expected: String) -> Bool { + if case .read(let by, _) = status { return by == expected } + return false +} + +// MARK: - Coordinator Tests Against Mock Context + +/// Exercises `ChatPrivateConversationCoordinator` against +/// `MockChatPrivateConversationContext` with no `ChatViewModel`. Scoped to the +/// pure-state and ack flows; flows that hit `NotificationService` / +/// `FavoritesPersistenceService` singletons remain covered by the full +/// view-model tests. +struct ChatPrivateConversationCoordinatorContextTests { + + @Test @MainActor + func addMessageToPrivateChats_upsertsByIdAndSanitizes() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + let peerID = PeerID(str: "0102030405060708") + + let original = makeIncomingMessage(id: "m1", content: "first") + coordinator.addMessageToPrivateChatsIfNeeded(original, targetPeerID: peerID) + #expect(context.privateChats[peerID]?.map(\.id) == ["m1"]) + + // Same id again must replace in place, not append a duplicate. + let updated = makeIncomingMessage(id: "m1", content: "edited") + coordinator.addMessageToPrivateChatsIfNeeded(updated, targetPeerID: peerID) + #expect(context.privateChats[peerID]?.count == 1) + #expect(context.privateChats[peerID]?.first?.content == "edited") + + // A different id appends. + coordinator.addMessageToPrivateChatsIfNeeded(makeIncomingMessage(id: "m2"), targetPeerID: peerID) + #expect(context.privateChats[peerID]?.map(\.id) == ["m1", "m2"]) + #expect(context.sanitizedPeerIDs == [peerID, peerID, peerID]) + + #expect(coordinator.isDuplicateMessage("m1", targetPeerID: peerID)) + #expect(!coordinator.isDuplicateMessage("m3", targetPeerID: peerID)) + } + + @Test @MainActor + func geoDeliveredAndReadAcks_updateStatusAndNotify() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + let convKey = PeerID(str: "nostr_abcdef12") + let senderPubkey = "feedface00112233" + context.displayNamesByPubkey[senderPubkey] = "alice#1234" + context.privateChats[convKey] = [ + makeIncomingMessage(id: "mine-1", sender: "me"), + makeIncomingMessage(id: "mine-2", sender: "me"), + ] + + coordinator.handleDelivered( + NoisePayload(type: .delivered, data: Data("mine-1".utf8)), + senderPubkey: senderPubkey, + convKey: convKey + ) + #expect(isDelivered(context.privateChats[convKey]?.first?.deliveryStatus, to: "alice#1234")) + #expect(context.notifyUIChangedCount == 1) + + coordinator.handleReadReceipt( + NoisePayload(type: .readReceipt, data: Data("mine-2".utf8)), + senderPubkey: senderPubkey, + convKey: convKey + ) + #expect(isRead(context.privateChats[convKey]?.last?.deliveryStatus, by: "alice#1234")) + #expect(context.notifyUIChangedCount == 2) + + // Unknown message id: no state change, no UI notification. + coordinator.handleDelivered( + NoisePayload(type: .delivered, data: Data("missing".utf8)), + senderPubkey: senderPubkey, + convKey: convKey + ) + #expect(context.notifyUIChangedCount == 2) + } + + @Test @MainActor + func geoPrivateMessage_sendsDeliveryAckOnceAndDeduplicates() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + let convKey = PeerID(str: "nostr_abcdef12") + let senderPubkey = "feedface00112233" + context.displayNamesByPubkey[senderPubkey] = "bob#5678" + let payloadData = PrivateMessagePacket(messageID: "geo-1", content: "hi there").encode()! + let payload = NoisePayload(type: .privateMessage, data: payloadData) + // Old timestamp: not "recent", so no unread marking (and no notification). + let oldTimestamp = Date().addingTimeInterval(-120) + + coordinator.handlePrivateMessage( + payload, + senderPubkey: senderPubkey, + convKey: convKey, + id: MockChatPrivateConversationContext.dummyIdentity, + messageTimestamp: oldTimestamp + ) + + #expect(context.geoDeliveryAcks.map(\.messageID) == ["geo-1"]) + #expect(context.geoDeliveryAcks.first?.recipientHex == senderPubkey) + #expect(context.sentGeoDeliveryAcks == ["geo-1"]) + #expect(context.privateChats[convKey]?.map(\.id) == ["geo-1"]) + #expect(context.privateChats[convKey]?.first?.sender == "bob#5678") + #expect(context.unreadPrivateMessages.isEmpty) + #expect(context.notifyUIChangedCount == 1) + + // Redelivery: ack is deduplicated and the message is not appended twice. + coordinator.handlePrivateMessage( + payload, + senderPubkey: senderPubkey, + convKey: convKey, + id: MockChatPrivateConversationContext.dummyIdentity, + messageTimestamp: oldTimestamp + ) + #expect(context.geoDeliveryAcks.count == 1) + #expect(context.privateChats[convKey]?.count == 1) + } + + @Test @MainActor + func handleViewingThisChat_clearsUnreadAndSendsRoutedReadReceiptOnce() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + let noiseKey = Data(repeating: 0xAB, count: 32) + let stablePeerID = PeerID(hexData: noiseKey) + let ephemeralPeerID = PeerID(str: "0102030405060708") + context.ephemeralPeerIDsByNoiseKey[noiseKey] = ephemeralPeerID + context.unreadPrivateMessages = [stablePeerID, ephemeralPeerID] + let message = makeIncomingMessage(id: "read-1", senderPeerID: stablePeerID) + + coordinator.handleViewingThisChat( + message, + targetPeerID: stablePeerID, + key: noiseKey, + senderPubkey: "feedface00112233" + ) + + #expect(context.unreadPrivateMessages.isEmpty) + #expect(context.routedReadReceipts.map(\.messageID) == ["read-1"]) + #expect(context.routedReadReceipts.first?.peerID == stablePeerID) + #expect(context.sentReadReceipts == ["read-1"]) + + // Already-acked message must not produce a second receipt. + coordinator.handleViewingThisChat( + message, + targetPeerID: stablePeerID, + key: noiseKey, + senderPubkey: "feedface00112233" + ) + #expect(context.routedReadReceipts.count == 1) + + // Without a Noise key, the receipt goes out via the geohash transport. + context.sentReadReceipts = [] + coordinator.handleViewingThisChat( + message, + targetPeerID: stablePeerID, + key: nil, + senderPubkey: "feedface00112233" + ) + #expect(context.geoReadReceipts.map(\.messageID) == ["read-1"]) + #expect(context.sentReadReceipts == ["read-1"]) + } + + @Test @MainActor + func markAsUnread_tracksTargetAndEphemeralWithoutNotificationWhenStale() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + let noiseKey = Data(repeating: 0xCD, count: 32) + let stablePeerID = PeerID(hexData: noiseKey) + let ephemeralPeerID = PeerID(str: "1112131415161718") + context.ephemeralPeerIDsByNoiseKey[noiseKey] = ephemeralPeerID + + // isRecentMessage false keeps the flow off the NotificationService singleton. + coordinator.markAsUnreadIfNeeded( + shouldMarkAsUnread: true, + targetPeerID: stablePeerID, + key: noiseKey, + isRecentMessage: false, + senderNickname: "alice", + messageContent: "hello" + ) + #expect(context.unreadPrivateMessages == [stablePeerID, ephemeralPeerID]) + + context.unreadPrivateMessages = [] + coordinator.markAsUnreadIfNeeded( + shouldMarkAsUnread: false, + targetPeerID: stablePeerID, + key: noiseKey, + isRecentMessage: false, + senderNickname: "alice", + messageContent: "hello" + ) + #expect(context.unreadPrivateMessages.isEmpty) + } + + @Test @MainActor + func migratePrivateChats_movesMessagesOnFingerprintMatchAndClearsOldPeer() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + let oldPeerID = PeerID(str: "aaaaaaaaaaaaaaaa") + let newPeerID = PeerID(str: "bbbbbbbbbbbbbbbb") + context.fingerprintsByPeerID[oldPeerID] = "fp-1" + context.fingerprintsByPeerID[newPeerID] = "fp-1" + let older = makeIncomingMessage(id: "old-1", timestamp: Date().addingTimeInterval(-60)) + let newer = makeIncomingMessage(id: "old-2", timestamp: Date().addingTimeInterval(-30)) + context.privateChats[oldPeerID] = [newer, older] + context.unreadPrivateMessages = [oldPeerID] + context.selectedPrivateChatPeer = oldPeerID + + coordinator.migratePrivateChatsIfNeeded(for: newPeerID, senderNickname: "alice") + + #expect(context.privateChats[oldPeerID] == nil) + #expect(context.privateChats[newPeerID]?.map(\.id) == ["old-1", "old-2"]) + #expect(context.unreadPrivateMessages.isEmpty) + #expect(context.clearedFingerprints == [oldPeerID]) + #expect(context.selectedPrivateChatPeer == newPeerID) + #expect(context.sanitizedPeerIDs == [newPeerID]) + #expect(context.notifyUIChangedCount == 1) + } +} diff --git a/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift b/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift new file mode 100644 index 00000000..68322fd3 --- /dev/null +++ b/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift @@ -0,0 +1,495 @@ +// +// ChatPublicConversationCoordinatorContextTests.swift +// bitchatTests +// +// Exercises `ChatPublicConversationCoordinator` against a mock +// `ChatPublicConversationContext` — proving the coordinator works without a +// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` / +// `ChatPrivateConversationCoordinatorContextTests` exemplars. +// +// Scope note: flows that hit process-wide singletons are intentionally not +// exercised here — `checkForMentions` / haptics (NotificationService.shared, +// UIApplication) and the geohash branch of `sendPublicRaw` +// (NostrRelayManager.shared, GeoRelayDirectory.shared). The mesh branch of +// `sendPublicRaw` and all timeline/store/blocking flows are covered. +// + +import Testing +import Foundation +import BitFoundation +@testable import bitchat + +// MARK: - Mock Context + +/// Lightweight stand-in for `ChatPublicConversationContext` proving that +/// `ChatPublicConversationCoordinator` is testable without a `ChatViewModel`. +@MainActor +private final class MockChatPublicConversationContext: ChatPublicConversationContext { + // Channel & visible timeline state + var messages: [BitchatMessage] = [] + var activeChannel: ChannelID = .mesh + var currentGeohash: String? + var nickname = "me" + var myPeerID = PeerID(str: "0011223344556677") + var isBatchingPublic = false + private(set) var notifyUIChangedCount = 0 + private(set) var trimMessagesCount = 0 + + func notifyUIChanged() { + notifyUIChangedCount += 1 + } + + func trimMessagesIfNeeded() { + trimMessagesCount += 1 + } + + // Public timeline store + var meshTimeline: [BitchatMessage] = [] + var geoTimelines: [String: [BitchatMessage]] = [:] + private(set) var queuedGeohashSystemMessages: [String] = [] + + func timelineMessages(for channel: ChannelID) -> [BitchatMessage] { + switch channel { + case .mesh: return meshTimeline + case .location(let channel): return geoTimelines[channel.geohash] ?? [] + } + } + + func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID) { + switch channel { + case .mesh: meshTimeline.append(message) + case .location(let channel): geoTimelines[channel.geohash, default: []].append(message) + } + } + + func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool { + if geoTimelines[geohash]?.contains(where: { $0.id == message.id }) == true { + return false + } + geoTimelines[geohash, default: []].append(message) + return true + } + + func removeTimelineMessage(withID id: String) -> BitchatMessage? { + if let index = meshTimeline.firstIndex(where: { $0.id == id }) { + return meshTimeline.remove(at: index) + } + for (geohash, timeline) in geoTimelines { + guard let index = timeline.firstIndex(where: { $0.id == id }) else { continue } + var updated = timeline + let removed = updated.remove(at: index) + geoTimelines[geohash] = updated + return removed + } + return nil + } + + func removeGeohashTimelineMessages(in geohash: String, where predicate: (BitchatMessage) -> Bool) { + geoTimelines[geohash]?.removeAll(where: predicate) + } + + func clearTimeline(for channel: ChannelID) { + switch channel { + case .mesh: meshTimeline.removeAll() + case .location(let channel): geoTimelines[channel.geohash] = [] + } + } + + func timelineGeohashKeys() -> [String] { + Array(geoTimelines.keys) + } + + func queueGeohashSystemMessage(_ content: String) { + queuedGeohashSystemMessages.append(content) + } + + // Conversation stores + private(set) var conversationActiveChannels: [ChannelID] = [] + private(set) var replacedChannelMessages: [(channel: ChannelID, messageIDs: [String])] = [] + private(set) var replacedConversationMessages: [(conversation: ConversationID, messageIDs: [String])] = [] + private(set) var privateStoreSyncCount = 0 + private(set) var selectionStoreSyncCount = 0 + + func setConversationActiveChannel(_ channel: ChannelID) { + conversationActiveChannels.append(channel) + } + + func replaceConversationMessages(_ messages: [BitchatMessage], for channelID: ChannelID) { + replacedChannelMessages.append((channelID, messages.map(\.id))) + } + + func replaceConversationMessages(_ messages: [BitchatMessage], for conversationID: ConversationID) { + replacedConversationMessages.append((conversationID, messages.map(\.id))) + } + + func synchronizePrivateConversationStore() { + privateStoreSyncCount += 1 + } + + func synchronizeConversationSelectionStore() { + selectionStoreSyncCount += 1 + } + + // Private chats + var privateChats: [PeerID: [BitchatMessage]] = [:] + var unreadPrivateMessages: Set = [] + private(set) var cleanedUpFileMessageIDs: [String] = [] + + func cleanupLocalFile(forMessage message: BitchatMessage) { + cleanedUpFileMessageIDs.append(message.id) + } + + // Geohash participants & presence + var geoNicknames: [String: String] = [:] + var isTeleported = false + var nostrKeyMapping: [PeerID: String] = [:] + var geoPeople: [GeoPerson] = [] + var geoParticipantCounts: [String: Int] = [:] + private(set) var removedGeoParticipants: [String] = [] + + func visibleGeoPeople() -> [GeoPerson] { + geoPeople + } + + func geoParticipantCount(for geohash: String) -> Int { + geoParticipantCounts[geohash] ?? 0 + } + + func removeGeoParticipant(pubkeyHex: String) { + removedGeoParticipants.append(pubkeyHex) + } + + // Nostr identity & blocking + var blockedNostrPubkeys: Set = [] + + func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity { + Self.dummyIdentity + } + + func isNostrBlocked(pubkeyHexLowercased: String) -> Bool { + blockedNostrPubkeys.contains(pubkeyHexLowercased) + } + + func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) { + if isBlocked { + blockedNostrPubkeys.insert(pubkeyHexLowercased) + } else { + blockedNostrPubkeys.remove(pubkeyHexLowercased) + } + } + + // Mesh transport + var meshNicknames: [PeerID: String] = [:] + private(set) var sentMeshMessages: [(content: String, messageID: String)] = [] + + func meshPeerNicknames() -> [PeerID: String] { + meshNicknames + } + + func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) { + sentMeshMessages.append((content, messageID)) + } + + // Inbound public message processing + var blockedMessageIDs: Set = [] + var rateLimitAllowed = true + private(set) var rateLimitChecks: [(senderKey: String, contentKey: String)] = [] + private(set) var enqueuedMessageIDs: [String] = [] + var stablePeerIDs: [PeerID: PeerID] = [:] + + func processActionMessage(_ message: BitchatMessage) -> BitchatMessage { + message + } + + func isMessageBlocked(_ message: BitchatMessage) -> Bool { + blockedMessageIDs.contains(message.id) + } + + func allowPublicMessage(senderKey: String, contentKey: String) -> Bool { + rateLimitChecks.append((senderKey, contentKey)) + return rateLimitAllowed + } + + func enqueuePublicMessage(_ message: BitchatMessage) { + enqueuedMessageIDs.append(message.id) + } + + func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID? { + stablePeerIDs[shortPeerID] + } + + // Content dedup & formatting + var contentTimestamps: [String: Date] = [:] + private(set) var recordedContentKeys: [(key: String, timestamp: Date)] = [] + private(set) var prewarmedMessageIDs: [String] = [] + + func normalizedContentKey(_ content: String) -> String { + content.lowercased() + } + + func contentTimestamp(forKey key: String) -> Date? { + contentTimestamps[key] + } + + func recordContentKey(_ key: String, timestamp: Date) { + recordedContentKeys.append((key, timestamp)) + } + + func prewarmMessageFormatting(_ message: BitchatMessage) { + prewarmedMessageIDs.append(message.id) + } + + static let dummyIdentity = NostrIdentity( + privateKey: Data(repeating: 0x11, count: 32), + publicKey: Data(repeating: 0x22, count: 32), + npub: "npub1mock", + createdAt: Date(timeIntervalSince1970: 0) + ) +} + +// MARK: - Helpers + +@MainActor +private func makePublicMessage( + id: String = UUID().uuidString, + sender: String = "alice", + content: String = "hello world", + senderPeerID: PeerID? = PeerID(str: "aabbccddeeff0011") +) -> BitchatMessage { + BitchatMessage( + id: id, + sender: sender, + content: content, + timestamp: Date(), + isRelay: false, + isPrivate: false, + senderPeerID: senderPeerID + ) +} + +// MARK: - Coordinator Tests Against Mock Context + +/// Exercises `ChatPublicConversationCoordinator` against +/// `MockChatPublicConversationContext` — no `ChatViewModel` involved. +struct ChatPublicConversationCoordinatorContextTests { + + @Test @MainActor + func handlePublicMessage_meshMessage_appendsSyncsStoreAndEnqueues() async { + let context = MockChatPublicConversationContext() + let coordinator = ChatPublicConversationCoordinator(context: context) + let message = makePublicMessage(id: "mesh-msg-1", content: "Hello Mesh") + + coordinator.handlePublicMessage(message) + + #expect(context.meshTimeline.map(\.id) == ["mesh-msg-1"]) + #expect(context.replacedChannelMessages.count == 1) + #expect(context.replacedChannelMessages.first?.channel == .mesh) + #expect(context.replacedChannelMessages.first?.messageIDs == ["mesh-msg-1"]) + #expect(context.rateLimitChecks.count == 1) + #expect(context.rateLimitChecks.first?.senderKey == "mesh:aabbccddeeff0011") + #expect(context.rateLimitChecks.first?.contentKey == "hello mesh") + #expect(context.enqueuedMessageIDs == ["mesh-msg-1"]) + + // Already visible in the timeline: stored again, but not re-enqueued. + context.messages = [message] + coordinator.handlePublicMessage(message) + #expect(context.enqueuedMessageIDs == ["mesh-msg-1"]) + } + + @Test @MainActor + func handlePublicMessage_blockedOrRateLimited_dropsMessage() async { + let context = MockChatPublicConversationContext() + let coordinator = ChatPublicConversationCoordinator(context: context) + + // Blocked sender: dropped before rate limiting and storage. + context.blockedMessageIDs = ["blocked-msg"] + coordinator.handlePublicMessage(makePublicMessage(id: "blocked-msg")) + #expect(context.rateLimitChecks.isEmpty) + #expect(context.meshTimeline.isEmpty) + #expect(context.enqueuedMessageIDs.isEmpty) + + // Rate limited: consulted, then dropped before storage. + context.rateLimitAllowed = false + coordinator.handlePublicMessage(makePublicMessage(id: "limited-msg")) + #expect(context.rateLimitChecks.count == 1) + #expect(context.meshTimeline.isEmpty) + #expect(context.enqueuedMessageIDs.isEmpty) + } + + @Test @MainActor + func handlePublicMessage_geoMessage_respectsActiveChannel() async { + let context = MockChatPublicConversationContext() + let coordinator = ChatPublicConversationCoordinator(context: context) + let geohash = "u4pruy" + context.currentGeohash = geohash + let senderHex = String(repeating: "ab", count: 32) + let geoMessage = makePublicMessage( + id: "geo-msg-1", + content: "geo hello", + senderPeerID: PeerID(nostr: senderHex) + ) + + // On mesh channel: stored in the geohash timeline but not enqueued. + context.activeChannel = .mesh + coordinator.handlePublicMessage(geoMessage) + #expect(context.geoTimelines[geohash]?.map(\.id) == ["geo-msg-1"]) + #expect(context.replacedConversationMessages.count == 1) + #expect(context.replacedConversationMessages.first?.conversation == .geohash(geohash)) + #expect(context.meshTimeline.isEmpty) + #expect(context.enqueuedMessageIDs.isEmpty) + + // On the matching location channel: enqueued for display. + context.activeChannel = .location(GeohashChannel(level: .city, geohash: geohash)) + let second = makePublicMessage( + id: "geo-msg-2", + content: "geo again", + senderPeerID: PeerID(nostr: senderHex) + ) + coordinator.handlePublicMessage(second) + #expect(context.enqueuedMessageIDs == ["geo-msg-2"]) + } + + @Test @MainActor + func blockGeohashUser_purgesMessagesMappingsAndPrivateChats() async { + let context = MockChatPublicConversationContext() + let coordinator = ChatPublicConversationCoordinator(context: context) + let geohash = "u4pruy" + let hex = String(repeating: "cd", count: 32) + let senderPeerID = PeerID(nostr: hex) + let convKey = PeerID(nostr_: hex) + let geoMessage = makePublicMessage(id: "geo-bad-1", sender: "rude", senderPeerID: senderPeerID) + + context.currentGeohash = geohash + context.activeChannel = .location(GeohashChannel(level: .city, geohash: geohash)) + context.geoTimelines[geohash] = [geoMessage] + context.messages = [geoMessage] + context.nostrKeyMapping = [senderPeerID: hex, convKey: hex] + context.privateChats[convKey] = [geoMessage] + context.unreadPrivateMessages = [convKey] + + coordinator.blockGeohashUser(pubkeyHexLowercased: hex, displayName: "rude#abcd") + + #expect(context.blockedNostrPubkeys.contains(hex)) + #expect(context.removedGeoParticipants == [hex]) + #expect(context.geoTimelines[geohash]?.isEmpty == true) + #expect(context.privateChats[convKey] == nil) + #expect(context.unreadPrivateMessages.isEmpty) + #expect(context.nostrKeyMapping.isEmpty) + // The blocked user's visible message is gone; a system notice was added. + #expect(!context.messages.contains(where: { $0.id == "geo-bad-1" })) + #expect(context.messages.last?.sender == "system") + + coordinator.unblockGeohashUser(pubkeyHexLowercased: hex, displayName: "rude#abcd") + #expect(!context.blockedNostrPubkeys.contains(hex)) + } + + @Test @MainActor + func removeMessage_removesEverywhereAndCleansUpFile() async { + let context = MockChatPublicConversationContext() + let coordinator = ChatPublicConversationCoordinator(context: context) + let peerID = PeerID(str: "0102030405060708") + let message = makePublicMessage(id: "doomed-msg") + context.messages = [message] + context.meshTimeline = [message] + context.privateChats[peerID] = [message] + + coordinator.removeMessage(withID: "doomed-msg", cleanupFile: true) + + #expect(context.messages.isEmpty) + #expect(context.meshTimeline.isEmpty) + #expect(context.privateChats[peerID] == nil) + #expect(context.cleanedUpFileMessageIDs == ["doomed-msg"]) + #expect(context.notifyUIChangedCount == 1) + // Timeline removal triggers a full conversation-store resync. + #expect(context.replacedChannelMessages.contains(where: { $0.channel == .mesh && $0.messageIDs.isEmpty })) + } + + @Test @MainActor + func addPublicSystemMessage_appendsRefreshesAndRecordsContentKey() async { + let context = MockChatPublicConversationContext() + let coordinator = ChatPublicConversationCoordinator(context: context) + + coordinator.addPublicSystemMessage("Tor Ready") + + #expect(context.meshTimeline.count == 1) + #expect(context.meshTimeline.first?.sender == "system") + // refreshVisibleMessages mirrors the timeline into the visible list. + #expect(context.messages.map(\.id) == context.meshTimeline.map(\.id)) + #expect(context.recordedContentKeys.map(\.key) == ["tor ready"]) + #expect(context.trimMessagesCount == 1) + #expect(context.notifyUIChangedCount == 1) + #expect(context.conversationActiveChannels == [.mesh]) + + // On mesh, geohash-only system messages are queued for the next geo visit. + coordinator.addGeohashOnlySystemMessage("geo notice") + #expect(context.queuedGeohashSystemMessages == ["geo notice"]) + } + + @Test @MainActor + func sendPublicRaw_onMeshChannel_sendsViaMeshTransport() async { + let context = MockChatPublicConversationContext() + let coordinator = ChatPublicConversationCoordinator(context: context) + + coordinator.sendPublicRaw("raw mesh payload") + + #expect(context.sentMeshMessages.count == 1) + #expect(context.sentMeshMessages.first?.content == "raw mesh payload") + } + + @Test @MainActor + func pipelineDelegate_readsAndWritesContextState() async { + let context = MockChatPublicConversationContext() + let coordinator = ChatPublicConversationCoordinator(context: context) + let pipeline = PublicMessagePipeline() + let message = makePublicMessage(id: "pipeline-msg") + context.messages = [message] + context.contentTimestamps["key-1"] = Date(timeIntervalSince1970: 42) + + #expect(coordinator.pipelineCurrentMessages(pipeline).map(\.id) == ["pipeline-msg"]) + #expect(coordinator.pipeline(pipeline, normalizeContent: "HeLLo") == "hello") + #expect(coordinator.pipeline(pipeline, contentTimestampForKey: "key-1") == Date(timeIntervalSince1970: 42)) + + coordinator.pipeline(pipeline, setMessages: []) + #expect(context.messages.isEmpty) + + coordinator.pipeline(pipeline, recordContentKey: "key-2", timestamp: Date(timeIntervalSince1970: 7)) + #expect(context.recordedContentKeys.map(\.key) == ["key-2"]) + + coordinator.pipelineTrimMessages(pipeline) + #expect(context.trimMessagesCount == 1) + + coordinator.pipelinePrewarmMessage(pipeline, message: message) + #expect(context.prewarmedMessageIDs == ["pipeline-msg"]) + + coordinator.pipelineSetBatchingState(pipeline, isBatching: true) + #expect(context.isBatchingPublic) + } + + @Test @MainActor + func currentPublicSenderAndDisplayName_deriveGeoSuffixedIdentity() async { + let context = MockChatPublicConversationContext() + let coordinator = ChatPublicConversationCoordinator(context: context) + let identityHex = MockChatPublicConversationContext.dummyIdentity.publicKeyHex + let suffix = String(identityHex.suffix(4)) + + // Mesh: plain nickname and mesh peer ID. + let meshSender = coordinator.currentPublicSender() + #expect(meshSender.name == "me") + #expect(meshSender.peerID == context.myPeerID) + + // Location channel: suffixed nickname and nostr peer ID. + context.activeChannel = .location(GeohashChannel(level: .city, geohash: "u4pruy")) + let geoSender = coordinator.currentPublicSender() + #expect(geoSender.name == "me#\(suffix)") + #expect(geoSender.peerID == PeerID(nostr: identityHex)) + + // Display names: own geo identity, known nickname, and anon fallback. + context.currentGeohash = "u4pruy" + #expect(coordinator.displayNameForNostrPubkey(identityHex) == "me#\(suffix)") + let otherHex = String(repeating: "ef", count: 32) + context.geoNicknames[otherHex] = "bob" + #expect(coordinator.displayNameForNostrPubkey(otherHex) == "bob#" + otherHex.suffix(4)) + let unknownHex = String(repeating: "12", count: 32) + #expect(coordinator.displayNameForNostrPubkey(unknownHex) == "anon#" + unknownHex.suffix(4)) + } +} diff --git a/bitchatTests/ChatViewModelDeliveryStatusTests.swift b/bitchatTests/ChatViewModelDeliveryStatusTests.swift index fc87ae6a..ae1c02e3 100644 --- a/bitchatTests/ChatViewModelDeliveryStatusTests.swift +++ b/bitchatTests/ChatViewModelDeliveryStatusTests.swift @@ -98,6 +98,30 @@ struct ChatViewModelDeliveryStatusTests { }()) } + @Test @MainActor + func deliveryStatus_identicalUpdateIsNoop() async { + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: "0102030405060708") + let messageID = "test-msg-identical" + let message = BitchatMessage( + id: messageID, + sender: viewModel.nickname, + content: "Test message", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Peer", + senderPeerID: transport.myPeerID, + deliveryStatus: .sent + ) + viewModel.privateChats[peerID] = [message] + + let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus(messageID, status: .sent) + + #expect(!didUpdate) + #expect(isSent(viewModel.privateChats[peerID]?.first?.deliveryStatus)) + } + @Test @MainActor func deliveryStatus_upgrade_deliveredToRead() async { let (viewModel, transport) = makeTestableViewModel() @@ -222,6 +246,104 @@ struct ChatViewModelDeliveryStatusTests { }()) } + @Test @MainActor + func deliveryStatus_updatesPublicAndMirroredPrivateMessages() async { + let (viewModel, transport) = makeTestableViewModel() + let messageID = "mirrored-msg-1" + let firstPeerID = PeerID(str: "0102030405060708") + let secondPeerID = PeerID(str: "1112131415161718") + + viewModel.messages = [ + BitchatMessage( + id: messageID, + sender: viewModel.nickname, + content: "Public copy", + timestamp: Date(), + isRelay: false, + isPrivate: false, + senderPeerID: transport.myPeerID, + deliveryStatus: .sent + ) + ] + viewModel.privateChats[firstPeerID] = [ + BitchatMessage( + id: messageID, + sender: viewModel.nickname, + content: "Private copy A", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Peer A", + senderPeerID: transport.myPeerID, + deliveryStatus: .sent + ) + ] + viewModel.privateChats[secondPeerID] = [ + BitchatMessage( + id: messageID, + sender: viewModel.nickname, + content: "Private copy B", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Peer B", + senderPeerID: transport.myPeerID, + deliveryStatus: .sent + ) + ] + + let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus( + messageID, + status: .delivered(to: "Peer", at: Date()) + ) + + #expect(didUpdate) + #expect(isDelivered(viewModel.messages.first?.deliveryStatus)) + #expect(isDelivered(viewModel.privateChats[firstPeerID]?.first?.deliveryStatus)) + #expect(isDelivered(viewModel.privateChats[secondPeerID]?.first?.deliveryStatus)) + } + + @Test @MainActor + func deliveryStatus_indexRefreshesAfterPrivateChatReorder() async { + let (viewModel, transport) = makeTestableViewModel() + let peerID = PeerID(str: "0102030405060708") + let messageID = "reordered-msg-1" + let olderMessage = BitchatMessage( + id: "older-msg", + sender: viewModel.nickname, + content: "Older message", + timestamp: Date(timeIntervalSince1970: 1), + isRelay: false, + isPrivate: true, + recipientNickname: "Peer", + senderPeerID: transport.myPeerID, + deliveryStatus: .sent + ) + let targetMessage = BitchatMessage( + id: messageID, + sender: viewModel.nickname, + content: "Target message", + timestamp: Date(timeIntervalSince1970: 2), + isRelay: false, + isPrivate: true, + recipientNickname: "Peer", + senderPeerID: transport.myPeerID, + deliveryStatus: .sent + ) + + viewModel.privateChats[peerID] = [targetMessage, olderMessage] + #expect(isSent(viewModel.deliveryCoordinator.deliveryStatus(for: messageID))) + + viewModel.privateChats[peerID] = [olderMessage, targetMessage] + let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus( + messageID, + status: .read(by: "Peer", at: Date()) + ) + + #expect(didUpdate) + #expect(isRead(viewModel.privateChats[peerID]?.last?.deliveryStatus)) + } + // MARK: - Status Rank Tests (for deduplication) @Test @MainActor @@ -252,3 +374,145 @@ struct ChatViewModelDeliveryStatusTests { } } } + +// MARK: - Mock Delivery Context + +/// Lightweight stand-in for `ChatDeliveryContext` proving that +/// `ChatDeliveryCoordinator` is testable without constructing a `ChatViewModel`. +@MainActor +private final class MockChatDeliveryContext: ChatDeliveryContext { + var messages: [BitchatMessage] = [] + var privateChats: [PeerID: [BitchatMessage]] = [:] + var sentReadReceipts: Set = [] + var isStartupPhase = false + private(set) var notifyUIChangedCount = 0 + private(set) var markedDeliveredMessageIDs: [String] = [] + + func notifyUIChanged() { + notifyUIChangedCount += 1 + } + + func markMessageDelivered(_ messageID: String) { + markedDeliveredMessageIDs.append(messageID) + } +} + +@MainActor +private func makePrivateMessage(id: String, status: DeliveryStatus) -> BitchatMessage { + BitchatMessage( + id: id, + sender: "me", + content: "Test message", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "Peer", + senderPeerID: PeerID(str: "aabbccddeeff0011"), + deliveryStatus: status + ) +} + +// MARK: - Coordinator Tests Against Mock Context + +/// Exercises `ChatDeliveryCoordinator` against `MockChatDeliveryContext` — +/// the exemplar for the narrow-dependency coordinator pattern. +struct ChatDeliveryCoordinatorContextTests { + + @Test @MainActor + func updateDeliveryStatus_updatesPrivateChatNotifiesAndMarksDelivered() async { + let context = MockChatDeliveryContext() + let coordinator = ChatDeliveryCoordinator(context: context) + let peerID = PeerID(str: "0102030405060708") + let messageID = "mock-msg-1" + context.privateChats[peerID] = [makePrivateMessage(id: messageID, status: .sent)] + + let didUpdate = coordinator.updateMessageDeliveryStatus( + messageID, + status: .delivered(to: "Peer", at: Date()) + ) + + #expect(didUpdate) + #expect(isDelivered(context.privateChats[peerID]?.first?.deliveryStatus)) + #expect(context.notifyUIChangedCount == 1) + #expect(context.markedDeliveredMessageIDs == [messageID]) + } + + @Test @MainActor + func readReceipt_marksDeliveredAndUpgradesStatus() async { + let context = MockChatDeliveryContext() + let coordinator = ChatDeliveryCoordinator(context: context) + let peerID = PeerID(str: "0102030405060708") + let messageID = "mock-msg-2" + context.privateChats[peerID] = [makePrivateMessage(id: messageID, status: .delivered(to: "Peer", at: Date()))] + + coordinator.didReceiveReadReceipt( + ReadReceipt(originalMessageID: messageID, readerID: peerID, readerNickname: "Peer") + ) + + #expect(isRead(context.privateChats[peerID]?.first?.deliveryStatus)) + #expect(context.notifyUIChangedCount == 1) + #expect(context.markedDeliveredMessageIDs == [messageID]) + } + + @Test @MainActor + func sentStatus_doesNotMarkDeliveredAndUnknownMessageDoesNotNotify() async { + let context = MockChatDeliveryContext() + let coordinator = ChatDeliveryCoordinator(context: context) + context.messages = [ + BitchatMessage( + id: "public-mock-1", + sender: "me", + content: "Public message", + timestamp: Date(), + isRelay: false, + isPrivate: false, + deliveryStatus: .sending + ) + ] + + // .sent is not a confirmed receipt — must not reach markMessageDelivered. + let didUpdate = coordinator.updateMessageDeliveryStatus("public-mock-1", status: .sent) + #expect(didUpdate) + #expect(isSent(context.messages.first?.deliveryStatus)) + #expect(context.markedDeliveredMessageIDs.isEmpty) + #expect(context.notifyUIChangedCount == 1) + + // Unknown message: no state change, no extra UI notification. + let didUpdateUnknown = coordinator.updateMessageDeliveryStatus("missing-msg", status: .sent) + #expect(!didUpdateUnknown) + #expect(context.notifyUIChangedCount == 1) + } + + @Test @MainActor + func cleanupOldReadReceipts_prunesReceiptsAgainstMockContext() async { + let context = MockChatDeliveryContext() + let coordinator = ChatDeliveryCoordinator(context: context) + let peerID = PeerID(str: "0102030405060708") + context.privateChats[peerID] = [makePrivateMessage(id: "keep-receipt", status: .sent)] + context.sentReadReceipts = ["keep-receipt", "drop-receipt"] + + // Startup phase: cleanup must be a no-op. + context.isStartupPhase = true + coordinator.cleanupOldReadReceipts() + #expect(context.sentReadReceipts == ["keep-receipt", "drop-receipt"]) + + context.isStartupPhase = false + coordinator.cleanupOldReadReceipts() + #expect(context.sentReadReceipts == ["keep-receipt"]) + } +} + +private func isSent(_ status: DeliveryStatus?) -> Bool { + if case .sent = status { return true } + return false +} + +private func isDelivered(_ status: DeliveryStatus?) -> Bool { + if case .delivered = status { return true } + return false +} + +private func isRead(_ status: DeliveryStatus?) -> Bool { + if case .read = status { return true } + return false +} diff --git a/bitchatTests/ChatViewModelExtensionsTests.swift b/bitchatTests/ChatViewModelExtensionsTests.swift index 7e8f0b9a..4af153e0 100644 --- a/bitchatTests/ChatViewModelExtensionsTests.swift +++ b/bitchatTests/ChatViewModelExtensionsTests.swift @@ -398,6 +398,26 @@ struct ChatViewModelNostrExtensionTests { #expect(viewModel.privateChats.isEmpty) } + @Test @MainActor + func handleNostrMessage_invalidSignatureDoesNotPoisonDedup() async throws { + let (viewModel, _) = makeTestableViewModel() + let sender = try NostrIdentity.generate() + let recipient = try #require(try viewModel.idBridge.getCurrentNostrIdentity()) + let giftWrap = try NostrProtocol.createPrivateMessage( + content: "verify:noop", + recipientPubkey: recipient.publicKeyHex, + senderIdentity: sender + ) + var invalidGiftWrap = giftWrap + invalidGiftWrap.sig = String(repeating: "0", count: 128) + + viewModel.handleNostrMessage(invalidGiftWrap) + #expect(!viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id)) + + viewModel.handleNostrMessage(giftWrap) + #expect(viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id)) + } + @Test @MainActor func switchLocationChannel_clearsNostrDedupCache() async { let (viewModel, _) = makeTestableViewModel() diff --git a/bitchatTests/GeohashPresenceTests.swift b/bitchatTests/GeohashPresenceTests.swift index dcb5e3bc..96f19080 100644 --- a/bitchatTests/GeohashPresenceTests.swift +++ b/bitchatTests/GeohashPresenceTests.swift @@ -278,6 +278,75 @@ struct ChatViewModelPresenceHandlingTests { #expect(count >= 1) } + @Test func subscribeNostrEvent_samplingDeduplicatesSameEventAcrossGeohashes() async throws { + let (viewModel, _) = makeTestableViewModel() + let firstGeohash = "u4pru" + let secondGeohash = "u4pruy" + let identity = try NostrIdentity.generate() + let event = NostrEvent( + pubkey: identity.publicKeyHex, + createdAt: Date(), + kind: .geohashPresence, + tags: [["g", secondGeohash]], + content: "" + ) + let signed = try event.sign(with: identity.schnorrSigningKey()) + + viewModel.subscribeNostrEvent(signed, gh: firstGeohash) + viewModel.subscribeNostrEvent(signed, gh: secondGeohash) + + #expect(viewModel.geohashParticipantCount(for: firstGeohash) == 1) + #expect(viewModel.geohashParticipantCount(for: secondGeohash) == 0) + } + + @Test func subscribeNostrEvent_samplingDedupDoesNotBlockActiveChannelProcessing() async throws { + let (viewModel, _) = makeTestableViewModel() + let sampleGeohash = "u4pru" + let activeGeohash = "u4pruy" + let identity = try NostrIdentity.generate() + + viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: activeGeohash))) + + let event = NostrEvent( + pubkey: identity.publicKeyHex, + createdAt: Date(), + kind: .geohashPresence, + tags: [["g", sampleGeohash]], + content: "" + ) + let signed = try event.sign(with: identity.schnorrSigningKey()) + + viewModel.subscribeNostrEvent(signed, gh: sampleGeohash) + #expect(!viewModel.deduplicationService.hasProcessedNostrEvent(signed.id)) + + viewModel.subscribeNostrEvent(signed) + + #expect(viewModel.geohashParticipantCount(for: sampleGeohash) == 1) + #expect(viewModel.deduplicationService.hasProcessedNostrEvent(signed.id)) + #expect(viewModel.geohashParticipantCount(for: activeGeohash) >= 1) + } + + @Test func subscribeNostrEvent_samplingInvalidSignatureDoesNotPoisonDedup() async throws { + let (viewModel, _) = makeTestableViewModel() + let sampleGeohash = "u4pru" + let identity = try NostrIdentity.generate() + let event = NostrEvent( + pubkey: identity.publicKeyHex, + createdAt: Date(), + kind: .geohashPresence, + tags: [["g", sampleGeohash]], + content: "" + ) + let signed = try event.sign(with: identity.schnorrSigningKey()) + var invalid = signed + invalid.sig = String(repeating: "0", count: 128) + + viewModel.subscribeNostrEvent(invalid, gh: sampleGeohash) + viewModel.subscribeNostrEvent(signed, gh: sampleGeohash) + + #expect(viewModel.geohashParticipantCount(for: sampleGeohash) == 1) + } + // MARK: - Test Helper private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) { diff --git a/bitchatTests/Integration/LargeTopologyTests.swift b/bitchatTests/Integration/LargeTopologyTests.swift new file mode 100644 index 00000000..3f4c44dc --- /dev/null +++ b/bitchatTests/Integration/LargeTopologyTests.swift @@ -0,0 +1,220 @@ +// +// LargeTopologyTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation +import Testing +@testable import BitFoundation // to avoid unnecessary public's +@testable import bitchat + +/// Production-shaped topology tests: deep relay chains, larger partial meshes, +/// partitions, and churn. +/// +/// Determinism: `MockBLEService` flooding is fully synchronous — `sendMessage` +/// recurses through `simulateIncomingPacket` across the whole connected +/// component before returning — so every test here sends, then asserts, with +/// no waits, sleeps, or confirmations needed. +struct LargeTopologyTests { + + private let helper = TestNetworkHelper() + + /// content -> receiver name -> delivery count. + /// Delivery in the mock harness is single-threaded and synchronous, so + /// plain (unlocked) mutation is safe here. + private final class DeliveryLog { + private(set) var counts: [String: [String: Int]] = [:] + func record(receiver: String, content: String) { + counts[content, default: [:]][receiver, default: 0] += 1 + } + func deliveries(of content: String) -> [String: Int] { + counts[content] ?? [:] + } + } + + private func makeNodes(_ names: [String]) { + for name in names { + helper.createNode(name, peerID: PeerID(str: UUID().uuidString)) + } + } + + /// Installs a counting message handler on every node. Local echo does not + /// trigger `messageDeliveryHandler`, so the sender of a broadcast records + /// no delivery for its own message. + private func installDeliveryLog() -> DeliveryLog { + let log = DeliveryLog() + for (name, node) in helper.nodes { + node.messageDeliveryHandler = { message in + log.record(receiver: name, content: message.content) + } + } + return log + } + + // MARK: - Deep chain + + @Test func deepChainBroadcastTraversesAllHopsWithTTLDecay() { + // A - B - C - D - E - F - G - H (7 hops end to end) + let chain = ["A", "B", "C", "D", "E", "F", "G", "H"] + makeNodes(chain) + for i in 0..<(chain.count - 1) { + helper.connect(chain[i], chain[i + 1]) + } + + let log = installDeliveryLog() + + // Record the highest TTL each node observes for the broadcast. The + // shortest-path arrival carries the highest TTL (back-floods from + // further down the chain have decayed further), and unlike + // "first handler call" this is independent of recursion order — the + // mock invokes packetDeliveryHandler after its recursive flood, so + // innermost calls fire first. + var maxSeenTTL: [String: UInt8] = [:] + for name in chain.dropFirst() { + helper.nodes[name]!.packetDeliveryHandler = { packet in + if let message = BitchatMessage(packet.payload), + message.content == "deep chain probe" { + maxSeenTTL[name] = max(maxSeenTTL[name] ?? 0, packet.ttl) + } + } + } + + helper.nodes["A"]!.sendMessage("deep chain probe") + + // Reachability: the broadcast relays across all 7 hops and lands + // exactly once at every other node (seenMessageIDs dedup). + let deliveries = log.deliveries(of: "deep chain probe") + for name in chain.dropFirst() { + #expect(deliveries[name] == 1, "\(name) should receive the broadcast exactly once") + } + #expect(deliveries["A"] == nil, "sender must not receive its own broadcast") + + // TTL-modeling gap: the mock decrements and clamps TTL while flooding, + // but it intentionally does NOT stop relaying when TTL reaches 0 — + // MockBLEService floods the entire connected component and relies on + // seenMessageIDs dedup (see the comment in simulateIncomingPacket). + // Production BLEService enforces TTL and drops packets at 0 (max 7 + // hops), so this 7-hop chain only succeeds end to end because the mock + // does not enforce TTL. We therefore assert decay and clamping here, + // not enforcement. + let initialTTL = helper.nodes["A"]!.sentPackets.last!.ttl + var previousTTL = initialTTL + for (hop, name) in chain.dropFirst().enumerated() { + // The first hop carries the original TTL (sendMessage delivers the + // packet unmodified); each subsequent relay decrements, clamped at 0. + let expected = initialTTL > UInt8(hop) ? initialTTL - UInt8(hop) : 0 + let observed = maxSeenTTL[name] + #expect(observed == expected, "\(name) expected shortest-path TTL \(expected), got \(String(describing: observed))") + if let observed { + #expect(observed <= initialTTL, "TTL must never exceed the initial TTL") + #expect(observed <= previousTTL, "TTL must be non-increasing along the chain") + previousTTL = observed + } + } + } + + // MARK: - Larger partial mesh + + @Test func partialMeshBroadcastReachesAllExactlyOnce() { + // 14 peers in a ring with chord edges: redundant paths create cycles, + // so duplicate suppression (seenMessageIDs) is genuinely exercised. + let names = (0..<14).map { String(format: "M%02d", $0) } + makeNodes(names) + for i in 0.. + // components {C8, C9, C0, C1, C2} and {C3, C4, C5, C6, C7}. + helper.disconnect("C2", "C3") + helper.disconnect("C7", "C8") + helper.nodes["C0"]!.sendMessage("split round") + var deliveries = log.deliveries(of: "split round") + for name in ["C1", "C2", "C8", "C9"] { + #expect(deliveries[name] == 1, "\(name) is in C0's component and should receive exactly once") + } + for i in 3...7 { #expect(deliveries["C\(i)"] == nil, "C\(i) is in the other component") } + + // Round 2: restore the ring; a broadcast from the far side reaches all. + helper.connect("C2", "C3") + helper.connect("C7", "C8") + helper.nodes["C5"]!.sendMessage("healed round") + deliveries = log.deliveries(of: "healed round") + for name in names where name != "C5" { + #expect(deliveries[name] == 1, "\(name) should receive exactly once after the ring heals") + } + #expect(deliveries["C5"] == nil, "sender must not receive its own broadcast") + + // Round 3: fully isolate C9; everyone else still receives. + helper.disconnect("C8", "C9") + helper.disconnect("C9", "C0") + helper.nodes["C0"]!.sendMessage("isolated round") + deliveries = log.deliveries(of: "isolated round") + for i in 1...8 { #expect(deliveries["C\(i)"] == 1, "C\(i) remains connected and should receive") } + #expect(deliveries["C9"] == nil, "isolated node must not receive") + + // Round 4: rejoin C9; a broadcast from the rejoined node reaches all. + helper.connect("C9", "C0") + helper.nodes["C9"]!.sendMessage("rejoined round") + deliveries = log.deliveries(of: "rejoined round") + for i in 0...8 { #expect(deliveries["C\(i)"] == 1, "C\(i) should receive from the rejoined node") } + #expect(deliveries["C9"] == nil, "sender must not receive its own broadcast") + } +} diff --git a/bitchatTests/PublicTimelineStoreTests.swift b/bitchatTests/PublicTimelineStoreTests.swift index ac177117..17081aca 100644 --- a/bitchatTests/PublicTimelineStoreTests.swift +++ b/bitchatTests/PublicTimelineStoreTests.swift @@ -1,4 +1,5 @@ import Foundation +import BitFoundation import Testing @testable import bitchat @@ -21,6 +22,38 @@ struct PublicTimelineStoreTests { #expect(messages.map(\.content) == ["two", "three"]) } + @Test("Timeline indexes allow trimmed message IDs to return") + func timelineIndexesAllowTrimmedMessageIDsToReturn() { + var store = PublicTimelineStore(meshCap: 2, geohashCap: 2) + let first = timelineMessage(id: "one", content: "one", timestamp: 1) + let second = timelineMessage(id: "two", content: "two", timestamp: 2) + let third = timelineMessage(id: "three", content: "three", timestamp: 3) + + store.append(first, to: .mesh) + store.append(second, to: .mesh) + store.append(third, to: .mesh) + store.append(first, to: .mesh) + + #expect(store.messages(for: .mesh).map(\.content) == ["three", "one"]) + + let geohash = "u4pruydq" + let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash)) + let geoFirst = timelineMessage(id: "geo-one", content: "geo one", timestamp: 1) + let geoSecond = timelineMessage(id: "geo-two", content: "geo two", timestamp: 2) + let geoThird = timelineMessage(id: "geo-three", content: "geo three", timestamp: 3) + + let didAppendGeoFirst = store.appendIfAbsent(geoFirst, toGeohash: geohash) + let didAppendGeoSecond = store.appendIfAbsent(geoSecond, toGeohash: geohash) + let didAppendGeoThird = store.appendIfAbsent(geoThird, toGeohash: geohash) + let didReappendGeoFirst = store.appendIfAbsent(geoFirst, toGeohash: geohash) + + #expect(didAppendGeoFirst) + #expect(didAppendGeoSecond) + #expect(didAppendGeoThird) + #expect(didReappendGeoFirst) + #expect(store.messages(for: channel).map(\.content) == ["geo one", "geo three"]) + } + @Test("Geohash appendIfAbsent remove and clear work together") func geohashStoreSupportsAppendRemoveAndClear() { var store = PublicTimelineStore(meshCap: 2, geohashCap: 3) @@ -69,4 +102,14 @@ struct PublicTimelineStoreTests { #expect(store.drainPendingGeohashSystemMessages() == ["first", "second"]) #expect(store.drainPendingGeohashSystemMessages().isEmpty) } + + private func timelineMessage(id: String, content: String, timestamp: TimeInterval) -> BitchatMessage { + BitchatMessage( + id: id, + sender: "alice", + content: content, + timestamp: Date(timeIntervalSince1970: timestamp), + isRelay: false + ) + } } diff --git a/bitchatTests/Services/BLEAnnounceHandlerTests.swift b/bitchatTests/Services/BLEAnnounceHandlerTests.swift new file mode 100644 index 00000000..2aece692 --- /dev/null +++ b/bitchatTests/Services/BLEAnnounceHandlerTests.swift @@ -0,0 +1,433 @@ +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +struct BLEAnnounceHandlerTests { + private final class Recorder { + var existingNoisePublicKey: Data? + var signatureValid = true + var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false) + var upsertResult = BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil) + var dedupSeenIDs: Set = [] + var shouldEmitReconnectLogResult = true + + var verifySignatureCalls: [(packet: BitchatPacket, signingPublicKey: Data)] = [] + var barrierCount = 0 + var upsertCalls: [(peerID: PeerID, announcement: AnnouncementPacket, isConnected: Bool, now: Date)] = [] + var reconnectLogQueries: [PeerID] = [] + var topologyUpdates: [(peerID: PeerID, neighbors: [Data])] = [] + var persistedIdentities: [AnnouncementPacket] = [] + var dedupContainsQueries: [String] = [] + var dedupMarkedIDs: [String] = [] + var uiEventDeliveries: [(peerID: PeerID, notifyPeerConnected: Bool, scheduleInitialSync: Bool)] = [] + var trackedPackets: [BitchatPacket] = [] + var announceBacks = 0 + var afterglowDelays: [TimeInterval] = [] + } + + private func makeHandler( + recorder: Recorder, + localPeerID: PeerID = PeerID(str: "0102030405060708"), + now: Date = Date(timeIntervalSince1970: 1_000) + ) -> BLEAnnounceHandler { + let environment = BLEAnnounceHandlerEnvironment( + localPeerID: { localPeerID }, + messageTTL: TransportConfig.messageTTLDefault, + now: { now }, + existingNoisePublicKey: { _ in recorder.existingNoisePublicKey }, + verifySignature: { packet, signingPublicKey in + recorder.verifySignatureCalls.append((packet, signingPublicKey)) + return recorder.signatureValid + }, + linkState: { _ in recorder.linkState }, + withRegistryBarrier: { body in + recorder.barrierCount += 1 + body() + }, + upsertVerifiedAnnounce: { peerID, announcement, isConnected, now in + recorder.upsertCalls.append((peerID, announcement, isConnected, now)) + return recorder.upsertResult + }, + shouldEmitReconnectLog: { peerID, _ in + recorder.reconnectLogQueries.append(peerID) + return recorder.shouldEmitReconnectLogResult + }, + updateTopology: { peerID, neighbors in + recorder.topologyUpdates.append((peerID, neighbors)) + }, + persistIdentity: { announcement in + recorder.persistedIdentities.append(announcement) + }, + dedupContains: { id in + recorder.dedupContainsQueries.append(id) + return recorder.dedupSeenIDs.contains(id) + }, + dedupMarkProcessed: { id in + recorder.dedupMarkedIDs.append(id) + }, + deliverAnnounceUIEvents: { peerID, notifyPeerConnected, scheduleInitialSync in + recorder.uiEventDeliveries.append((peerID, notifyPeerConnected, scheduleInitialSync)) + }, + trackPacketSeen: { packet in + recorder.trackedPackets.append(packet) + }, + sendAnnounceBack: { + recorder.announceBacks += 1 + }, + scheduleAfterglow: { delay in + recorder.afterglowDelays.append(delay) + } + ) + return BLEAnnounceHandler(environment: environment) + } + + @Test + func verifiedNewPeerAnnounceUpsertsNotifiesSyncsAndAnnouncesBack() throws { + let now = Date(timeIntervalSince1970: 1_000) + let noiseKey = Data(repeating: 0x11, count: 32) + let peerID = PeerID(publicKey: noiseKey) + let packet = try makeAnnouncePacket( + noisePublicKey: noiseKey, + peerID: peerID, + timestamp: timestamp(now), + signature: Data(repeating: 0xEE, count: 64) + ) + + let recorder = Recorder() + recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil) + let handler = makeHandler(recorder: recorder, now: now) + + handler.handle(packet, from: peerID) + + #expect(recorder.verifySignatureCalls.count == 1) + #expect(recorder.verifySignatureCalls.first?.signingPublicKey == Data(repeating: 0x99, count: 32)) + #expect(recorder.barrierCount == 1) + #expect(recorder.upsertCalls.count == 1) + #expect(recorder.upsertCalls.first?.peerID == peerID) + #expect(recorder.upsertCalls.first?.announcement.nickname == "Alice") + #expect(recorder.upsertCalls.first?.isConnected == true) + #expect(recorder.upsertCalls.first?.now == now) + #expect(recorder.persistedIdentities.count == 1) + #expect(recorder.persistedIdentities.first?.noisePublicKey == noiseKey) + #expect(recorder.uiEventDeliveries.count == 1) + #expect(recorder.uiEventDeliveries.first?.peerID == peerID) + #expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == true) + #expect(recorder.uiEventDeliveries.first?.scheduleInitialSync == true) + #expect(recorder.trackedPackets.count == 1) + #expect(recorder.dedupMarkedIDs == ["announce-back-\(peerID)"]) + #expect(recorder.announceBacks == 1) + #expect(recorder.afterglowDelays.count == 1) + } + + @Test + func afterglowDelayStaysWithinConfiguredRange() throws { + let now = Date(timeIntervalSince1970: 1_000) + let noiseKey = Data(repeating: 0x12, count: 32) + let peerID = PeerID(publicKey: noiseKey) + let packet = try makeAnnouncePacket( + noisePublicKey: noiseKey, + peerID: peerID, + timestamp: timestamp(now), + signature: Data(repeating: 0xEE, count: 64) + ) + + let recorder = Recorder() + recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil) + let handler = makeHandler(recorder: recorder, now: now) + + for _ in 0..<8 { + handler.handle(packet, from: peerID) + } + + #expect(recorder.afterglowDelays.count == 8) + for delay in recorder.afterglowDelays { + #expect(delay >= 0.3 && delay <= 0.6) + } + } + + @Test + func unverifiedAnnounceWithoutSignatureSkipsUpsertAndConnectNotify() throws { + let now = Date(timeIntervalSince1970: 1_000) + let noiseKey = Data(repeating: 0x22, count: 32) + let peerID = PeerID(publicKey: noiseKey) + let packet = try makeAnnouncePacket( + noisePublicKey: noiseKey, + peerID: peerID, + timestamp: timestamp(now), + signature: nil + ) + + let recorder = Recorder() + let handler = makeHandler(recorder: recorder, now: now) + + handler.handle(packet, from: peerID) + + #expect(recorder.verifySignatureCalls.isEmpty) + #expect(recorder.barrierCount == 1) + #expect(recorder.upsertCalls.isEmpty) + #expect(recorder.topologyUpdates.isEmpty) + #expect(recorder.afterglowDelays.isEmpty) + // Original behavior: list refresh, identity persistence, sync tracking + // and announce-back still occur for unverified announces. + #expect(recorder.uiEventDeliveries.count == 1) + #expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false) + #expect(recorder.uiEventDeliveries.first?.scheduleInitialSync == false) + #expect(recorder.persistedIdentities.count == 1) + #expect(recorder.trackedPackets.count == 1) + #expect(recorder.announceBacks == 1) + } + + @Test + func invalidSignatureSkipsUpsertAndConnectNotify() throws { + let now = Date(timeIntervalSince1970: 1_000) + let noiseKey = Data(repeating: 0x23, count: 32) + let peerID = PeerID(publicKey: noiseKey) + let packet = try makeAnnouncePacket( + noisePublicKey: noiseKey, + peerID: peerID, + timestamp: timestamp(now), + signature: Data(repeating: 0xEE, count: 64) + ) + + let recorder = Recorder() + recorder.signatureValid = false + let handler = makeHandler(recorder: recorder, now: now) + + handler.handle(packet, from: peerID) + + #expect(recorder.verifySignatureCalls.count == 1) + #expect(recorder.upsertCalls.isEmpty) + #expect(recorder.uiEventDeliveries.count == 1) + #expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false) + } + + @Test + func malformedAnnounceIsNoOp() { + let now = Date(timeIntervalSince1970: 1_000) + let peerID = PeerID(str: "1122334455667788") + let packet = BitchatPacket( + type: MessageType.announce.rawValue, + senderID: Data(hexString: peerID.id) ?? Data(), + recipientID: nil, + timestamp: timestamp(now), + payload: Data([0x01, 0x20]), + signature: nil, + ttl: TransportConfig.messageTTLDefault + ) + + let recorder = Recorder() + let handler = makeHandler(recorder: recorder, now: now) + + handler.handle(packet, from: peerID) + + expectNoSideEffects(recorder) + } + + @Test + func selfAnnounceIsNoOp() throws { + let now = Date(timeIntervalSince1970: 1_000) + let noiseKey = Data(repeating: 0x33, count: 32) + let peerID = PeerID(publicKey: noiseKey) + let packet = try makeAnnouncePacket( + noisePublicKey: noiseKey, + peerID: peerID, + timestamp: timestamp(now), + signature: Data(repeating: 0xEE, count: 64) + ) + + let recorder = Recorder() + let handler = makeHandler(recorder: recorder, localPeerID: peerID, now: now) + + handler.handle(packet, from: peerID) + + expectNoSideEffects(recorder) + } + + @Test + func staleAnnounceIsNoOp() throws { + let now = Date(timeIntervalSince1970: 1_000) + let noiseKey = Data(repeating: 0x44, count: 32) + let peerID = PeerID(publicKey: noiseKey) + let staleTimestamp = UInt64((now.timeIntervalSince1970 - 901) * 1000) + let packet = try makeAnnouncePacket( + noisePublicKey: noiseKey, + peerID: peerID, + timestamp: staleTimestamp, + signature: Data(repeating: 0xEE, count: 64) + ) + + let recorder = Recorder() + let handler = makeHandler(recorder: recorder, now: now) + + handler.handle(packet, from: peerID) + + expectNoSideEffects(recorder) + } + + @Test + func reconnectedPeerNotifiesConnectionWithoutAfterglow() throws { + let now = Date(timeIntervalSince1970: 1_000) + let noiseKey = Data(repeating: 0x55, count: 32) + let peerID = PeerID(publicKey: noiseKey) + let packet = try makeAnnouncePacket( + noisePublicKey: noiseKey, + peerID: peerID, + timestamp: timestamp(now), + signature: Data(repeating: 0xEE, count: 64) + ) + + let recorder = Recorder() + recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: true, previousNickname: "Alice") + let handler = makeHandler(recorder: recorder, now: now) + + handler.handle(packet, from: peerID) + + #expect(recorder.uiEventDeliveries.count == 1) + #expect(recorder.uiEventDeliveries.first?.peerID == peerID) + #expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == true) + #expect(recorder.uiEventDeliveries.first?.scheduleInitialSync == true) + #expect(recorder.reconnectLogQueries == [peerID]) + #expect(recorder.afterglowDelays.isEmpty) + } + + @Test + func relayedNewPeerSchedulesAfterglowWithoutConnectNotify() throws { + let now = Date(timeIntervalSince1970: 1_000) + let noiseKey = Data(repeating: 0x66, count: 32) + let peerID = PeerID(publicKey: noiseKey) + let packet = try makeAnnouncePacket( + noisePublicKey: noiseKey, + peerID: peerID, + timestamp: timestamp(now), + signature: Data(repeating: 0xEE, count: 64), + ttl: TransportConfig.messageTTLDefault - 1 + ) + + let recorder = Recorder() + recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil) + let handler = makeHandler(recorder: recorder, now: now) + + handler.handle(packet, from: peerID) + + #expect(recorder.upsertCalls.count == 1) + #expect(recorder.upsertCalls.first?.isConnected == false) + #expect(recorder.uiEventDeliveries.count == 1) + #expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false) + #expect(recorder.uiEventDeliveries.first?.scheduleInitialSync == false) + #expect(recorder.afterglowDelays.count == 1) + } + + @Test + func announceBackIsSkippedWhenAlreadyMarked() throws { + let now = Date(timeIntervalSince1970: 1_000) + let noiseKey = Data(repeating: 0x77, count: 32) + let peerID = PeerID(publicKey: noiseKey) + let packet = try makeAnnouncePacket( + noisePublicKey: noiseKey, + peerID: peerID, + timestamp: timestamp(now), + signature: Data(repeating: 0xEE, count: 64) + ) + + let recorder = Recorder() + recorder.dedupSeenIDs = ["announce-back-\(peerID)"] + let handler = makeHandler(recorder: recorder, now: now) + + handler.handle(packet, from: peerID) + + #expect(recorder.dedupContainsQueries == ["announce-back-\(peerID)"]) + #expect(recorder.dedupMarkedIDs.isEmpty) + #expect(recorder.announceBacks == 0) + } + + @Test + func verifiedAnnounceWithNeighborsUpdatesTopology() throws { + let now = Date(timeIntervalSince1970: 1_000) + let noiseKey = Data(repeating: 0x88, count: 32) + let peerID = PeerID(publicKey: noiseKey) + let neighbors = [Data(repeating: 0xAB, count: 8), Data(repeating: 0xCD, count: 8)] + let packet = try makeAnnouncePacket( + noisePublicKey: noiseKey, + peerID: peerID, + timestamp: timestamp(now), + signature: Data(repeating: 0xEE, count: 64), + directNeighbors: neighbors + ) + + let recorder = Recorder() + let handler = makeHandler(recorder: recorder, now: now) + + handler.handle(packet, from: peerID) + + #expect(recorder.topologyUpdates.count == 1) + #expect(recorder.topologyUpdates.first?.peerID == peerID) + #expect(recorder.topologyUpdates.first?.neighbors == neighbors) + } + + @Test + func keyMismatchWithExistingPeerKeepsAnnounceUnverified() throws { + let now = Date(timeIntervalSince1970: 1_000) + let noiseKey = Data(repeating: 0x99, count: 32) + let peerID = PeerID(publicKey: noiseKey) + let packet = try makeAnnouncePacket( + noisePublicKey: noiseKey, + peerID: peerID, + timestamp: timestamp(now), + signature: Data(repeating: 0xEE, count: 64) + ) + + let recorder = Recorder() + recorder.existingNoisePublicKey = Data(repeating: 0xAA, count: 32) + let handler = makeHandler(recorder: recorder, now: now) + + handler.handle(packet, from: peerID) + + #expect(recorder.upsertCalls.isEmpty) + #expect(recorder.uiEventDeliveries.count == 1) + #expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false) + } + + private func expectNoSideEffects(_ recorder: Recorder) { + #expect(recorder.barrierCount == 0) + #expect(recorder.upsertCalls.isEmpty) + #expect(recorder.topologyUpdates.isEmpty) + #expect(recorder.persistedIdentities.isEmpty) + #expect(recorder.dedupMarkedIDs.isEmpty) + #expect(recorder.uiEventDeliveries.isEmpty) + #expect(recorder.trackedPackets.isEmpty) + #expect(recorder.announceBacks == 0) + #expect(recorder.afterglowDelays.isEmpty) + } + + private func makeAnnouncePacket( + noisePublicKey: Data, + peerID: PeerID, + timestamp: UInt64, + signature: Data?, + ttl: UInt8 = TransportConfig.messageTTLDefault, + directNeighbors: [Data]? = nil + ) throws -> BitchatPacket { + let announcement = AnnouncementPacket( + nickname: "Alice", + noisePublicKey: noisePublicKey, + signingPublicKey: Data(repeating: 0x99, count: 32), + directNeighbors: directNeighbors + ) + let payload = try #require(announcement.encode()) + + return BitchatPacket( + type: MessageType.announce.rawValue, + senderID: Data(hexString: peerID.id) ?? Data(), + recipientID: nil, + timestamp: timestamp, + payload: payload, + signature: signature, + ttl: ttl + ) + } + + private func timestamp(_ date: Date) -> UInt64 { + UInt64(date.timeIntervalSince1970 * 1000) + } +} diff --git a/bitchatTests/Services/BLEFileTransferHandlerTests.swift b/bitchatTests/Services/BLEFileTransferHandlerTests.swift new file mode 100644 index 00000000..518c97c0 --- /dev/null +++ b/bitchatTests/Services/BLEFileTransferHandlerTests.swift @@ -0,0 +1,267 @@ +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +struct BLEFileTransferHandlerTests { + private final class Recorder { + var localNickname = "Me" + var peers: [PeerID: BLEPeerInfo] = [:] + var signedName: String? + var saveResult: URL? = URL(fileURLWithPath: "/tmp/files/incoming/sample.pdf") + + var signedNameQueries: [PeerID] = [] + var trackedPackets: [BitchatPacket] = [] + var quotaReservations: [Int] = [] + var saveCalls: [(data: Data, preferredName: String?, subdirectory: String, fallbackExtension: String?, defaultPrefix: String)] = [] + var lastSeenUpdates: [PeerID] = [] + var deliveredMessages: [BitchatMessage] = [] + } + + private let localPeerID = PeerID(str: "0102030405060708") + private let remotePeerID = PeerID(str: "1122334455667788") + + private func makeHandler(recorder: Recorder) -> BLEFileTransferHandler { + let environment = BLEFileTransferHandlerEnvironment( + localPeerID: { [localPeerID] in localPeerID }, + localNickname: { recorder.localNickname }, + peersSnapshot: { recorder.peers }, + signedSenderDisplayName: { _, peerID in + recorder.signedNameQueries.append(peerID) + return recorder.signedName + }, + trackPacketSeen: { packet in + recorder.trackedPackets.append(packet) + }, + enforceStorageQuota: { reservingBytes in + recorder.quotaReservations.append(reservingBytes) + }, + saveIncomingFile: { data, preferredName, subdirectory, fallbackExtension, defaultPrefix in + recorder.saveCalls.append((data, preferredName, subdirectory, fallbackExtension, defaultPrefix)) + return recorder.saveResult + }, + updatePeerLastSeen: { peerID in + recorder.lastSeenUpdates.append(peerID) + }, + deliverMessage: { message in + recorder.deliveredMessages.append(message) + } + ) + return BLEFileTransferHandler(environment: environment) + } + + @Test + func broadcastFileFromVerifiedPeerIsSavedAndDelivered() throws { + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] + let handler = makeHandler(recorder: recorder) + let content = Data("%PDF-1.7".utf8) + let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: content) + + handler.handle(packet, from: remotePeerID) + + #expect(recorder.signedNameQueries.isEmpty) + #expect(recorder.trackedPackets.count == 1) + #expect(recorder.quotaReservations == [content.count]) + #expect(recorder.saveCalls.count == 1) + #expect(recorder.saveCalls.first?.data == content) + #expect(recorder.saveCalls.first?.preferredName == "sample") + #expect(recorder.saveCalls.first?.subdirectory == "files/incoming") + #expect(recorder.saveCalls.first?.fallbackExtension == "pdf") + #expect(recorder.saveCalls.first?.defaultPrefix == "file") + #expect(recorder.lastSeenUpdates.isEmpty) + #expect(recorder.deliveredMessages.count == 1) + let message = recorder.deliveredMessages.first + #expect(message?.sender == "Alice") + #expect(message?.content == "[file] sample.pdf") + #expect(message?.isPrivate == false) + #expect(message?.senderPeerID == remotePeerID) + #expect(message?.timestamp == Date(timeIntervalSince1970: 900)) + } + + @Test + func selfEchoIsDropped() throws { + let recorder = Recorder() + let handler = makeHandler(recorder: recorder) + let packet = try makeFileTransferPacket(sender: localPeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8), ttl: 3) + + handler.handle(packet, from: localPeerID) + + expectNoSideEffects(recorder) + } + + @Test + func unknownPeerWithoutValidSignatureIsDropped() throws { + let recorder = Recorder() + let handler = makeHandler(recorder: recorder) + let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8)) + + handler.handle(packet, from: remotePeerID) + + #expect(recorder.signedNameQueries == [remotePeerID]) + #expect(recorder.trackedPackets.isEmpty) + #expect(recorder.deliveredMessages.isEmpty) + } + + @Test + func connectedUnverifiedPeerIsAccepted() throws { + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Bob", isVerified: false, isConnected: true)] + let handler = makeHandler(recorder: recorder) + let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8)) + + handler.handle(packet, from: remotePeerID) + + // Unlike public messages, file transfers accept connected-but-unverified peers. + #expect(recorder.signedNameQueries.isEmpty) + #expect(recorder.deliveredMessages.count == 1) + #expect(recorder.deliveredMessages.first?.sender == "Bob") + } + + @Test + func fileDirectedToAnotherPeerIsIgnored() throws { + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] + let handler = makeHandler(recorder: recorder) + let packet = try makeFileTransferPacket( + sender: remotePeerID, + mimeType: "application/pdf", + content: Data("%PDF-1.7".utf8), + recipientID: Data(hexString: "AABBCCDDEEFF0011") + ) + + handler.handle(packet, from: remotePeerID) + + #expect(recorder.trackedPackets.isEmpty) + #expect(recorder.quotaReservations.isEmpty) + #expect(recorder.saveCalls.isEmpty) + #expect(recorder.deliveredMessages.isEmpty) + } + + @Test + func privateFileUpdatesLastSeenAndDeliversPrivateMessage() throws { + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] + let handler = makeHandler(recorder: recorder) + let packet = try makeFileTransferPacket( + sender: remotePeerID, + mimeType: "application/pdf", + content: Data("%PDF-1.7".utf8), + recipientID: Data(hexString: localPeerID.id) + ) + + handler.handle(packet, from: remotePeerID) + + // Directed transfers are not tracked for gossip sync. + #expect(recorder.trackedPackets.isEmpty) + #expect(recorder.lastSeenUpdates == [remotePeerID]) + #expect(recorder.deliveredMessages.count == 1) + #expect(recorder.deliveredMessages.first?.isPrivate == true) + } + + @Test + func malformedPayloadIsTrackedForSyncButDropped() { + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] + let handler = makeHandler(recorder: recorder) + let packet = BitchatPacket( + type: MessageType.fileTransfer.rawValue, + senderID: Data(hexString: remotePeerID.id) ?? Data(), + recipientID: nil, + timestamp: 900_000, + payload: Data([0x01, 0x02, 0x03]), + signature: nil, + ttl: TransportConfig.messageTTLDefault + ) + + handler.handle(packet, from: remotePeerID) + + // Sync tracking happens before payload validation, matching the original order. + #expect(recorder.trackedPackets.count == 1) + #expect(recorder.quotaReservations.isEmpty) + #expect(recorder.saveCalls.isEmpty) + #expect(recorder.deliveredMessages.isEmpty) + } + + @Test + func unsupportedMimeIsDroppedBeforeQuotaAndSave() throws { + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] + let handler = makeHandler(recorder: recorder) + let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: nil, content: Data([0x4D, 0x5A, 0x00, 0x00])) + + handler.handle(packet, from: remotePeerID) + + #expect(recorder.trackedPackets.count == 1) + #expect(recorder.quotaReservations.isEmpty) + #expect(recorder.saveCalls.isEmpty) + #expect(recorder.deliveredMessages.isEmpty) + } + + @Test + func saveFailureSkipsDelivery() throws { + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] + recorder.saveResult = nil + let handler = makeHandler(recorder: recorder) + let packet = try makeFileTransferPacket(sender: remotePeerID, mimeType: "application/pdf", content: Data("%PDF-1.7".utf8)) + + handler.handle(packet, from: remotePeerID) + + #expect(recorder.quotaReservations.count == 1) + #expect(recorder.saveCalls.count == 1) + #expect(recorder.lastSeenUpdates.isEmpty) + #expect(recorder.deliveredMessages.isEmpty) + } + + private func expectNoSideEffects(_ recorder: Recorder) { + #expect(recorder.signedNameQueries.isEmpty) + #expect(recorder.trackedPackets.isEmpty) + #expect(recorder.quotaReservations.isEmpty) + #expect(recorder.saveCalls.isEmpty) + #expect(recorder.lastSeenUpdates.isEmpty) + #expect(recorder.deliveredMessages.isEmpty) + } + + private func makePeerInfo( + _ peerID: PeerID, + nickname: String, + isVerified: Bool, + isConnected: Bool = true + ) -> BLEPeerInfo { + BLEPeerInfo( + peerID: peerID, + nickname: nickname, + isConnected: isConnected, + noisePublicKey: nil, + signingPublicKey: nil, + isVerifiedNickname: isVerified, + lastSeen: Date(timeIntervalSince1970: 999) + ) + } + + private func makeFileTransferPacket( + sender: PeerID, + mimeType: String?, + content: Data, + ttl: UInt8 = TransportConfig.messageTTLDefault, + recipientID: Data? = nil + ) throws -> BitchatPacket { + let filePacket = BitchatFilePacket( + fileName: "sample", + fileSize: UInt64(content.count), + mimeType: mimeType, + content: content + ) + let payload = try #require(filePacket.encode()) + return BitchatPacket( + type: MessageType.fileTransfer.rawValue, + senderID: Data(hexString: sender.id) ?? Data(), + recipientID: recipientID, + timestamp: 900_000, + payload: payload, + signature: nil, + ttl: ttl + ) + } +} diff --git a/bitchatTests/Services/BLEFragmentHandlerTests.swift b/bitchatTests/Services/BLEFragmentHandlerTests.swift new file mode 100644 index 00000000..d3694be0 --- /dev/null +++ b/bitchatTests/Services/BLEFragmentHandlerTests.swift @@ -0,0 +1,227 @@ +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +struct BLEFragmentHandlerTests { + private final class Recorder { + /// Optional override for the assembly result; defaults to `.stored`. + var appendResult: ((BLEFragmentHeader) -> BLEFragmentAssemblyBuffer.AppendResult)? + var accepted = true + + var trackedPackets: [BitchatPacket] = [] + var appendedHeaders: [BLEFragmentHeader] = [] + var ingressChecks: [(packet: BitchatPacket, innerSender: PeerID)] = [] + var reinjectedPackets: [(packet: BitchatPacket, from: PeerID)] = [] + } + + private let localPeerID = PeerID(str: "0102030405060708") + private let remotePeerID = PeerID(str: "1122334455667788") + + private func makeHandler(recorder: Recorder) -> BLEFragmentHandler { + let environment = BLEFragmentHandlerEnvironment( + localPeerID: { [localPeerID] in localPeerID }, + trackPacketSeen: { packet in + recorder.trackedPackets.append(packet) + }, + appendFragment: { header in + recorder.appendedHeaders.append(header) + return recorder.appendResult?(header) ?? .stored(header: header, started: true) + }, + isAcceptedIngressPayload: { packet, innerSender in + recorder.ingressChecks.append((packet, innerSender)) + return recorder.accepted + }, + processReassembledPacket: { packet, from in + recorder.reinjectedPackets.append((packet, from)) + } + ) + return BLEFragmentHandler(environment: environment) + } + + @Test + func ownFragmentIsIgnored() { + let recorder = Recorder() + let handler = makeHandler(recorder: recorder) + let packet = makeFragmentPacket(sender: localPeerID, index: 0, total: 2) + + handler.handle(packet, from: localPeerID) + + #expect(recorder.trackedPackets.isEmpty) + #expect(recorder.appendedHeaders.isEmpty) + #expect(recorder.reinjectedPackets.isEmpty) + } + + @Test + func malformedFragmentPayloadIsIgnored() { + let recorder = Recorder() + let handler = makeHandler(recorder: recorder) + let packet = BitchatPacket( + type: MessageType.fragment.rawValue, + senderID: Data(hexString: remotePeerID.id) ?? Data(), + recipientID: nil, + timestamp: 900_000, + payload: Data([0x01, 0x02, 0x03]), + signature: nil, + ttl: TransportConfig.messageTTLDefault + ) + + handler.handle(packet, from: remotePeerID) + + #expect(recorder.trackedPackets.isEmpty) + #expect(recorder.appendedHeaders.isEmpty) + } + + @Test + func broadcastFragmentIsTrackedAndAppended() { + let recorder = Recorder() + let handler = makeHandler(recorder: recorder) + let chunk = Data([0xDE, 0xAD, 0xBE, 0xEF]) + let packet = makeFragmentPacket(sender: remotePeerID, index: 0, total: 3, chunk: chunk) + + handler.handle(packet, from: remotePeerID) + + #expect(recorder.trackedPackets.count == 1) + #expect(recorder.appendedHeaders.count == 1) + #expect(recorder.appendedHeaders.first?.index == 0) + #expect(recorder.appendedHeaders.first?.total == 3) + #expect(recorder.appendedHeaders.first?.originalType == MessageType.message.rawValue) + #expect(recorder.appendedHeaders.first?.fragmentData == chunk) + #expect(recorder.ingressChecks.isEmpty) + #expect(recorder.reinjectedPackets.isEmpty) + } + + @Test + func directedFragmentIsNotTrackedForSync() { + let recorder = Recorder() + let handler = makeHandler(recorder: recorder) + let packet = makeFragmentPacket( + sender: remotePeerID, + index: 1, + total: 3, + recipientID: Data(hexString: localPeerID.id) + ) + + handler.handle(packet, from: remotePeerID) + + #expect(recorder.trackedPackets.isEmpty) + #expect(recorder.appendedHeaders.count == 1) + } + + @Test + func completedReassemblyReinjectsAcceptedPacketWithZeroTTL() throws { + let innerSender = PeerID(str: "99AABBCCDDEEFF00") + let innerPacket = BitchatPacket( + type: MessageType.message.rawValue, + senderID: Data(hexString: innerSender.id) ?? Data(), + recipientID: nil, + timestamp: 900_000, + payload: Data("hello".utf8), + signature: nil, + ttl: 5 + ) + let reassembled = try #require(innerPacket.toBinaryData()) + + let recorder = Recorder() + recorder.appendResult = { header in + .complete(header: header, reassembledData: reassembled, started: false) + } + let handler = makeHandler(recorder: recorder) + let packet = makeFragmentPacket(sender: remotePeerID, index: 1, total: 2) + + handler.handle(packet, from: remotePeerID) + + #expect(recorder.ingressChecks.count == 1) + #expect(recorder.ingressChecks.first?.innerSender == innerSender) + #expect(recorder.reinjectedPackets.count == 1) + let reinjected = recorder.reinjectedPackets.first + #expect(reinjected?.from == remotePeerID) + #expect(reinjected?.packet.type == MessageType.message.rawValue) + #expect(reinjected?.packet.payload == Data("hello".utf8)) + // Reassembled packets must re-enter the pipeline with TTL zeroed. + #expect(reinjected?.packet.ttl == 0) + } + + @Test + func rejectedReassembledPacketIsNotReinjected() throws { + let innerPacket = BitchatPacket( + type: MessageType.message.rawValue, + senderID: Data(hexString: "99AABBCCDDEEFF00") ?? Data(), + recipientID: nil, + timestamp: 900_000, + payload: Data("hello".utf8), + signature: nil, + ttl: 5 + ) + let reassembled = try #require(innerPacket.toBinaryData()) + + let recorder = Recorder() + recorder.accepted = false + recorder.appendResult = { header in + .complete(header: header, reassembledData: reassembled, started: false) + } + let handler = makeHandler(recorder: recorder) + let packet = makeFragmentPacket(sender: remotePeerID, index: 1, total: 2) + + handler.handle(packet, from: remotePeerID) + + #expect(recorder.ingressChecks.count == 1) + #expect(recorder.reinjectedPackets.isEmpty) + } + + @Test + func undecodableReassembledDataIsDropped() { + let recorder = Recorder() + recorder.appendResult = { header in + .complete(header: header, reassembledData: Data([0x00, 0x01]), started: false) + } + let handler = makeHandler(recorder: recorder) + let packet = makeFragmentPacket(sender: remotePeerID, index: 1, total: 2) + + handler.handle(packet, from: remotePeerID) + + #expect(recorder.ingressChecks.isEmpty) + #expect(recorder.reinjectedPackets.isEmpty) + } + + @Test + func incompleteAssemblyDoesNotReinject() { + let recorder = Recorder() + let handler = makeHandler(recorder: recorder) + let packet = makeFragmentPacket(sender: remotePeerID, index: 0, total: 2) + + handler.handle(packet, from: remotePeerID) + + #expect(recorder.appendedHeaders.count == 1) + #expect(recorder.ingressChecks.isEmpty) + #expect(recorder.reinjectedPackets.isEmpty) + } + + private func makeFragmentPacket( + sender: PeerID, + index: UInt16, + total: UInt16, + originalType: UInt8 = MessageType.message.rawValue, + chunk: Data = Data([0xAA, 0xBB]), + recipientID: Data? = nil + ) -> BitchatPacket { + var payload = Data() + payload.append(contentsOf: [0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77]) // fragment ID + payload.append(UInt8(index >> 8)) + payload.append(UInt8(index & 0xFF)) + payload.append(UInt8(total >> 8)) + payload.append(UInt8(total & 0xFF)) + payload.append(originalType) + payload.append(chunk) + + return BitchatPacket( + type: MessageType.fragment.rawValue, + senderID: Data(hexString: sender.id) ?? Data(), + recipientID: recipientID, + timestamp: 900_000, + payload: payload, + signature: nil, + ttl: TransportConfig.messageTTLDefault + ) + } +} diff --git a/bitchatTests/Services/BLENoisePacketHandlerTests.swift b/bitchatTests/Services/BLENoisePacketHandlerTests.swift new file mode 100644 index 00000000..0f40d316 --- /dev/null +++ b/bitchatTests/Services/BLENoisePacketHandlerTests.swift @@ -0,0 +1,310 @@ +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +struct BLENoisePacketHandlerTests { + private struct TestError: Error {} + + private final class Recorder { + var handshakeResult: Result = .success(nil) + var hasSession = false + var decryptResult: Result = .success(Data()) + + var processedHandshakes: [(peerID: PeerID, message: Data)] = [] + var hasSessionQueries: [PeerID] = [] + var initiatedHandshakes: [PeerID] = [] + var broadcastPackets: [BitchatPacket] = [] + var lastSeenUpdates: [PeerID] = [] + var decryptCalls: [(payload: Data, peerID: PeerID)] = [] + var clearedSessions: [PeerID] = [] + var deliveries: [(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)] = [] + /// Ordered side-effect log to assert recovery sequencing. + var events: [String] = [] + } + + private let localPeerID = PeerID(str: "0102030405060708") + private let remotePeerID = PeerID(str: "1122334455667788") + private let localPeerIDData = Data(hexString: "0102030405060708") ?? Data() + + private func makeHandler( + recorder: Recorder, + now: Date = Date(timeIntervalSince1970: 1_000) + ) -> BLENoisePacketHandler { + let environment = BLENoisePacketHandlerEnvironment( + localPeerID: { [localPeerID] in localPeerID }, + localPeerIDData: { [localPeerIDData] in localPeerIDData }, + messageTTL: TransportConfig.messageTTLDefault, + now: { now }, + processHandshakeMessage: { peerID, message in + recorder.processedHandshakes.append((peerID, message)) + return try recorder.handshakeResult.get() + }, + hasNoiseSession: { peerID in + recorder.hasSessionQueries.append(peerID) + return recorder.hasSession + }, + initiateHandshake: { peerID in + recorder.initiatedHandshakes.append(peerID) + recorder.events.append("initiateHandshake") + }, + broadcastPacket: { packet in + recorder.broadcastPackets.append(packet) + }, + updatePeerLastSeen: { peerID in + recorder.lastSeenUpdates.append(peerID) + }, + decrypt: { payload, peerID in + recorder.decryptCalls.append((payload, peerID)) + return try recorder.decryptResult.get() + }, + clearSession: { peerID in + recorder.clearedSessions.append(peerID) + recorder.events.append("clearSession") + }, + deliverNoisePayload: { peerID, type, payload, timestamp in + recorder.deliveries.append((peerID, type, payload, timestamp)) + } + ) + return BLENoisePacketHandler(environment: environment) + } + + // MARK: Handshake + + @Test + func handshakeForUsBroadcastsResponsePacket() { + let now = Date(timeIntervalSince1970: 1_000) + let recorder = Recorder() + recorder.handshakeResult = .success(Data([0xAA, 0xBB])) + let handler = makeHandler(recorder: recorder, now: now) + let packet = makeHandshakePacket(recipientID: Data(hexString: localPeerID.id)) + + handler.handleHandshake(packet, from: remotePeerID) + + #expect(recorder.processedHandshakes.count == 1) + #expect(recorder.processedHandshakes.first?.peerID == remotePeerID) + #expect(recorder.processedHandshakes.first?.message == packet.payload) + #expect(recorder.broadcastPackets.count == 1) + let response = recorder.broadcastPackets.first + #expect(response?.type == MessageType.noiseHandshake.rawValue) + #expect(response?.senderID == localPeerIDData) + #expect(response?.recipientID == Data(hexString: remotePeerID.id)) + #expect(response?.payload == Data([0xAA, 0xBB])) + #expect(response?.signature == nil) + #expect(response?.ttl == TransportConfig.messageTTLDefault) + #expect(response?.timestamp == UInt64(now.timeIntervalSince1970 * 1000)) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func handshakeWithoutResponseDoesNotBroadcast() { + let recorder = Recorder() + recorder.handshakeResult = .success(nil) + let handler = makeHandler(recorder: recorder) + let packet = makeHandshakePacket(recipientID: Data(hexString: localPeerID.id)) + + handler.handleHandshake(packet, from: remotePeerID) + + #expect(recorder.processedHandshakes.count == 1) + #expect(recorder.broadcastPackets.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func handshakeForAnotherPeerIsIgnored() { + let recorder = Recorder() + let handler = makeHandler(recorder: recorder) + let packet = makeHandshakePacket(recipientID: Data(hexString: remotePeerID.id)) + + handler.handleHandshake(packet, from: remotePeerID) + + #expect(recorder.processedHandshakes.isEmpty) + #expect(recorder.broadcastPackets.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func handshakeFailureInitiatesNewHandshakeWhenNoSession() { + let recorder = Recorder() + recorder.handshakeResult = .failure(TestError()) + recorder.hasSession = false + let handler = makeHandler(recorder: recorder) + let packet = makeHandshakePacket(recipientID: Data(hexString: localPeerID.id)) + + handler.handleHandshake(packet, from: remotePeerID) + + #expect(recorder.hasSessionQueries == [remotePeerID]) + #expect(recorder.initiatedHandshakes == [remotePeerID]) + #expect(recorder.broadcastPackets.isEmpty) + } + + @Test + func handshakeFailureSkipsInitiateWhenSessionExists() { + let recorder = Recorder() + recorder.handshakeResult = .failure(TestError()) + recorder.hasSession = true + let handler = makeHandler(recorder: recorder) + let packet = makeHandshakePacket(recipientID: Data(hexString: localPeerID.id)) + + handler.handleHandshake(packet, from: remotePeerID) + + #expect(recorder.hasSessionQueries == [remotePeerID]) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + // MARK: Encrypted + + @Test + func encryptedWithoutRecipientIsDropped() { + let recorder = Recorder() + let handler = makeHandler(recorder: recorder) + let packet = makeEncryptedPacket(recipientID: nil) + + handler.handleEncrypted(packet, from: remotePeerID) + + #expect(recorder.lastSeenUpdates.isEmpty) + #expect(recorder.decryptCalls.isEmpty) + #expect(recorder.deliveries.isEmpty) + } + + @Test + func encryptedForAnotherPeerIsDropped() { + let recorder = Recorder() + let handler = makeHandler(recorder: recorder) + let packet = makeEncryptedPacket(recipientID: Data(hexString: remotePeerID.id)) + + handler.handleEncrypted(packet, from: remotePeerID) + + #expect(recorder.lastSeenUpdates.isEmpty) + #expect(recorder.decryptCalls.isEmpty) + #expect(recorder.deliveries.isEmpty) + } + + @Test + func decryptedPayloadIsDeliveredWithTypeAndTimestamp() { + let now = Date(timeIntervalSince1970: 1_000) + let recorder = Recorder() + recorder.decryptResult = .success(Data([NoisePayloadType.privateMessage.rawValue, 0x01, 0x02, 0x03])) + let handler = makeHandler(recorder: recorder, now: now) + let sentAt = Date(timeIntervalSince1970: 900) + let packet = makeEncryptedPacket( + recipientID: Data(hexString: localPeerID.id), + timestamp: UInt64(sentAt.timeIntervalSince1970 * 1000) + ) + + handler.handleEncrypted(packet, from: remotePeerID) + + #expect(recorder.lastSeenUpdates == [remotePeerID]) + #expect(recorder.decryptCalls.count == 1) + #expect(recorder.decryptCalls.first?.payload == packet.payload) + #expect(recorder.deliveries.count == 1) + #expect(recorder.deliveries.first?.peerID == remotePeerID) + #expect(recorder.deliveries.first?.type == .privateMessage) + #expect(recorder.deliveries.first?.payload == Data([0x01, 0x02, 0x03])) + #expect(recorder.deliveries.first?.timestamp == sentAt) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.initiatedHandshakes.isEmpty) + } + + @Test + func emptyDecryptedPayloadIsIgnored() { + let recorder = Recorder() + recorder.decryptResult = .success(Data()) + let handler = makeHandler(recorder: recorder) + let packet = makeEncryptedPacket(recipientID: Data(hexString: localPeerID.id)) + + handler.handleEncrypted(packet, from: remotePeerID) + + #expect(recorder.decryptCalls.count == 1) + #expect(recorder.deliveries.isEmpty) + #expect(recorder.clearedSessions.isEmpty) + } + + @Test + func unknownNoisePayloadTypeIsIgnored() { + let recorder = Recorder() + recorder.decryptResult = .success(Data([0xEE, 0x01])) + let handler = makeHandler(recorder: recorder) + let packet = makeEncryptedPacket(recipientID: Data(hexString: localPeerID.id)) + + handler.handleEncrypted(packet, from: remotePeerID) + + #expect(recorder.decryptCalls.count == 1) + #expect(recorder.deliveries.isEmpty) + } + + @Test + func missingSessionInitiatesHandshakeWithoutClearing() { + let recorder = Recorder() + recorder.decryptResult = .failure(NoiseEncryptionError.sessionNotEstablished) + recorder.hasSession = false + let handler = makeHandler(recorder: recorder) + let packet = makeEncryptedPacket(recipientID: Data(hexString: localPeerID.id)) + + handler.handleEncrypted(packet, from: remotePeerID) + + #expect(recorder.hasSessionQueries == [remotePeerID]) + #expect(recorder.initiatedHandshakes == [remotePeerID]) + #expect(recorder.clearedSessions.isEmpty) + #expect(recorder.deliveries.isEmpty) + } + + @Test + func missingSessionSkipsInitiateWhenSessionAppeared() { + let recorder = Recorder() + recorder.decryptResult = .failure(NoiseEncryptionError.sessionNotEstablished) + recorder.hasSession = true + let handler = makeHandler(recorder: recorder) + let packet = makeEncryptedPacket(recipientID: Data(hexString: localPeerID.id)) + + handler.handleEncrypted(packet, from: remotePeerID) + + #expect(recorder.initiatedHandshakes.isEmpty) + #expect(recorder.clearedSessions.isEmpty) + } + + @Test + func decryptFailureClearsSessionThenReinitiatesHandshake() { + let recorder = Recorder() + recorder.decryptResult = .failure(TestError()) + // Even with a live session, recovery clears it and re-initiates unconditionally. + recorder.hasSession = true + let handler = makeHandler(recorder: recorder) + let packet = makeEncryptedPacket(recipientID: Data(hexString: localPeerID.id)) + + handler.handleEncrypted(packet, from: remotePeerID) + + #expect(recorder.clearedSessions == [remotePeerID]) + #expect(recorder.initiatedHandshakes == [remotePeerID]) + // Session-recovery order must stay clear → re-initiate. + #expect(recorder.events == ["clearSession", "initiateHandshake"]) + #expect(recorder.deliveries.isEmpty) + } + + private func makeHandshakePacket(recipientID: Data?) -> BitchatPacket { + BitchatPacket( + type: MessageType.noiseHandshake.rawValue, + senderID: Data(hexString: remotePeerID.id) ?? Data(), + recipientID: recipientID, + timestamp: 900_000, + payload: Data([0x01, 0x02, 0x03]), + signature: nil, + ttl: TransportConfig.messageTTLDefault + ) + } + + private func makeEncryptedPacket( + recipientID: Data?, + timestamp: UInt64 = 900_000 + ) -> BitchatPacket { + BitchatPacket( + type: MessageType.noiseEncrypted.rawValue, + senderID: Data(hexString: remotePeerID.id) ?? Data(), + recipientID: recipientID, + timestamp: timestamp, + payload: Data([0xC0, 0xFF, 0xEE]), + signature: nil, + ttl: TransportConfig.messageTTLDefault + ) + } +} diff --git a/bitchatTests/Services/BLEPublicMessageHandlerTests.swift b/bitchatTests/Services/BLEPublicMessageHandlerTests.swift new file mode 100644 index 00000000..d669f4d4 --- /dev/null +++ b/bitchatTests/Services/BLEPublicMessageHandlerTests.swift @@ -0,0 +1,251 @@ +import BitFoundation +import Foundation +import Testing +@testable import bitchat + +struct BLEPublicMessageHandlerTests { + private final class Recorder { + var localNickname = "Me" + var peers: [PeerID: BLEPeerInfo] = [:] + var signedName: String? + var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false) + var selfBroadcastMessageID: String? + + var peersSnapshotReads = 0 + var signedNameQueries: [PeerID] = [] + var trackedPackets: [BitchatPacket] = [] + var selfBroadcastTakes: [BitchatPacket] = [] + var deliveries: [(peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)] = [] + } + + private let localPeerID = PeerID(str: "0102030405060708") + private let remotePeerID = PeerID(str: "1122334455667788") + + private func makeHandler( + recorder: Recorder, + localPeerID: PeerID? = nil, + now: Date = Date(timeIntervalSince1970: 1_000) + ) -> BLEPublicMessageHandler { + let resolvedLocalPeerID = localPeerID ?? self.localPeerID + let environment = BLEPublicMessageHandlerEnvironment( + localPeerID: { resolvedLocalPeerID }, + localNickname: { recorder.localNickname }, + now: { now }, + peersSnapshot: { + recorder.peersSnapshotReads += 1 + return recorder.peers + }, + signedSenderDisplayName: { _, peerID in + recorder.signedNameQueries.append(peerID) + return recorder.signedName + }, + trackPacketSeen: { packet in + recorder.trackedPackets.append(packet) + }, + linkState: { _ in recorder.linkState }, + takeSelfBroadcastMessageID: { packet in + recorder.selfBroadcastTakes.append(packet) + return recorder.selfBroadcastMessageID + }, + deliverPublicMessage: { peerID, nickname, content, timestamp, messageID in + recorder.deliveries.append((peerID, nickname, content, timestamp, messageID)) + } + ) + return BLEPublicMessageHandler(environment: environment) + } + + @Test + func verifiedPeerBroadcastIsTrackedAndDelivered() { + let now = Date(timeIntervalSince1970: 1_000) + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] + let handler = makeHandler(recorder: recorder, now: now) + let packet = makeMessagePacket(sender: remotePeerID, content: "hello mesh", timestamp: timestamp(now)) + + handler.handle(packet, from: remotePeerID) + + #expect(recorder.peersSnapshotReads == 1) + #expect(recorder.signedNameQueries.isEmpty) + #expect(recorder.trackedPackets.count == 1) + #expect(recorder.selfBroadcastTakes.isEmpty) + #expect(recorder.deliveries.count == 1) + #expect(recorder.deliveries.first?.peerID == remotePeerID) + #expect(recorder.deliveries.first?.nickname == "Alice") + #expect(recorder.deliveries.first?.content == "hello mesh") + #expect(recorder.deliveries.first?.timestamp == now) + #expect(recorder.deliveries.first?.messageID == nil) + } + + @Test + func selfEchoIsDropped() { + let now = Date(timeIntervalSince1970: 1_000) + let recorder = Recorder() + let handler = makeHandler(recorder: recorder, now: now) + let packet = makeMessagePacket(sender: localPeerID, content: "echo", timestamp: timestamp(now), ttl: 3) + + handler.handle(packet, from: localPeerID) + + expectNoSideEffects(recorder) + } + + @Test + func staleBroadcastIsDropped() { + let now = Date(timeIntervalSince1970: 1_000) + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] + let handler = makeHandler(recorder: recorder, now: now) + let staleTimestamp = UInt64((now.timeIntervalSince1970 - 901) * 1000) + let packet = makeMessagePacket(sender: remotePeerID, content: "old", timestamp: staleTimestamp) + + handler.handle(packet, from: remotePeerID) + + expectNoSideEffects(recorder) + } + + @Test + func unknownPeerWithoutValidSignatureIsDroppedBeforeSyncTracking() { + let now = Date(timeIntervalSince1970: 1_000) + let recorder = Recorder() + let handler = makeHandler(recorder: recorder, now: now) + let packet = makeMessagePacket(sender: remotePeerID, content: "hi", timestamp: timestamp(now)) + + handler.handle(packet, from: remotePeerID) + + #expect(recorder.signedNameQueries == [remotePeerID]) + // The sender must resolve before the packet is tracked for sync. + #expect(recorder.trackedPackets.isEmpty) + #expect(recorder.deliveries.isEmpty) + } + + @Test + func connectedButUnverifiedPeerIsDropped() { + let now = Date(timeIntervalSince1970: 1_000) + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Mallory", isVerified: false, isConnected: true)] + let handler = makeHandler(recorder: recorder, now: now) + let packet = makeMessagePacket(sender: remotePeerID, content: "hi", timestamp: timestamp(now)) + + handler.handle(packet, from: remotePeerID) + + // Public messages never accept connected-but-unverified registry entries. + #expect(recorder.signedNameQueries == [remotePeerID]) + #expect(recorder.trackedPackets.isEmpty) + #expect(recorder.deliveries.isEmpty) + } + + @Test + func signedSenderFallbackDeliversWithSignedName() { + let now = Date(timeIntervalSince1970: 1_000) + let recorder = Recorder() + recorder.signedName = "SignedAlice" + let handler = makeHandler(recorder: recorder, now: now) + let packet = makeMessagePacket(sender: remotePeerID, content: "signed hello", timestamp: timestamp(now)) + + handler.handle(packet, from: remotePeerID) + + #expect(recorder.signedNameQueries == [remotePeerID]) + #expect(recorder.trackedPackets.count == 1) + #expect(recorder.deliveries.count == 1) + #expect(recorder.deliveries.first?.nickname == "SignedAlice") + } + + @Test + func invalidUTF8PayloadIsTrackedForSyncButNotDelivered() { + let now = Date(timeIntervalSince1970: 1_000) + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] + let handler = makeHandler(recorder: recorder, now: now) + let packet = makeMessagePacket(sender: remotePeerID, payload: Data([0xFF, 0xFE, 0xFD]), timestamp: timestamp(now)) + + handler.handle(packet, from: remotePeerID) + + // Sync tracking happens before payload decoding, matching the original order. + #expect(recorder.trackedPackets.count == 1) + #expect(recorder.deliveries.isEmpty) + } + + @Test + func selfSyncReplayResolvesOriginalMessageID() { + let now = Date(timeIntervalSince1970: 1_000) + let recorder = Recorder() + recorder.selfBroadcastMessageID = "original-id" + let handler = makeHandler(recorder: recorder, now: now) + // TTL 0 marks a sync replay of our own broadcast, not a self echo. + let packet = makeMessagePacket(sender: localPeerID, content: "mine", timestamp: timestamp(now), ttl: 0) + + handler.handle(packet, from: localPeerID) + + #expect(recorder.selfBroadcastTakes.count == 1) + #expect(recorder.deliveries.count == 1) + #expect(recorder.deliveries.first?.peerID == localPeerID) + #expect(recorder.deliveries.first?.nickname == recorder.localNickname) + #expect(recorder.deliveries.first?.messageID == "original-id") + } + + @Test + func directedMessageIsDeliveredWithoutSyncTracking() { + let now = Date(timeIntervalSince1970: 1_000) + let recorder = Recorder() + recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] + let handler = makeHandler(recorder: recorder, now: now) + let packet = makeMessagePacket( + sender: remotePeerID, + content: "direct", + timestamp: timestamp(now), + recipientID: Data(hexString: localPeerID.id) + ) + + handler.handle(packet, from: remotePeerID) + + #expect(recorder.trackedPackets.isEmpty) + #expect(recorder.deliveries.count == 1) + #expect(recorder.deliveries.first?.content == "direct") + } + + private func expectNoSideEffects(_ recorder: Recorder) { + #expect(recorder.signedNameQueries.isEmpty) + #expect(recorder.trackedPackets.isEmpty) + #expect(recorder.selfBroadcastTakes.isEmpty) + #expect(recorder.deliveries.isEmpty) + } + + private func makePeerInfo( + _ peerID: PeerID, + nickname: String, + isVerified: Bool, + isConnected: Bool = true + ) -> BLEPeerInfo { + BLEPeerInfo( + peerID: peerID, + nickname: nickname, + isConnected: isConnected, + noisePublicKey: nil, + signingPublicKey: nil, + isVerifiedNickname: isVerified, + lastSeen: Date(timeIntervalSince1970: 999) + ) + } + + private func makeMessagePacket( + sender: PeerID, + content: String? = nil, + payload: Data? = nil, + timestamp: UInt64, + ttl: UInt8 = TransportConfig.messageTTLDefault, + recipientID: Data? = nil + ) -> BitchatPacket { + BitchatPacket( + type: MessageType.message.rawValue, + senderID: Data(hexString: sender.id) ?? Data(), + recipientID: recipientID, + timestamp: timestamp, + payload: payload ?? Data((content ?? "").utf8), + signature: nil, + ttl: ttl + ) + } + + private func timestamp(_ date: Date) -> UInt64 { + UInt64(date.timeIntervalSince1970 * 1000) + } +}