diff --git a/.github/workflows/arti-provenance.yml b/.github/workflows/arti-provenance.yml new file mode 100644 index 00000000..5d50564e --- /dev/null +++ b/.github/workflows/arti-provenance.yml @@ -0,0 +1,85 @@ +name: Arti Binary Provenance + +# The Arti xcframework is a vendored binary; these checks turn the policy in +# docs/ARTI-BINARY-PROVENANCE.md into an enforced gate: +# 1. The checked-in binary must match the hash manifest in the provenance doc. +# 2. A PR that changes the binary must also change at least one provenance +# input (Rust source, lockfile, build script, or the doc itself). + +on: + push: + branches: + - main + paths: + - "localPackages/Arti/**" + - "docs/ARTI-BINARY-PROVENANCE.md" + pull_request: + paths: + - "localPackages/Arti/**" + - "docs/ARTI-BINARY-PROVENANCE.md" + +jobs: + verify-hashes: + name: Verify xcframework hashes against provenance doc + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Compare artifact hashes with manifest + run: | + set -euo pipefail + doc="docs/ARTI-BINARY-PROVENANCE.md" + + # Extract the manifest: lines of " " from the doc. + grep -E '^[0-9a-f]{64} localPackages/Arti/Frameworks/arti\.xcframework/' "$doc" \ + | sort -k2 > expected.txt + + if [ ! -s expected.txt ]; then + echo "::error::No hash manifest found in $doc" + exit 1 + fi + + # Hash the same file set the doc documents. + find localPackages/Arti/Frameworks/arti.xcframework -maxdepth 3 -type f -print0 \ + | sort -z | xargs -0 sha256sum | sed 's/ \.\// /' | sort -k2 > actual.txt + + if ! diff -u expected.txt actual.txt; then + echo "::error::Checked-in arti.xcframework does not match the manifest in $doc. If the binary change is intentional, rebuild per the doc and update the manifest in the same PR." + exit 1 + fi + echo "All $(wc -l < actual.txt) artifact hashes match the provenance manifest." + + require-provenance-evidence: + name: Binary changes must ship with provenance inputs + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Check changed files + run: | + set -euo pipefail + base="origin/${{ github.base_ref }}" + git fetch --no-tags --depth=1 origin "${{ github.base_ref }}" + changed=$(git diff --name-only "$base"...HEAD) + echo "Changed files:" + echo "$changed" + + if ! echo "$changed" | grep -q '^localPackages/Arti/Frameworks/arti\.xcframework/'; then + echo "No binary artifact changes; nothing to verify." + exit 0 + fi + + if echo "$changed" | grep -Eq '^(localPackages/Arti/(Cargo\.(toml|lock)|build-ios\.sh|arti-bitchat/)|docs/ARTI-BINARY-PROVENANCE\.md)'; then + echo "Binary change is accompanied by provenance inputs." + exit 0 + fi + + echo "::error::arti.xcframework changed without matching source, lockfile, build-script, or provenance-doc changes. See docs/ARTI-BINARY-PROVENANCE.md (\"Do not accept an xcframework-only update\")." + exit 1 diff --git a/.github/workflows/swift-tests.yml b/.github/workflows/swift-tests.yml index a56b9030..846b16f7 100644 --- a/.github/workflows/swift-tests.yml +++ b/.github/workflows/swift-tests.yml @@ -40,3 +40,25 @@ jobs: - name: Run Tests run: swift test --parallel --quiet --package-path ${{ matrix.path }} + + # SPM tests above only compile the macOS slice; this job covers the + # iOS-conditional code paths (UIKit, CoreBluetooth restoration, etc.). + ios-build: + name: Build iOS app (simulator) + runs-on: macos-latest + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Build iOS (simulator, no signing) + # arm64 only: the vendored arti.xcframework has no x86_64 simulator slice. + run: | + set -o pipefail + xcodebuild -project bitchat.xcodeproj \ + -scheme "bitchat (iOS)" \ + -sdk iphonesimulator \ + -destination 'generic/platform=iOS Simulator' \ + ARCHS=arm64 \ + CODE_SIGNING_ALLOWED=NO \ + build diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md index e27866bb..03d770de 100644 --- a/PRIVACY_POLICY.md +++ b/PRIVACY_POLICY.md @@ -1,6 +1,6 @@ # bitchat Privacy Policy -*Last updated: January 2025* +*Last updated: June 2026* ## Our Commitment @@ -9,7 +9,7 @@ bitchat is designed with privacy as its foundation. We believe private communica ## Summary - **No personal data collection** - We don't collect names, emails, or phone numbers -- **No servers** - Everything happens on your device and through peer-to-peer connections +- **No accounts or company servers** - Mesh chat works peer-to-peer; optional Nostr features use public or user-selected relays - **No tracking** - We have no analytics, telemetry, or user tracking - **Open source** - You can verify these claims by reading our code @@ -17,11 +17,11 @@ bitchat is designed with privacy as its foundation. We believe private communica ### On Your Device Only -1. **Identity Key** - - A cryptographic key generated on first launch +1. **Identity Keys** + - Cryptographic private keys generated on first launch or when optional Nostr identities are created - Stored locally in your device's secure storage - Allows you to maintain "favorite" relationships across app restarts - - Never leaves your device + - Private keys never leave your device; public keys are shared when needed for messaging 2. **Nickname** - The display name you choose (or auto-generated) @@ -38,12 +38,19 @@ bitchat is designed with privacy as its foundation. We believe private communica - Stored only on your device - Allows you to recognize these peers in future sessions +5. **Optional Location Channel State** + - Your selected geohash channel, bookmarked geohashes, teleport flags, and bookmark display names + - Stored locally on your device so the location-channel UI can restore your choices + - Per-geohash Nostr identities are derived locally from a device seed stored in secure storage + - Exact latitude and longitude are not persisted by bitchat + ### Temporary Session Data During each session, bitchat temporarily maintains: - Active peer connections (forgotten when app closes) - Routing information for message delivery - Cached messages for offline peers (12 hours max) +- Your current location while optional location channels are enabled, used locally to compute geohash channels and friendly place names ## What Information is Shared @@ -62,13 +69,21 @@ When you join a password-protected room: - Your nickname appears in the member list - Room owners can see you've joined +### With Nostr Relays (Optional Features) + +If you enable Nostr-backed features: +- Private fallback messages to mutual favorites are sent as encrypted NIP-17 gift wraps. Relays can see event metadata, but not message content. +- Public location-channel messages, location notes, and presence are scoped with geohash tags. Relays and other participants can see the geohash tag, event kind, timestamp, and public key used for that geohash. +- Exact GPS coordinates are not included in Nostr events by bitchat. The geohash precision you choose can still reveal an approximate area, from region-level to building-level. +- Automatic presence heartbeats are limited to low-precision geohashes (region, province, and city). More precise geohash posts happen only when you use those channels or location notes. + ## What We DON'T Do bitchat **never**: - Collects personal information -- Tracks your location -- Stores data on servers -- Shares data with third parties +- Sells or shares your exact GPS location +- Stores data on servers we operate +- Sells your data to advertisers or data brokers - Uses analytics or telemetry - Creates user profiles - Requires registration @@ -84,19 +99,27 @@ All private messages use end-to-end encryption: ## Your Rights You have complete control: -- **Delete Everything**: Triple-tap the logo to instantly wipe all data -- **Leave Anytime**: Close the app and your presence disappears -- **No Account**: Nothing to delete from servers because there are none -- **Portability**: Your data never leaves your device unless you export it +- **Delete Local State**: Triple-tap the logo to instantly wipe local keys, sessions, caches, and preferences +- **Leave Anytime**: Close the app and local presence stops; relay-backed presence ages out +- **No Account**: No account record exists for you to delete from us +- **Portability**: Your local state stays on your device unless you send messages, use optional relay-backed features, or export it ## Bluetooth & Permissions bitchat requires Bluetooth permission to function: - Used only for peer-to-peer communication -- No location data is accessed or stored - Bluetooth is not used for tracking - You can revoke this permission at any time in system settings +## Location Permission + +Location permission is optional and is used only for location channels: +- Used to compute local geohash channels and display names +- Requested as when-in-use permission +- Exact coordinates are not shared in messages or stored by bitchat +- Selected and bookmarked geohashes may persist locally until you remove them, use panic wipe, or delete the app +- You can revoke this permission at any time in system settings + ## Children's Privacy bitchat does not knowingly collect information from children. The app has no age verification because it collects no personal information from anyone. @@ -106,12 +129,15 @@ bitchat does not knowingly collect information from children. The app has no age - **Messages**: Deleted from memory when app closes (unless room retention is enabled) - **Identity Key**: Persists until you delete the app - **Favorites**: Persist until you remove them or delete the app +- **Location channel choices**: Selected/bookmarked geohashes persist locally until removed, panic-wiped, or the app is deleted +- **Nostr relay data**: Public geohash events and encrypted gift wraps may be retained by relays according to each relay's policy - **Everything Else**: Exists only during active sessions ## Security Measures - All communication is encrypted -- No data transmitted to servers (there are none) +- No accounts or company servers +- Optional Nostr relays receive only the events needed for Nostr-backed private fallback or public location channels - Open source code for public audit - Regular security updates - Cryptographic signatures prevent tampering @@ -121,7 +147,7 @@ bitchat does not knowingly collect information from children. The app has no age If we update this policy: - The "Last updated" date will change - The updated policy will be included in the app -- No retroactive changes can affect data (since we don't collect any) +- No retroactive changes can make us collect data already held only in your app ## Contact @@ -132,7 +158,7 @@ bitchat is an open source project. For privacy questions: ## Philosophy -Privacy isn't just a feature—it's the entire point. bitchat proves that modern communication doesn't require surrendering your privacy. No accounts, no servers, no surveillance. Just people talking freely. +Privacy isn't just a feature—it's the entire point. bitchat proves that modern communication doesn't require surrendering your privacy. No accounts, no company servers, no analytics. Just people talking freely. --- 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/Localizable.xcstrings b/bitchat/Localizable.xcstrings index 8cbbf031..ac92becf 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -24397,7 +24397,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "chat with people near you using geohash channels. only a coarse geohash is shared, never exact GPS. your IP address is hidden by routing all traffic over tor." + "value" : "chat with people near you using geohash channels. the selected geohash is public and may reveal an approximate area; exact GPS is never shared. your IP address is hidden by routing all traffic over tor." } }, "es" : { diff --git a/bitchat/Models/RequestSyncPacket.swift b/bitchat/Models/RequestSyncPacket.swift index 047cc20b..0db8e7aa 100644 --- a/bitchat/Models/RequestSyncPacket.swift +++ b/bitchat/Models/RequestSyncPacket.swift @@ -96,7 +96,7 @@ struct RequestSyncPacket { } } - guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil } + guard let pp = p, let mm = m, let dd = payload, pp >= 1, pp <= GCSFilter.maxP, mm > 0 else { return nil } return RequestSyncPacket(p: pp, m: mm, data: dd, types: types, sinceTimestamp: sinceTimestamp, fragmentIdFilter: fragmentIdFilter) } } diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index 7428fc55..2366c68c 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -142,11 +142,22 @@ final class NostrRelayManager: ObservableObject { private var subscriptions: [String: Set] = [:] // relay URL -> active subscription IDs private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON) private var messageHandlers: [String: (NostrEvent) -> Void] = [:] + private struct InboundEventKey: Hashable { + let subscriptionID: String + let eventID: String + } + private let recentInboundEventKeyLimit = TransportConfig.nostrInboundEventDedupCap + private let recentInboundEventKeyTrimTarget = TransportConfig.nostrInboundEventDedupTrimTarget + private var recentInboundEventKeys = Set() + private var recentInboundEventKeyOrder: [InboundEventKey] = [] + private var duplicateInboundEventDropCount = 0 + private var duplicateInboundEventDropCountBySubscription: [String: Int] = [:] // Coalesce duplicate subscribe requests for the same id within a short window. private let subscribeCoalesceInterval: TimeInterval = 1.0 private var subscribeCoalesce: [String: Date] = [:] private var pendingTorConnectionURLs = Set() private var awaitingTorForConnections = false + private var torReadyWaitAttempts = 0 private var cancellables = Set() private struct SubscriptionRequestState: Equatable { @@ -263,6 +274,7 @@ final class NostrRelayManager: ObservableObject { eoseTrackers.removeAll() pendingTorConnectionURLs.removeAll() awaitingTorForConnections = false + torReadyWaitAttempts = 0 updateConnectionStatus() } @@ -285,11 +297,14 @@ final class NostrRelayManager: ObservableObject { // Global network policy gate guard dependencies.activationAllowed() else { return } if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() { - // Defer sends until Tor is ready to avoid premature queueing - dependencies.awaitTorReady { [weak self] ready in - guard let self = self else { return } - if ready { self.sendEvent(event, to: relayUrls) } - } + // Fail-closed: nothing touches the network until Tor is up. Queue the + // event locally so it survives a slow bootstrap (queued sends flush + // when relays connect), then kick off connection setup, which itself + // waits for Tor readiness. + let targetRelays = allowedRelayList(from: relayUrls ?? Self.defaultRelays) + guard !targetRelays.isEmpty else { return } + enqueuePendingSend(event, pendingRelays: Set(targetRelays)) + ensureConnections(to: targetRelays) return } let requestedRelays = relayUrls ?? Self.defaultRelays @@ -307,12 +322,20 @@ final class NostrRelayManager: ObservableObject { } } if !stillPending.isEmpty { - messageQueueLock.lock() - messageQueue.append(PendingSend(event: event, pendingRelays: stillPending)) - messageQueueLock.unlock() + enqueuePendingSend(event, pendingRelays: stillPending) } } + private func enqueuePendingSend(_ event: NostrEvent, pendingRelays: Set) { + messageQueueLock.lock() + messageQueue.append(PendingSend(event: event, pendingRelays: pendingRelays)) + let overflow = messageQueue.count - TransportConfig.nostrPendingSendQueueCap + if overflow > 0 { + messageQueue.removeFirst(overflow) + } + messageQueueLock.unlock() + } + /// Try to flush any queued messages for relays that are now connected. private func flushMessageQueue(for relayUrl: String? = nil) { messageQueueLock.lock() @@ -486,6 +509,8 @@ final class NostrRelayManager: ObservableObject { /// Unsubscribe from a subscription func unsubscribe(id: String) { messageHandlers.removeValue(forKey: id) + removeRecentInboundEvents(forSubscriptionID: id) + duplicateInboundEventDropCountBySubscription.removeValue(forKey: id) // Allow immediate re-subscription by clearing coalescer timestamp subscribeCoalesce.removeValue(forKey: id) subscriptionRequestState.removeValue(forKey: id) @@ -561,14 +586,40 @@ final class NostrRelayManager: ObservableObject { self.awaitingTorForConnections = false guard ready else { - SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session) + self.torReadyWaitAttempts += 1 + if self.torReadyWaitAttempts < TransportConfig.nostrTorReadyMaxWaitAttempts { + SecureLogger.warning("Tor not ready; re-queueing \(pending.count) relay connection(s) (attempt \(self.torReadyWaitAttempts))", category: .session) + self.queueConnectionsUntilTorReady(pending) + } else { + // Still fail-closed (no network), but unblock any callers + // waiting on EOSE so the UI doesn't hang indefinitely. + // Queued subscriptions/sends are kept and flush if a later + // trigger (e.g. app foreground) brings Tor up. + SecureLogger.error("❌ Tor not ready after \(self.torReadyWaitAttempts) wait(s); aborting relay connections (fail-closed)", category: .session) + self.torReadyWaitAttempts = 0 + self.unblockPendingEOSECallbacks(reason: "tor-unavailable") + } return } + self.torReadyWaitAttempts = 0 self.connectToRelays(pending, shouldLog: true) } } + /// Fire and clear all EOSE callbacks that are parked waiting for Tor. + /// Callers treat EOSE as "initial fetch finished"; firing with no data is + /// safe and prevents indefinite hangs when Tor cannot bootstrap. + private func unblockPendingEOSECallbacks(reason: String) { + guard !pendingEOSECallbacks.isEmpty else { return } + let callbacks = pendingEOSECallbacks + pendingEOSECallbacks.removeAll() + SecureLogger.warning("Unblocking \(callbacks.count) pending EOSE callback(s) without data (\(reason))", category: .session) + for (_, callback) in callbacks { + callback() + } + } + private func subscriptionStateExists(id: String, requestState: SubscriptionRequestState) -> Bool { guard !requestState.relayURLs.isEmpty else { return true } return requestState.relayURLs.allSatisfy { url in @@ -608,6 +659,53 @@ final class NostrRelayManager: ObservableObject { startEOSETracking(id: id, relayURLs: requestState.relayURLs, callback: callback) } } + + private func shouldDeliverInboundEvent(subscriptionID: String, eventID: String) -> Bool { + guard !eventID.isEmpty else { return true } + let key = InboundEventKey(subscriptionID: subscriptionID, eventID: eventID) + guard recentInboundEventKeys.insert(key).inserted else { + recordDuplicateInboundEventDrop(subscriptionID: subscriptionID) + return false + } + recentInboundEventKeyOrder.append(key) + + if recentInboundEventKeyOrder.count > recentInboundEventKeyLimit { + let removeCount = recentInboundEventKeyOrder.count - recentInboundEventKeyTrimTarget + for staleKey in recentInboundEventKeyOrder.prefix(removeCount) { + recentInboundEventKeys.remove(staleKey) + } + recentInboundEventKeyOrder.removeFirst(removeCount) + } + return true + } + + private func recordDuplicateInboundEventDrop(subscriptionID: String) { + duplicateInboundEventDropCount += 1 + let subscriptionCount = (duplicateInboundEventDropCountBySubscription[subscriptionID] ?? 0) + 1 + duplicateInboundEventDropCountBySubscription[subscriptionID] = subscriptionCount + + if duplicateInboundEventDropCount == 1 || + duplicateInboundEventDropCount.isMultiple(of: TransportConfig.nostrDuplicateEventLogInterval) { + SecureLogger.debug( + "Dropped duplicate Nostr event deliveries total=\(duplicateInboundEventDropCount) sub=\(subscriptionID) sub_total=\(subscriptionCount)", + category: .session + ) + } + } + + private func removeRecentInboundEvents(forSubscriptionID subscriptionID: String) { + guard !recentInboundEventKeyOrder.isEmpty else { return } + var retainedKeys: [InboundEventKey] = [] + retainedKeys.reserveCapacity(recentInboundEventKeyOrder.count) + for key in recentInboundEventKeyOrder { + if key.subscriptionID == subscriptionID { + recentInboundEventKeys.remove(key) + } else { + retainedKeys.append(key) + } + } + recentInboundEventKeyOrder = retainedKeys + } private func connectToRelay(_ urlString: String) { // Global network policy gate @@ -722,12 +820,22 @@ final class NostrRelayManager: ObservableObject { private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) { switch parsed { case .event(let subId, let event): - if event.kind != 1059 { - SecureLogger.debug("📥 Event kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session) - } if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) { self.relays[index].messagesReceived += 1 } + guard event.isValidSignature() else { + SecureLogger.warning( + "⚠️ Dropped invalid Nostr event id=\(event.id.prefix(16))… sub=\(subId) relay=\(relayUrl)", + category: .session + ) + return + } + guard shouldDeliverInboundEvent(subscriptionID: subId, eventID: event.id) else { + return + } + if event.kind != 1059 { + SecureLogger.debug("📥 Event kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session) + } if let handler = self.messageHandlers[subId] { handler(event) } else { @@ -919,6 +1027,14 @@ final class NostrRelayManager: ObservableObject { pendingSubscriptions[relayUrl]?.count ?? 0 } + var debugDuplicateInboundEventDropCount: Int { + duplicateInboundEventDropCount + } + + func debugDuplicateInboundEventDropCount(forSubscriptionID subscriptionID: String) -> Int { + duplicateInboundEventDropCountBySubscription[subscriptionID] ?? 0 + } + func debugFlushMessageQueue() { flushMessageQueue(for: nil) } @@ -974,8 +1090,7 @@ private enum ParsedInbound { if array.count >= 3, let subId = array[1] as? String, let eventDict = array[2] as? [String: Any], - let event = try? NostrEvent(from: eventDict), - event.isValidSignature() { + let event = try? NostrEvent(from: eventDict) { self = .event(subId: subId, event: event) return } 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/BLELinkStateStore.swift b/bitchat/Services/BLE/BLELinkStateStore.swift index b46edaec..54fad9b9 100644 --- a/bitchat/Services/BLE/BLELinkStateStore.swift +++ b/bitchat/Services/BLE/BLELinkStateStore.swift @@ -26,36 +26,69 @@ struct BLESubscribedCentralSnapshot { } } +/// Owns all BLE link state (peripheral connections we hold as central, and +/// central subscriptions we serve as peripheral). The store has no internal +/// locking: every access must happen on the single owning queue (the BLE +/// queue). Other queues must go through BLEService's `readLinkState`, which +/// hops to that queue. Call `assumeOwnership(of:)` to have debug builds trap +/// any access from the wrong queue. final class BLELinkStateStore { private(set) var peripherals: [String: BLEPeripheralLinkState] = [:] private(set) var peerToPeripheralUUID: [PeerID: String] = [:] private(set) var subscribedCentrals: [CBCentral] = [] private(set) var centralToPeerID: [String: PeerID] = [:] + #if DEBUG + private var ownerQueue: DispatchQueue? + #endif + + /// Pin the store to its owning queue. Debug-only enforcement; release + /// builds are unchanged. + func assumeOwnership(of queue: DispatchQueue) { + #if DEBUG + ownerQueue = queue + #endif + } + + @inline(__always) + private func assertOwned() { + #if DEBUG + if let queue = ownerQueue { + dispatchPrecondition(condition: .onQueue(queue)) + } + #endif + } + var peripheralStates: [BLEPeripheralLinkState] { - Array(peripherals.values) + assertOwned() + return Array(peripherals.values) } var subscribedCentralSnapshot: BLESubscribedCentralSnapshot { - BLESubscribedCentralSnapshot( + assertOwned() + return BLESubscribedCentralSnapshot( centrals: subscribedCentrals, peerIDsByCentralUUID: centralToPeerID ) } var subscribedCentralCount: Int { - subscribedCentrals.count + assertOwned() + return subscribedCentrals.count } var connectedOrConnectingPeripheralCount: Int { - peripherals.values.filter { $0.isConnected || $0.isConnecting }.count + assertOwned() + return peripherals.values.filter { $0.isConnected || $0.isConnecting }.count } func state(forPeripheralID peripheralID: String) -> BLEPeripheralLinkState? { - peripherals[peripheralID] + assertOwned() + return peripherals[peripheralID] } func setPeripheralState(_ state: BLEPeripheralLinkState, for peripheralID: String) { + assertOwned() peripherals[peripheralID] = state } @@ -64,6 +97,7 @@ final class BLELinkStateStore { _ peripheralID: String, _ update: (inout BLEPeripheralLinkState) -> Void ) -> BLEPeripheralLinkState? { + assertOwned() guard var state = peripherals[peripheralID] else { return nil } update(&state) peripherals[peripheralID] = state @@ -113,10 +147,12 @@ final class BLELinkStateStore { } func directPeripheralState(for peerID: PeerID) -> BLEPeripheralLinkState? { - peerToPeripheralUUID[peerID].flatMap { peripherals[$0] } + assertOwned() + return peerToPeripheralUUID[peerID].flatMap { peripherals[$0] } } func directLinkState(for peerID: PeerID) -> BLEDirectLinkState { + assertOwned() let peripheralUUID = peerToPeripheralUUID[peerID] let hasPeripheral = peripheralUUID.flatMap { peripherals[$0]?.isConnected } ?? false let hasCentral = centralToPeerID.values.contains(peerID) @@ -124,6 +160,7 @@ final class BLELinkStateStore { } func links(to peerID: PeerID?) -> Set { + assertOwned() guard let peerID else { return [] } var links: Set = [] @@ -137,35 +174,42 @@ final class BLELinkStateStore { } func peerID(forPeripheralID peripheralID: String) -> PeerID? { - peripherals[peripheralID]?.peerID + assertOwned() + return peripherals[peripheralID]?.peerID } func peerID(forCentralUUID centralUUID: String) -> PeerID? { - centralToPeerID[centralUUID] + assertOwned() + return centralToPeerID[centralUUID] } func addSubscribedCentral(_ central: CBCentral) { + assertOwned() guard !subscribedCentrals.contains(central) else { return } subscribedCentrals.append(central) } func removeSubscribedCentral(_ central: CBCentral) -> PeerID? { + assertOwned() let centralUUID = central.identifier.uuidString subscribedCentrals.removeAll { $0.identifier == central.identifier } return centralToPeerID.removeValue(forKey: centralUUID) } func bindCentral(_ centralUUID: String, to peerID: PeerID) { + assertOwned() centralToPeerID[centralUUID] = peerID } func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) { + assertOwned() if updatePeripheral(peripheralUUID, { $0.peerID = peerID }) != nil { peerToPeripheralUUID[peerID] = peripheralUUID } } func removePeripheral(_ peripheralID: String) -> PeerID? { + assertOwned() let peerID = peripherals.removeValue(forKey: peripheralID)?.peerID if let peerID { peerToPeripheralUUID.removeValue(forKey: peerID) @@ -174,6 +218,7 @@ final class BLELinkStateStore { } func clearPeripherals() -> [PeerID] { + assertOwned() let peerIDs = peripherals.compactMap { $0.value.peerID } peripherals.removeAll() peerToPeripheralUUID.removeAll() @@ -181,6 +226,7 @@ final class BLELinkStateStore { } func clearCentrals() -> [PeerID] { + assertOwned() let peerIDs = Array(centralToPeerID.values) subscribedCentrals.removeAll() centralToPeerID.removeAll() @@ -188,6 +234,7 @@ final class BLELinkStateStore { } func clearAll() { + assertOwned() peripherals.removeAll() peerToPeripheralUUID.removeAll() subscribedCentrals.removeAll() 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/Services/MessageRouter.swift b/bitchat/Services/MessageRouter.swift index 971a8432..2f00955c 100644 --- a/bitchat/Services/MessageRouter.swift +++ b/bitchat/Services/MessageRouter.swift @@ -13,6 +13,7 @@ final class MessageRouter { let nickname: String let messageID: String let timestamp: Date + var sendAttempts: Int = 0 } private var outbox: [PeerID: [QueuedMessage]] = [:] @@ -20,6 +21,9 @@ final class MessageRouter { // Outbox limits to prevent unbounded memory growth private static let maxMessagesPerPeer = 100 private static let messageTTLSeconds: TimeInterval = 24 * 60 * 60 // 24 hours + // Bound resends of messages sent on a weak reachability signal that never + // get a delivery ack (e.g. peer on an old client that doesn't ack). + private static let maxSendAttempts = 8 init(transports: [Transport]) { self.transports = transports @@ -61,26 +65,53 @@ final class MessageRouter { // MARK: - Message Sending func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { - if let transport = reachableTransport(for: peerID) { - SecureLogger.debug("Routing PM via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) + if let transport = connectedTransport(for: peerID) { + // A live link is a strong delivery signal; trust it outright. + SecureLogger.debug("Routing PM via \(type(of: transport)) (connected) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) + return + } + + let message = QueuedMessage(content: content, nickname: recipientNickname, messageID: messageID, timestamp: Date(), sendAttempts: 1) + if let transport = reachableTransport(for: peerID) { + // Reachability without a connection is a freshness heuristic (e.g. + // the mesh retention window), so the send can silently go nowhere. + // Send now, but retain a copy until a delivery/read ack clears it; + // receivers dedup resends by message ID. + SecureLogger.debug("Routing PM via \(type(of: transport)) (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) + transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) + enqueue(message, for: peerID) } else { - // Queue for later with timestamp for TTL tracking - if outbox[peerID] == nil { outbox[peerID] = [] } - - let message = QueuedMessage(content: content, nickname: recipientNickname, messageID: messageID, timestamp: Date()) - outbox[peerID]?.append(message) - - // Enforce per-peer size limit with FIFO eviction - if let count = outbox[peerID]?.count, count > Self.maxMessagesPerPeer { - let evicted = outbox[peerID]?.removeFirst() - SecureLogger.warning("📤 Outbox overflow for \(peerID.id.prefix(8))… - evicted oldest message: \(evicted?.messageID.prefix(8) ?? "?")…", category: .session) - } - + var unsent = message + unsent.sendAttempts = 0 + enqueue(unsent, for: peerID) SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session) } } + /// A delivery or read ack confirms receipt; stop retaining the message. + func markDelivered(_ messageID: String) { + for (peerID, queue) in outbox { + let filtered = queue.filter { $0.messageID != messageID } + guard filtered.count != queue.count else { continue } + outbox[peerID] = filtered.isEmpty ? nil : filtered + } + } + + private func enqueue(_ message: QueuedMessage, for peerID: PeerID) { + var queue = outbox[peerID] ?? [] + // Re-sending an already-queued ID replaces the entry (keeps attempt count fresh) + queue.removeAll { $0.messageID == message.messageID } + queue.append(message) + + // Enforce per-peer size limit with FIFO eviction + if queue.count > Self.maxMessagesPerPeer { + let evicted = queue.removeFirst() + SecureLogger.warning("📤 Outbox overflow for \(peerID.id.prefix(8))… - evicted oldest message: \(evicted.messageID.prefix(8))…", category: .session) + } + outbox[peerID] = queue + } + func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { if let transport = reachableTransport(for: peerID) { SecureLogger.debug("Routing READ ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session) @@ -121,9 +152,22 @@ final class MessageRouter { continue } - if let transport = reachableTransport(for: peerID) { - SecureLogger.debug("Outbox -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session) + if let transport = connectedTransport(for: peerID) { + // Live link: send and stop retaining. + SecureLogger.debug("Outbox -> \(type(of: transport)) (connected) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session) transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID) + } else if let transport = reachableTransport(for: peerID) { + // Weak signal: send but keep retaining until an ack clears it, + // bounded by attempt count for peers that never ack. + guard message.sendAttempts < Self.maxSendAttempts else { + SecureLogger.warning("📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session) + continue + } + SecureLogger.debug("Outbox -> \(type(of: transport)) (reachable) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session) + transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID) + var retained = message + retained.sendAttempts += 1 + remaining.append(retained) } else { remaining.append(message) } diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index 6153c266..846689d1 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -18,6 +18,7 @@ enum TransportConfig { static let meshTimelineCap: Int = 1337 static let geoTimelineCap: Int = 1337 static let contentLRUCap: Int = 2000 + static let geoSamplingEventLRUCap: Int = 2000 // Timers static let networkResetGraceSeconds: TimeInterval = 600 // 10 minutes @@ -43,6 +44,9 @@ enum TransportConfig { // Nostr static let nostrReadAckInterval: TimeInterval = 0.35 // ~3 per second + static let nostrInboundEventDedupCap: Int = 4096 + static let nostrInboundEventDedupTrimTarget: Int = 3072 + static let nostrDuplicateEventLogInterval: Int = 50 // UI thresholds static let uiLateInsertThreshold: TimeInterval = 15.0 @@ -146,6 +150,10 @@ enum TransportConfig { static let nostrRelayBackoffMultiplier: Double = 2.0 static let nostrRelayMaxReconnectAttempts: Int = 10 static let nostrRelayDefaultFetchLimit: Int = 100 + // How many consecutive Tor-readiness waits (each bounded by TorManager's + // bootstrap deadline) to attempt before unblocking pending EOSE callers. + static let nostrTorReadyMaxWaitAttempts: Int = 3 + static let nostrPendingSendQueueCap: Int = 200 // Geo relay directory static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24 diff --git a/bitchat/Sync/GCSFilter.swift b/bitchat/Sync/GCSFilter.swift index d3c4d9fb..7d76c23c 100644 --- a/bitchat/Sync/GCSFilter.swift +++ b/bitchat/Sync/GCSFilter.swift @@ -12,6 +12,11 @@ import CryptoKit enum GCSFilter { struct Params { let p: Int; let m: UInt32; let data: Data } + // Highest Golomb-Rice parameter we accept from the wire. P maps to an FPR + // of ~1/2^P; beyond 32 the remainder width exceeds any practical filter + // and shifts in decode would silently overflow to garbage values. + static let maxP = 32 + // Derive P from FPR (~ 1 / 2^P) static func deriveP(targetFpr: Double) -> Int { let f = max(0.000001, min(0.25, targetFpr)) @@ -66,6 +71,9 @@ enum GCSFilter { } static func decodeToSortedSet(p: Int, m: UInt32, data: Data) -> [UInt64] { + // Reject out-of-range parameters rather than decoding garbage: callers + // treat the result as "peer has nothing" and fall back to sending data. + guard p >= 1, p <= maxP, m > 1 else { return [] } var values: [UInt64] = [] let reader = BitReader(data) var acc: UInt64 = 0 diff --git a/bitchat/ViewModels/ChatDeliveryCoordinator.swift b/bitchat/ViewModels/ChatDeliveryCoordinator.swift index 19f3a333..62492aa2 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,163 @@ 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 { + // Growth is only a pure append if the previously indexed tail kept + // its position; a middle insertion (out-of-order timestamp arrival) + // shifts it and invalidates every indexed location after the + // insertion point. + if indexedPublicMessageCount > 0, + context.messages[indexedPublicMessageCount - 1].id != indexedPublicTailMessageID { + rebuildMessageLocationIndex() + return true + } + for index in indexedPublicMessageCount.. indexedCount else { continue } + // Same append-only check as the public timeline above. + if indexedCount > 0, + messages[indexedCount - 1].id != indexedPrivateTailMessageIDs[peerID] { + rebuildMessageLocationIndex() + return true + } + 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..60d0b86d 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,198 @@ 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 middleInsertedMessage_isFoundAfterIndexWasBuilt() async { + let context = MockChatDeliveryContext() + let coordinator = ChatDeliveryCoordinator(context: context) + let makePublic = { (id: String) in + BitchatMessage( + id: id, + sender: "me", + content: "Public message", + timestamp: Date(), + isRelay: false, + isPrivate: false, + deliveryStatus: .sending + ) + } + context.messages = [makePublic("public-a"), makePublic("public-b")] + + // Build the incremental index. + #expect(coordinator.updateMessageDeliveryStatus("public-a", status: .sent)) + + // Out-of-order arrival: PublicMessagePipeline inserts by timestamp, + // so the count grows while the tail ID stays the same. + context.messages.insert(makePublic("public-mid"), at: 1) + + // The inserted message must be locatable, and the shifted tail must + // not be updated through a stale index entry. + #expect(coordinator.updateMessageDeliveryStatus("public-mid", status: .sent)) + #expect(isSent(context.messages[1].deliveryStatus)) + #expect(coordinator.updateMessageDeliveryStatus("public-b", status: .sent)) + #expect(isSent(context.messages[2].deliveryStatus)) + } + + @Test @MainActor + func middleInsertedPrivateMessage_isFoundAfterIndexWasBuilt() async { + let context = MockChatDeliveryContext() + let coordinator = ChatDeliveryCoordinator(context: context) + let peerID = PeerID(str: "0102030405060708") + context.privateChats[peerID] = [ + makePrivateMessage(id: "pm-a", status: .sending), + makePrivateMessage(id: "pm-b", status: .sending) + ] + + #expect(coordinator.updateMessageDeliveryStatus("pm-a", status: .sent)) + + // Timestamp re-sort (sanitizeChat) can place a late arrival mid-array. + context.privateChats[peerID]?.insert(makePrivateMessage(id: "pm-mid", status: .sending), at: 1) + + #expect(coordinator.updateMessageDeliveryStatus("pm-mid", status: .sent)) + #expect(isSent(context.privateChats[peerID]?[1].deliveryStatus)) + #expect(coordinator.updateMessageDeliveryStatus("pm-b", status: .sent)) + #expect(isSent(context.privateChats[peerID]?[2].deliveryStatus)) + } + + @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/GCSFilterTests.swift b/bitchatTests/GCSFilterTests.swift index 36f91773..464f368e 100644 --- a/bitchatTests/GCSFilterTests.swift +++ b/bitchatTests/GCSFilterTests.swift @@ -20,4 +20,32 @@ struct GCSFilterTests { #expect(bucket != 0) #expect(bucket < 2) } + + @Test func decodeRejectsOutOfRangeParameters() { + let junk = Data(repeating: 0xFF, count: 64) + #expect(GCSFilter.decodeToSortedSet(p: 0, m: 1000, data: junk).isEmpty) + #expect(GCSFilter.decodeToSortedSet(p: -1, m: 1000, data: junk).isEmpty) + #expect(GCSFilter.decodeToSortedSet(p: GCSFilter.maxP + 1, m: 1000, data: junk).isEmpty) + #expect(GCSFilter.decodeToSortedSet(p: 255, m: UInt32.max, data: junk).isEmpty) + #expect(GCSFilter.decodeToSortedSet(p: 8, m: 0, data: junk).isEmpty) + #expect(GCSFilter.decodeToSortedSet(p: 8, m: 1, data: junk).isEmpty) + } + + @Test func decodeOfTruncatedDataReturnsOnlyCompleteValues() { + let ids = (0..<32).map { i in Data(repeating: UInt8(i), count: 16) } + let params = GCSFilter.buildFilter(ids: ids, maxBytes: 128, targetFpr: 0.01) + let full = GCSFilter.decodeToSortedSet(p: params.p, m: params.m, data: params.data) + let truncated = GCSFilter.decodeToSortedSet(p: params.p, m: params.m, data: params.data.prefix(params.data.count / 2)) + #expect(truncated.count <= full.count) + // Truncation must not invent values that were not in the full set. + #expect(truncated.allSatisfy { full.contains($0) }) + } + + @Test func requestSyncPacketDecodeRejectsOversizedP() { + let valid = RequestSyncPacket(p: 8, m: 4096, data: Data([0x01, 0x02])) + #expect(RequestSyncPacket.decode(from: valid.encode()) != nil) + + let oversized = RequestSyncPacket(p: 200, m: 4096, data: Data([0x01, 0x02])) + #expect(RequestSyncPacket.decode(from: oversized.encode()) == nil) + } } 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) + } +} diff --git a/bitchatTests/Services/MessageRouterTests.swift b/bitchatTests/Services/MessageRouterTests.swift index 89289320..01aea3ff 100644 --- a/bitchatTests/Services/MessageRouterTests.swift +++ b/bitchatTests/Services/MessageRouterTests.swift @@ -42,6 +42,74 @@ struct MessageRouterTests { #expect(transport.sentPrivateMessages.count == 1) } + @Test @MainActor + func sendPrivate_prefersConnectedTransportOverEarlierReachableOne() async { + let peerID = PeerID(str: "0000000000000005") + let reachableOnly = MockTransport() + reachableOnly.reachablePeers.insert(peerID) + let connected = MockTransport() + connected.connectedPeers.insert(peerID) + connected.reachablePeers.insert(peerID) + + let router = MessageRouter(transports: [reachableOnly, connected]) + router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m5") + + #expect(reachableOnly.sentPrivateMessages.isEmpty) + #expect(connected.sentPrivateMessages.count == 1) + } + + @Test @MainActor + func sendPrivate_reachableOnlySendRetainsUntilDeliveryAck() async { + let peerID = PeerID(str: "0000000000000006") + let transport = MockTransport() + transport.reachablePeers.insert(peerID) + + let router = MessageRouter(transports: [transport]) + router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m6") + #expect(transport.sentPrivateMessages.count == 1) + + // No ack yet: a flush retries over the weak signal. + router.flushOutbox(for: peerID) + #expect(transport.sentPrivateMessages.count == 2) + + // Ack clears the retained copy; later flushes stop resending. + router.markDelivered("m6") + router.flushOutbox(for: peerID) + #expect(transport.sentPrivateMessages.count == 2) + } + + @Test @MainActor + func sendPrivate_connectedSendIsNotRetained() async { + let peerID = PeerID(str: "0000000000000007") + let transport = MockTransport() + transport.connectedPeers.insert(peerID) + transport.reachablePeers.insert(peerID) + + let router = MessageRouter(transports: [transport]) + router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m7") + #expect(transport.sentPrivateMessages.count == 1) + + router.flushOutbox(for: peerID) + #expect(transport.sentPrivateMessages.count == 1) + } + + @Test @MainActor + func flushOutbox_dropsUnackedMessageAfterAttemptCap() async { + let peerID = PeerID(str: "0000000000000008") + let transport = MockTransport() + transport.reachablePeers.insert(peerID) + + let router = MessageRouter(transports: [transport]) + router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m8") + + for _ in 0..<10 { + router.flushOutbox(for: peerID) + } + + // Initial send plus capped resends; never unbounded. + #expect(transport.sentPrivateMessages.count == 8) + } + @Test @MainActor func sendReadReceipt_usesReachableTransport() async { let peerID = PeerID(str: "0000000000000003") diff --git a/bitchatTests/Services/NostrRelayManagerTests.swift b/bitchatTests/Services/NostrRelayManagerTests.swift index a7447980..1f4db703 100644 --- a/bitchatTests/Services/NostrRelayManagerTests.swift +++ b/bitchatTests/Services/NostrRelayManagerTests.swift @@ -90,6 +90,90 @@ final class NostrRelayManagerTests: XCTestCase { XCTAssertFalse(context.manager.isConnected) } + func test_connect_retriesTorWaitAndConnectsWhenTorBecomesReady() async { + let context = makeContext(permission: .authorized, userTorEnabled: true, torEnforced: true, torIsReady: false) + + context.manager.connect() + XCTAssertEqual(context.torWaiter.awaitCallCount, 1) + + context.torWaiter.resolve(false) + + // A failed wait re-queues the same targets and waits again instead of dropping them. + XCTAssertEqual(context.torWaiter.awaitCallCount, 2) + XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty) + + context.torWaiter.resolve(true) + + let connected = await waitUntil { + context.sessionFactory.requestedURLs.count == self.expectedDefaultRelayCount && + context.manager.relays.allSatisfy(\.isConnected) + } + XCTAssertTrue(connected) + } + + func test_subscribe_unblocksDeferredEOSEWhenTorWaitAttemptsExhausted() async { + let relayURL = "wss://tor-eose-unblock.example" + let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false) + var eoseCount = 0 + + context.manager.subscribe( + filter: makeFilter(), + id: "tor-sub-unblock", + relayUrls: [relayURL], + handler: { _ in }, + onEOSE: { eoseCount += 1 } + ) + + for _ in 0.. NostrEvent { + var invalid = event + invalid.sig = String(repeating: "0", count: 128) + return invalid + } + private func waitUntil( timeout: TimeInterval = 1.0, condition: @escaping @MainActor () -> Bool diff --git a/docs/ARTI-BINARY-PROVENANCE.md b/docs/ARTI-BINARY-PROVENANCE.md new file mode 100644 index 00000000..a13240ba --- /dev/null +++ b/docs/ARTI-BINARY-PROVENANCE.md @@ -0,0 +1,88 @@ +# Arti Binary Provenance + +This repo vendors a prebuilt Arti static-library xcframework at: + +`localPackages/Arti/Frameworks/arti.xcframework` + +SwiftPM links it through `localPackages/Arti/Package.swift` as the binary target named `arti`. Treat changes to this artifact like dependency updates: review the Rust sources, lockfile, build script, produced headers, and artifact hashes together. + +## Source Inputs + +- Rust workspace: `localPackages/Arti/Cargo.toml` +- Crate: `localPackages/Arti/arti-bitchat` +- Dependency lockfile: `localPackages/Arti/Cargo.lock` +- Build script: `localPackages/Arti/build-ios.sh` +- Exported C header: `localPackages/Arti/Frameworks/include/arti.h` + +The crate declares `rust-version = "1.90"` and uses `arti-client` / `tor-rtcompat` `0.38` with minimal Tokio/Rustls features. The current lockfile requires Rust 1.90 or newer. The build script currently targets: + +- `aarch64-apple-ios` +- `aarch64-apple-ios-sim` +- `aarch64-apple-darwin` + +It builds release static libraries with size-oriented flags (`opt-level=z`, fat LTO, one codegen unit, `panic=abort`, stripped symbols), normalizes static-archive metadata with `xcrun libtool -static -D`, then packages them with `xcodebuild -create-xcframework`. + +## Regenerating The Artifact + +From the repo root: + +```sh +cd localPackages/Arti +rustup toolchain install 1.96.0 +rustup default 1.96.0 +rustup target add aarch64-apple-ios aarch64-apple-ios-sim aarch64-apple-darwin +cargo install cbindgen +./build-ios.sh +``` + +After rebuilding, verify that: + +- `Cargo.lock` changes are intentional and reviewed. +- `Frameworks/include/arti.h` still matches the exported FFI functions used by `TorManager`. +- `Frameworks/arti.xcframework` contains iOS device, iOS simulator, and macOS arm64 slices. +- The main app still passes iOS tests and the macOS build. + +## Audited Rebuild + +The June 2026 artifact below was rebuilt from source on this host with: + +```text +rustc 1.96.0 (ac68faa20 2026-05-25) +cargo 1.96.0 (30a34c682 2026-05-25) +rustup 1.29.0 (28d1352db 2026-03-05) +cbindgen 0.29.3 +Xcode 26.5 +Build version 17F42 +``` + +Rust 1.86.0 was also checked during the audit and no longer builds this lockfile because `typed-index-collections@3.4.0` requires Rust 1.90.0 or newer. + +The build script now normalizes static-archive metadata and writes a stable xcframework `Info.plist`. Two consecutive no-source-change rebuilds on this host produced the same hashes below. + +## Current Artifact Hashes + +Run this from the repo root to verify the checked-in artifact: + +```sh +find localPackages/Arti/Frameworks/arti.xcframework -maxdepth 3 -type f -print0 | sort -z | xargs -0 shasum -a 256 +``` + +Current hashes: + +```text +2083d44eafc765db1ffa2691a5c5fabe60b4edbb82b574169ca0c6b98e245e3a localPackages/Arti/Frameworks/arti.xcframework/Info.plist +551655904834748c9dc36034fdbc9465e7533aef1e4a6514b4fcc75875b93058 localPackages/Arti/Frameworks/arti.xcframework/ios-arm64-simulator/Headers/arti.h +85febff37b751df667a3cab8222de2e1450cefe44b5b62c419adcbce48b9663f localPackages/Arti/Frameworks/arti.xcframework/ios-arm64-simulator/libarti_bitchat.a +551655904834748c9dc36034fdbc9465e7533aef1e4a6514b4fcc75875b93058 localPackages/Arti/Frameworks/arti.xcframework/ios-arm64/Headers/arti.h +fd25ee379d709a794733fc3c052746d1e6f7b25fec23e5f5234008a3434ce879 localPackages/Arti/Frameworks/arti.xcframework/ios-arm64/libarti_bitchat.a +551655904834748c9dc36034fdbc9465e7533aef1e4a6514b4fcc75875b93058 localPackages/Arti/Frameworks/arti.xcframework/macos-arm64/Headers/arti.h +8c426a41dc3eb76cc3e3e22e3356b9d11dbebdf0a0f248c5ac892e1839352c75 localPackages/Arti/Frameworks/arti.xcframework/macos-arm64/libarti_bitchat.a +``` + +## Review Checklist + +- Record `rustc --version`, `cargo --version`, `cbindgen --version`, and `xcodebuild -version` in the PR when refreshing the binary. +- Include the hash output above after any binary change. +- If a rebuild changes only xcframework/library bytes, record the new hashes and app validation evidence in the PR. +- Keep `target/`, `.build/`, and `.swiftpm/` out of source control. +- Do not accept an xcframework-only update without matching source, lockfile, or build-script evidence explaining where it came from. diff --git a/docs/TOR-INTEGRATION.md b/docs/TOR-INTEGRATION.md index 0d714834..165acfe3 100644 --- a/docs/TOR-INTEGRATION.md +++ b/docs/TOR-INTEGRATION.md @@ -2,7 +2,7 @@ Tor-by-default integration (scaffold) Overview - All network traffic is routed via a local Tor SOCKS5 proxy by default, with fail-closed behavior when Tor isn’t ready. There are no user-visible settings. -- This repo now includes a minimal TorManager and TorURLSession to make dropping in an embedded Tor framework straightforward. +- This repo vendors an Arti-backed Swift package under `localPackages/Arti`, including a Rust static-library xcframework linked by SwiftPM. Key pieces - TorManager @@ -12,36 +12,17 @@ Key pieces - Provides a shared URLSession configured with a SOCKS5 proxy when Tor is enforced/ready. - NostrRelayManager and GeoRelayDirectory now use this session and await Tor readiness before starting network activity. -Drop‑in steps -1) Build or obtain a small Tor framework - - Recommended: Tor C (client-only) with static linking and dead-strip. - - Configure Tor with a minimal feature set: - ./configure \ - --enable-static \ - --disable-asciidoc --disable-unittests --disable-manpage \ - --disable-zstd --disable-lzma --enable-zlib \ - --disable-systemd --disable-ptrace --disable-seccomp - CFLAGS="-Os -fdata-sections -ffunction-sections" \ - LDFLAGS="-Wl,-dead_strip" - - Build a tiny OpenSSL/LibreSSL (no engines, strip symbols) or reuse system crypto where permitted on macOS. +Artifact maintenance +- Binary provenance, rebuild steps, and current hashes are documented in `docs/ARTI-BINARY-PROVENANCE.md`. +- The xcframework must include iOS device, iOS simulator, and macOS arm64 slices. +- Any refresh should review the Rust source, `Cargo.lock`, generated header, build script, and new hashes together. -2) Add the framework to Xcode targets - - Drop your xcframework into `Frameworks/`. The project is prewired in `project.yml` to link/embed `Frameworks/tor-nolzma.xcframework` (rename yours to match, or update the path). - - Ensure the binary includes the slices you need (iOS device/simulator and/or macOS). If your xcframework lacks simulator slices, you can still build/run on device or macOS arm64; simulator will fail to link. - - On iOS, it will be embedded and signed automatically. - -3) Wire Tor bootstrap in TorManager.startTor() - - Two paths are already implemented: - - If a module named `Tor` is present (iCepa API), it starts `TORThread` directly. - - Otherwise, it attempts a dynamic load (`dlopen`) of a bundled framework binary named `tor-nolzma.framework/tor-nolzma` (or `Tor.framework/Tor`), resolves `tor_run_main`, and launches Tor on a background thread. - - `TorManager` writes a torrc and then probes `127.0.0.1:39050` until ready. - -4) Verify networking +Verification - On app launch, TorManager.startIfNeeded() is called implicitly by awaitReady(). - NostrRelayManager.connect() awaits readiness, then creates WebSocket tasks via TorURLSession.shared. - GeoRelayDirectory.fetchRemote() awaits readiness, then fetches via TorURLSession.shared. -5) Optional macOS optimization +Optional macOS optimization - Detect a system Tor binary (e.g., /opt/homebrew/bin/tor) and run it as a subprocess to avoid bundling. Keep the embedded fallback for portability. torrc template diff --git a/docs/privacy-assessment.md b/docs/privacy-assessment.md index cba3aa2e..bcb5c178 100644 --- a/docs/privacy-assessment.md +++ b/docs/privacy-assessment.md @@ -4,14 +4,16 @@ BitChat Privacy Assessment Scope - Mesh transport (BLE) behavior and metadata minimization - Nostr-based private message fallback (gift-wrapped, end-to-end encrypted) +- Nostr-backed public geohash channels, presence heartbeats, and location notes +- Optional CoreLocation use for geohash channel discovery - Read receipts and delivery acknowledgments - Logging/telemetry posture and controls Summary -- No accounts, no servers for mesh; Nostr used only for mutual favorites, with end-to-end Noise encryption encapsulated in gift wraps. +- No accounts and no project-operated servers. Mesh traffic is peer-to-peer; Nostr is used for mutual-favorite private fallback and public geohash features. - BLE announces contain only nickname and Noise pubkey. No device name, no plaintext identity beyond what the user broadcasts. - Discovery and flooding incorporate jitter and TTL caps to reduce linkability and propagation radius of encrypted payloads. -- UI and storage remain ephemeral; message content is not persisted to disk. Minimal state (e.g., read-receipt IDs) is stored for UX and is bounded/cleaned. +- UI and storage remain mostly ephemeral; message content is not persisted to disk by default. Minimal local state (e.g., read-receipt IDs, favorites, selected/bookmarked geohashes) is stored for UX and is bounded or user-wipeable. - Logging defaults to conservative levels; debug verbosity is suppressed for release builds. A single env var can raise/lower threshold when needed. BLE Privacy Considerations @@ -35,6 +37,14 @@ Nostr Private Messaging Fallback - Read/delivery acks: Also encapsulated in gift wraps, preserving content secrecy and minimizing metadata. - Relay policy variance: Some relays apply “web-of-trust” policies and may reject events; BitChat tolerates partial delivery and still prefers mesh when available. +Location Channels and Geohash Public Chats +- Location permission: Optional when-in-use CoreLocation access computes local geohash channel options. Exact coordinates are held in memory only and are not included in BitChat or Nostr payloads. +- Local state: Selected channel, teleported geohashes, bookmarks, and bookmark display names are stored in `UserDefaults`; the panic action clears location presence state along with identity/session state. +- Geohash precision: User-selected channels can range from region-level to building-level. Public geohash messages and location notes expose the selected geohash tag to relays and participants. +- Presence minimization: Automatic presence heartbeats are restricted to low-precision region/province/city geohashes and use randomized timing. +- Per-geohash identities: Public geohash Nostr identities are derived from a device seed stored in the keychain, reducing cross-channel linkability compared with a single stable public key. +- Relay metadata: Relays can observe event kind, geohash tag, public key, timestamp, and network metadata. Content in public geohash channels is intentionally public to that channel. + Read Receipts and Delivery Acks - Routing policy: Prefer mesh if Noise session established; otherwise use Nostr when mapping exists. - Throttling: Nostr READ acks are queued and rate-limited (~3/s) to prevent relay rate limits during backlogs. @@ -44,6 +54,9 @@ Data Retention and State - Messages: Ephemeral in-memory only; history is bounded per chat and trimmed. - Read-receipt IDs: Stored in `UserDefaults` for UX continuity; periodically pruned to IDs present in memory. - Favorites: Noise and optional Nostr keys with petnames; can be wiped via panic action. +- Location channels: Exact coordinates are not persisted by BitChat. Selected/bookmarked geohashes, teleport flags, and bookmark display names persist locally until removed, panic-wiped, or the app is deleted. +- Geohash identities: Device seed is stored in the keychain and used to derive per-geohash Nostr identities deterministically. +- Relay persistence: Public geohash events, location notes, and encrypted gift wraps may be retained by relays according to each relay's policy. - Panic: Triple-tap clears keys, sessions, cached state, and disconnects transports. Logging and Telemetry @@ -56,9 +69,11 @@ Residual Risks and Mitigations - RF fingerprinting: BLE presence is observable at the RF layer; mitigated by minimal announce content and platform MAC randomization. - Timing correlation: Announce/relay jitter reduces but does not eliminate timing analysis. Avoids synchronized bursts. - Relay metadata: Nostr relays can see that an account posts gift wraps; content remains end-to-end encrypted. Favor mesh path when in range. +- Geohash inference: Public location-channel tags reveal approximate area. Mitigated by explicit channel selection, low-precision automatic presence, and per-geohash identities. +- Bookmark persistence: Locally stored geohash bookmarks may reveal places of interest on a seized/unlocked device. Mitigated by panic wipe and local-only storage. Recommendations (Next) - Add optional coalesced READ behavior for large backlogs. - Expose a “low-visibility mode” to reduce scanning aggressiveness in sensitive contexts. - Allow user-configurable Nostr relay set with a “private relays only” toggle. - +- Add a user-facing precision warning before posting in block/building-level geohash channels. diff --git a/localPackages/Arti/Frameworks/arti.xcframework/Info.plist b/localPackages/Arti/Frameworks/arti.xcframework/Info.plist index 5e9b24d6..2cfc3283 100644 --- a/localPackages/Arti/Frameworks/arti.xcframework/Info.plist +++ b/localPackages/Arti/Frameworks/arti.xcframework/Info.plist @@ -4,22 +4,6 @@ AvailableLibraries - - BinaryPath - libarti_bitchat.a - HeadersPath - Headers - LibraryIdentifier - macos-arm64 - LibraryPath - libarti_bitchat.a - SupportedArchitectures - - arm64 - - SupportedPlatform - macos - BinaryPath libarti_bitchat.a @@ -54,6 +38,22 @@ SupportedPlatformVariant simulator + + BinaryPath + libarti_bitchat.a + HeadersPath + Headers + LibraryIdentifier + macos-arm64 + LibraryPath + libarti_bitchat.a + SupportedArchitectures + + arm64 + + SupportedPlatform + macos + CFBundlePackageType XFWK diff --git a/localPackages/Arti/Frameworks/arti.xcframework/ios-arm64-simulator/libarti_bitchat.a b/localPackages/Arti/Frameworks/arti.xcframework/ios-arm64-simulator/libarti_bitchat.a index 3b5d7176..9ab40ec7 100644 Binary files a/localPackages/Arti/Frameworks/arti.xcframework/ios-arm64-simulator/libarti_bitchat.a and b/localPackages/Arti/Frameworks/arti.xcframework/ios-arm64-simulator/libarti_bitchat.a differ diff --git a/localPackages/Arti/Frameworks/arti.xcframework/ios-arm64/libarti_bitchat.a b/localPackages/Arti/Frameworks/arti.xcframework/ios-arm64/libarti_bitchat.a index 35bf4c67..86b0de10 100644 Binary files a/localPackages/Arti/Frameworks/arti.xcframework/ios-arm64/libarti_bitchat.a and b/localPackages/Arti/Frameworks/arti.xcframework/ios-arm64/libarti_bitchat.a differ diff --git a/localPackages/Arti/Frameworks/arti.xcframework/macos-arm64/libarti_bitchat.a b/localPackages/Arti/Frameworks/arti.xcframework/macos-arm64/libarti_bitchat.a index 3884360b..e9525474 100644 Binary files a/localPackages/Arti/Frameworks/arti.xcframework/macos-arm64/libarti_bitchat.a and b/localPackages/Arti/Frameworks/arti.xcframework/macos-arm64/libarti_bitchat.a differ diff --git a/localPackages/Arti/Package.swift b/localPackages/Arti/Package.swift index c32ad6cf..1fe587da 100644 --- a/localPackages/Arti/Package.swift +++ b/localPackages/Arti/Package.swift @@ -37,7 +37,8 @@ let package = Package( .linkedLibrary("sqlite3"), ] ), - // Binary framework containing the Rust static library + // Binary framework containing the Rust static library. + // Provenance and rebuild steps: repo-root docs/ARTI-BINARY-PROVENANCE.md .binaryTarget( name: "arti", path: "Frameworks/arti.xcframework" diff --git a/localPackages/Arti/Sources/TorManager.swift b/localPackages/Arti/Sources/TorManager.swift index 45958b59..cd831ba5 100644 --- a/localPackages/Arti/Sources/TorManager.swift +++ b/localPackages/Arti/Sources/TorManager.swift @@ -110,7 +110,9 @@ public final class TorManager: ObservableObject { public func isForeground() -> Bool { isAppForeground } nonisolated - public func awaitReady(timeout: TimeInterval = 25.0) async -> Bool { + // Default matches the bootstrap monitor deadline (75s); a shorter wait here + // reports "not ready" while Arti is still legitimately bootstrapping. + public func awaitReady(timeout: TimeInterval = 75.0) async -> Bool { await MainActor.run { if self.isAppForeground { self.startIfNeeded() } } diff --git a/localPackages/Arti/arti-bitchat/Cargo.toml b/localPackages/Arti/arti-bitchat/Cargo.toml index fcb8abf5..5a835194 100644 --- a/localPackages/Arti/arti-bitchat/Cargo.toml +++ b/localPackages/Arti/arti-bitchat/Cargo.toml @@ -2,7 +2,7 @@ name = "arti-bitchat" version = "0.1.0" edition = "2021" -rust-version = "1.86" +rust-version = "1.90" [lib] crate-type = ["staticlib"] diff --git a/localPackages/Arti/build-ios.sh b/localPackages/Arti/build-ios.sh index 3b16b52e..ca01025f 100755 --- a/localPackages/Arti/build-ios.sh +++ b/localPackages/Arti/build-ios.sh @@ -133,6 +133,11 @@ create_xcframework() { log_info "Stripping $target library..." strip -x "$lib_path" 2>/dev/null || true + # Normalize archive metadata so repeated rebuilds are byte-stable. + local normalized_path="$lib_path.normalized" + xcrun libtool -static -D -no_warning_for_no_symbols "$lib_path" -o "$normalized_path" + mv "$normalized_path" "$lib_path" + cmd="$cmd -library $lib_path" # Add headers if they exist @@ -151,6 +156,71 @@ create_xcframework() { eval "$cmd" if [[ -d "$xcframework_path" ]]; then + cat > "$xcframework_path/Info.plist" << 'EOF' + + + + + AvailableLibraries + + + BinaryPath + libarti_bitchat.a + HeadersPath + Headers + LibraryIdentifier + ios-arm64 + LibraryPath + libarti_bitchat.a + SupportedArchitectures + + arm64 + + SupportedPlatform + ios + + + BinaryPath + libarti_bitchat.a + HeadersPath + Headers + LibraryIdentifier + ios-arm64-simulator + LibraryPath + libarti_bitchat.a + SupportedArchitectures + + arm64 + + SupportedPlatform + ios + SupportedPlatformVariant + simulator + + + BinaryPath + libarti_bitchat.a + HeadersPath + Headers + LibraryIdentifier + macos-arm64 + LibraryPath + libarti_bitchat.a + SupportedArchitectures + + arm64 + + SupportedPlatform + macos + + + CFBundlePackageType + XFWK + XCFrameworkFormatVersion + 1.0 + + +EOF local size=$(du -sh "$xcframework_path" | cut -f1) log_info "Created $xcframework_path ($size)" else