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) + } +}