Architecture hardening: Tor reliability, supply-chain CI gate, BLE handler extraction, coordinator contexts (#1331)

Architecture hardening: Tor reliability, supply-chain CI gate, BLE handler extraction, coordinator contexts
This commit is contained in:
jack
2026-06-10 20:24:39 +02:00
committed by GitHub
56 changed files with 6663 additions and 1057 deletions
+85
View File
@@ -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 "<sha256> <path>" 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
+22
View File
@@ -40,3 +40,25 @@ jobs:
- name: Run Tests - name: Run Tests
run: swift test --parallel --quiet --package-path ${{ matrix.path }} 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
+42 -16
View File
@@ -1,6 +1,6 @@
# bitchat Privacy Policy # bitchat Privacy Policy
*Last updated: January 2025* *Last updated: June 2026*
## Our Commitment ## Our Commitment
@@ -9,7 +9,7 @@ bitchat is designed with privacy as its foundation. We believe private communica
## Summary ## Summary
- **No personal data collection** - We don't collect names, emails, or phone numbers - **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 - **No tracking** - We have no analytics, telemetry, or user tracking
- **Open source** - You can verify these claims by reading our code - **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 ### On Your Device Only
1. **Identity Key** 1. **Identity Keys**
- A cryptographic key generated on first launch - Cryptographic private keys generated on first launch or when optional Nostr identities are created
- Stored locally in your device's secure storage - Stored locally in your device's secure storage
- Allows you to maintain "favorite" relationships across app restarts - 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** 2. **Nickname**
- The display name you choose (or auto-generated) - 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 - Stored only on your device
- Allows you to recognize these peers in future sessions - 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 ### Temporary Session Data
During each session, bitchat temporarily maintains: During each session, bitchat temporarily maintains:
- Active peer connections (forgotten when app closes) - Active peer connections (forgotten when app closes)
- Routing information for message delivery - Routing information for message delivery
- Cached messages for offline peers (12 hours max) - 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 ## What Information is Shared
@@ -62,13 +69,21 @@ When you join a password-protected room:
- Your nickname appears in the member list - Your nickname appears in the member list
- Room owners can see you've joined - 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 ## What We DON'T Do
bitchat **never**: bitchat **never**:
- Collects personal information - Collects personal information
- Tracks your location - Sells or shares your exact GPS location
- Stores data on servers - Stores data on servers we operate
- Shares data with third parties - Sells your data to advertisers or data brokers
- Uses analytics or telemetry - Uses analytics or telemetry
- Creates user profiles - Creates user profiles
- Requires registration - Requires registration
@@ -84,19 +99,27 @@ All private messages use end-to-end encryption:
## Your Rights ## Your Rights
You have complete control: You have complete control:
- **Delete Everything**: Triple-tap the logo to instantly wipe all data - **Delete Local State**: Triple-tap the logo to instantly wipe local keys, sessions, caches, and preferences
- **Leave Anytime**: Close the app and your presence disappears - **Leave Anytime**: Close the app and local presence stops; relay-backed presence ages out
- **No Account**: Nothing to delete from servers because there are none - **No Account**: No account record exists for you to delete from us
- **Portability**: Your data never leaves your device unless you export it - **Portability**: Your local state stays on your device unless you send messages, use optional relay-backed features, or export it
## Bluetooth & Permissions ## Bluetooth & Permissions
bitchat requires Bluetooth permission to function: bitchat requires Bluetooth permission to function:
- Used only for peer-to-peer communication - Used only for peer-to-peer communication
- No location data is accessed or stored
- Bluetooth is not used for tracking - Bluetooth is not used for tracking
- You can revoke this permission at any time in system settings - 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 ## 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. 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) - **Messages**: Deleted from memory when app closes (unless room retention is enabled)
- **Identity Key**: Persists until you delete the app - **Identity Key**: Persists until you delete the app
- **Favorites**: Persist until you remove them or 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 - **Everything Else**: Exists only during active sessions
## Security Measures ## Security Measures
- All communication is encrypted - 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 - Open source code for public audit
- Regular security updates - Regular security updates
- Cryptographic signatures prevent tampering - 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: If we update this policy:
- The "Last updated" date will change - The "Last updated" date will change
- The updated policy will be included in the app - 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 ## Contact
@@ -132,7 +158,7 @@ bitchat is an open source project. For privacy questions:
## Philosophy ## 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.
--- ---
+29 -9
View File
@@ -207,9 +207,14 @@ final class ConversationStore: ObservableObject {
private var directHandlesByConversation: [ConversationID: PeerHandle] = [:] private var directHandlesByConversation: [ConversationID: PeerHandle] = [:]
func setActiveChannel(_ channelID: ChannelID) { func setActiveChannel(_ channelID: ChannelID) {
activeChannel = channelID if activeChannel != channelID {
activeChannel = channelID
}
if selectedPrivatePeerID == nil { 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, activeChannel: ChannelID,
identityResolver: IdentityResolver identityResolver: IdentityResolver
) { ) {
self.activeChannel = activeChannel if self.activeChannel != activeChannel {
selectedPrivatePeerID = peerID self.activeChannel = activeChannel
}
if selectedPrivatePeerID != peerID {
selectedPrivatePeerID = peerID
}
if let peerID { if let peerID {
selectedConversationID = directConversationID( let conversationID = directConversationID(
for: peerID, for: peerID,
identityResolver: identityResolver identityResolver: identityResolver
) )
if selectedConversationID != conversationID {
selectedConversationID = conversationID
}
} else { } else {
selectedConversationID = ConversationID(channelID: activeChannel) let conversationID = ConversationID(channelID: activeChannel)
if selectedConversationID != conversationID {
selectedConversationID = conversationID
}
} }
} }
func replaceMessages(_ messages: [BitchatMessage], for conversationID: 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) { func replaceMessages(_ messages: [BitchatMessage], for channelID: ChannelID) {
@@ -296,7 +313,7 @@ final class ConversationStore: ObservableObject {
let conversationID = ConversationID.direct(handle) let conversationID = ConversationID.direct(handle)
liveConversations.insert(conversationID) liveConversations.insert(conversationID)
directHandlesByConversation[conversationID] = handle directHandlesByConversation[conversationID] = handle
messagesByConversation[conversationID] = normalized(messages) replaceMessages(messages, for: conversationID)
} }
let staleDirectConversations = messagesByConversation.keys.filter { conversationID in 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) let handle = identityResolver.canonicalHandle(for: peerID)
result.insert(.direct(handle)) result.insert(.direct(handle))
} }
if unreadConversations != nextUnreadConversations {
unreadConversations = nextUnreadConversations
}
} }
func markRead(_ conversationID: ConversationID) { func markRead(_ conversationID: ConversationID) {
@@ -63,6 +63,7 @@ final class PrivateInboxModel: ObservableObject {
nextMessagesByPeerID[peerID] = [] nextMessagesByPeerID[peerID] = []
} }
guard messagesByPeerID != nextMessagesByPeerID else { return }
messagesByPeerID = nextMessagesByPeerID messagesByPeerID = nextMessagesByPeerID
} }
} }
+3 -1
View File
@@ -36,6 +36,8 @@ final class PublicChatModel: ObservableObject {
} }
private func refreshMessages() { 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
} }
} }
+1 -1
View File
@@ -24397,7 +24397,7 @@
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
"state" : "translated", "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" : { "es" : {
+1 -1
View File
@@ -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) return RequestSyncPacket(p: pp, m: mm, data: dd, types: types, sinceTimestamp: sinceTimestamp, fragmentIdFilter: fragmentIdFilter)
} }
} }
+129 -14
View File
@@ -142,11 +142,22 @@ final class NostrRelayManager: ObservableObject {
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON) private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
private var messageHandlers: [String: (NostrEvent) -> Void] = [:] 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<InboundEventKey>()
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. // Coalesce duplicate subscribe requests for the same id within a short window.
private let subscribeCoalesceInterval: TimeInterval = 1.0 private let subscribeCoalesceInterval: TimeInterval = 1.0
private var subscribeCoalesce: [String: Date] = [:] private var subscribeCoalesce: [String: Date] = [:]
private var pendingTorConnectionURLs = Set<String>() private var pendingTorConnectionURLs = Set<String>()
private var awaitingTorForConnections = false private var awaitingTorForConnections = false
private var torReadyWaitAttempts = 0
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private struct SubscriptionRequestState: Equatable { private struct SubscriptionRequestState: Equatable {
@@ -263,6 +274,7 @@ final class NostrRelayManager: ObservableObject {
eoseTrackers.removeAll() eoseTrackers.removeAll()
pendingTorConnectionURLs.removeAll() pendingTorConnectionURLs.removeAll()
awaitingTorForConnections = false awaitingTorForConnections = false
torReadyWaitAttempts = 0
updateConnectionStatus() updateConnectionStatus()
} }
@@ -285,11 +297,14 @@ final class NostrRelayManager: ObservableObject {
// Global network policy gate // Global network policy gate
guard dependencies.activationAllowed() else { return } guard dependencies.activationAllowed() else { return }
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() { if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
// Defer sends until Tor is ready to avoid premature queueing // Fail-closed: nothing touches the network until Tor is up. Queue the
dependencies.awaitTorReady { [weak self] ready in // event locally so it survives a slow bootstrap (queued sends flush
guard let self = self else { return } // when relays connect), then kick off connection setup, which itself
if ready { self.sendEvent(event, to: relayUrls) } // 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 return
} }
let requestedRelays = relayUrls ?? Self.defaultRelays let requestedRelays = relayUrls ?? Self.defaultRelays
@@ -307,12 +322,20 @@ final class NostrRelayManager: ObservableObject {
} }
} }
if !stillPending.isEmpty { if !stillPending.isEmpty {
messageQueueLock.lock() enqueuePendingSend(event, pendingRelays: stillPending)
messageQueue.append(PendingSend(event: event, pendingRelays: stillPending))
messageQueueLock.unlock()
} }
} }
private func enqueuePendingSend(_ event: NostrEvent, pendingRelays: Set<String>) {
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. /// Try to flush any queued messages for relays that are now connected.
private func flushMessageQueue(for relayUrl: String? = nil) { private func flushMessageQueue(for relayUrl: String? = nil) {
messageQueueLock.lock() messageQueueLock.lock()
@@ -486,6 +509,8 @@ final class NostrRelayManager: ObservableObject {
/// Unsubscribe from a subscription /// Unsubscribe from a subscription
func unsubscribe(id: String) { func unsubscribe(id: String) {
messageHandlers.removeValue(forKey: id) messageHandlers.removeValue(forKey: id)
removeRecentInboundEvents(forSubscriptionID: id)
duplicateInboundEventDropCountBySubscription.removeValue(forKey: id)
// Allow immediate re-subscription by clearing coalescer timestamp // Allow immediate re-subscription by clearing coalescer timestamp
subscribeCoalesce.removeValue(forKey: id) subscribeCoalesce.removeValue(forKey: id)
subscriptionRequestState.removeValue(forKey: id) subscriptionRequestState.removeValue(forKey: id)
@@ -561,14 +586,40 @@ final class NostrRelayManager: ObservableObject {
self.awaitingTorForConnections = false self.awaitingTorForConnections = false
guard ready else { 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 return
} }
self.torReadyWaitAttempts = 0
self.connectToRelays(pending, shouldLog: true) 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 { private func subscriptionStateExists(id: String, requestState: SubscriptionRequestState) -> Bool {
guard !requestState.relayURLs.isEmpty else { return true } guard !requestState.relayURLs.isEmpty else { return true }
return requestState.relayURLs.allSatisfy { url in return requestState.relayURLs.allSatisfy { url in
@@ -608,6 +659,53 @@ final class NostrRelayManager: ObservableObject {
startEOSETracking(id: id, relayURLs: requestState.relayURLs, callback: callback) 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) { private func connectToRelay(_ urlString: String) {
// Global network policy gate // Global network policy gate
@@ -722,12 +820,22 @@ final class NostrRelayManager: ObservableObject {
private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) { private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) {
switch parsed { switch parsed {
case .event(let subId, let event): 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 }) { if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) {
self.relays[index].messagesReceived += 1 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] { if let handler = self.messageHandlers[subId] {
handler(event) handler(event)
} else { } else {
@@ -919,6 +1027,14 @@ final class NostrRelayManager: ObservableObject {
pendingSubscriptions[relayUrl]?.count ?? 0 pendingSubscriptions[relayUrl]?.count ?? 0
} }
var debugDuplicateInboundEventDropCount: Int {
duplicateInboundEventDropCount
}
func debugDuplicateInboundEventDropCount(forSubscriptionID subscriptionID: String) -> Int {
duplicateInboundEventDropCountBySubscription[subscriptionID] ?? 0
}
func debugFlushMessageQueue() { func debugFlushMessageQueue() {
flushMessageQueue(for: nil) flushMessageQueue(for: nil)
} }
@@ -974,8 +1090,7 @@ private enum ParsedInbound {
if array.count >= 3, if array.count >= 3,
let subId = array[1] as? String, let subId = array[1] as? String,
let eventDict = array[2] as? [String: Any], let eventDict = array[2] as? [String: Any],
let event = try? NostrEvent(from: eventDict), let event = try? NostrEvent(from: eventDict) {
event.isValidSignature() {
self = .event(subId: subId, event: event) self = .event(subId: subId, event: event)
return return
} }
@@ -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)
}
}
}
@@ -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 ?? "<empty>")' 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)
}
}
@@ -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
)
}
}
}
+55 -8
View File
@@ -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 { final class BLELinkStateStore {
private(set) var peripherals: [String: BLEPeripheralLinkState] = [:] private(set) var peripherals: [String: BLEPeripheralLinkState] = [:]
private(set) var peerToPeripheralUUID: [PeerID: String] = [:] private(set) var peerToPeripheralUUID: [PeerID: String] = [:]
private(set) var subscribedCentrals: [CBCentral] = [] private(set) var subscribedCentrals: [CBCentral] = []
private(set) var centralToPeerID: [String: PeerID] = [:] 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] { var peripheralStates: [BLEPeripheralLinkState] {
Array(peripherals.values) assertOwned()
return Array(peripherals.values)
} }
var subscribedCentralSnapshot: BLESubscribedCentralSnapshot { var subscribedCentralSnapshot: BLESubscribedCentralSnapshot {
BLESubscribedCentralSnapshot( assertOwned()
return BLESubscribedCentralSnapshot(
centrals: subscribedCentrals, centrals: subscribedCentrals,
peerIDsByCentralUUID: centralToPeerID peerIDsByCentralUUID: centralToPeerID
) )
} }
var subscribedCentralCount: Int { var subscribedCentralCount: Int {
subscribedCentrals.count assertOwned()
return subscribedCentrals.count
} }
var connectedOrConnectingPeripheralCount: Int { 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? { func state(forPeripheralID peripheralID: String) -> BLEPeripheralLinkState? {
peripherals[peripheralID] assertOwned()
return peripherals[peripheralID]
} }
func setPeripheralState(_ state: BLEPeripheralLinkState, for peripheralID: String) { func setPeripheralState(_ state: BLEPeripheralLinkState, for peripheralID: String) {
assertOwned()
peripherals[peripheralID] = state peripherals[peripheralID] = state
} }
@@ -64,6 +97,7 @@ final class BLELinkStateStore {
_ peripheralID: String, _ peripheralID: String,
_ update: (inout BLEPeripheralLinkState) -> Void _ update: (inout BLEPeripheralLinkState) -> Void
) -> BLEPeripheralLinkState? { ) -> BLEPeripheralLinkState? {
assertOwned()
guard var state = peripherals[peripheralID] else { return nil } guard var state = peripherals[peripheralID] else { return nil }
update(&state) update(&state)
peripherals[peripheralID] = state peripherals[peripheralID] = state
@@ -113,10 +147,12 @@ final class BLELinkStateStore {
} }
func directPeripheralState(for peerID: PeerID) -> BLEPeripheralLinkState? { func directPeripheralState(for peerID: PeerID) -> BLEPeripheralLinkState? {
peerToPeripheralUUID[peerID].flatMap { peripherals[$0] } assertOwned()
return peerToPeripheralUUID[peerID].flatMap { peripherals[$0] }
} }
func directLinkState(for peerID: PeerID) -> BLEDirectLinkState { func directLinkState(for peerID: PeerID) -> BLEDirectLinkState {
assertOwned()
let peripheralUUID = peerToPeripheralUUID[peerID] let peripheralUUID = peerToPeripheralUUID[peerID]
let hasPeripheral = peripheralUUID.flatMap { peripherals[$0]?.isConnected } ?? false let hasPeripheral = peripheralUUID.flatMap { peripherals[$0]?.isConnected } ?? false
let hasCentral = centralToPeerID.values.contains(peerID) let hasCentral = centralToPeerID.values.contains(peerID)
@@ -124,6 +160,7 @@ final class BLELinkStateStore {
} }
func links(to peerID: PeerID?) -> Set<BLEIngressLinkID> { func links(to peerID: PeerID?) -> Set<BLEIngressLinkID> {
assertOwned()
guard let peerID else { return [] } guard let peerID else { return [] }
var links: Set<BLEIngressLinkID> = [] var links: Set<BLEIngressLinkID> = []
@@ -137,35 +174,42 @@ final class BLELinkStateStore {
} }
func peerID(forPeripheralID peripheralID: String) -> PeerID? { func peerID(forPeripheralID peripheralID: String) -> PeerID? {
peripherals[peripheralID]?.peerID assertOwned()
return peripherals[peripheralID]?.peerID
} }
func peerID(forCentralUUID centralUUID: String) -> PeerID? { func peerID(forCentralUUID centralUUID: String) -> PeerID? {
centralToPeerID[centralUUID] assertOwned()
return centralToPeerID[centralUUID]
} }
func addSubscribedCentral(_ central: CBCentral) { func addSubscribedCentral(_ central: CBCentral) {
assertOwned()
guard !subscribedCentrals.contains(central) else { return } guard !subscribedCentrals.contains(central) else { return }
subscribedCentrals.append(central) subscribedCentrals.append(central)
} }
func removeSubscribedCentral(_ central: CBCentral) -> PeerID? { func removeSubscribedCentral(_ central: CBCentral) -> PeerID? {
assertOwned()
let centralUUID = central.identifier.uuidString let centralUUID = central.identifier.uuidString
subscribedCentrals.removeAll { $0.identifier == central.identifier } subscribedCentrals.removeAll { $0.identifier == central.identifier }
return centralToPeerID.removeValue(forKey: centralUUID) return centralToPeerID.removeValue(forKey: centralUUID)
} }
func bindCentral(_ centralUUID: String, to peerID: PeerID) { func bindCentral(_ centralUUID: String, to peerID: PeerID) {
assertOwned()
centralToPeerID[centralUUID] = peerID centralToPeerID[centralUUID] = peerID
} }
func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) { func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) {
assertOwned()
if updatePeripheral(peripheralUUID, { $0.peerID = peerID }) != nil { if updatePeripheral(peripheralUUID, { $0.peerID = peerID }) != nil {
peerToPeripheralUUID[peerID] = peripheralUUID peerToPeripheralUUID[peerID] = peripheralUUID
} }
} }
func removePeripheral(_ peripheralID: String) -> PeerID? { func removePeripheral(_ peripheralID: String) -> PeerID? {
assertOwned()
let peerID = peripherals.removeValue(forKey: peripheralID)?.peerID let peerID = peripherals.removeValue(forKey: peripheralID)?.peerID
if let peerID { if let peerID {
peerToPeripheralUUID.removeValue(forKey: peerID) peerToPeripheralUUID.removeValue(forKey: peerID)
@@ -174,6 +218,7 @@ final class BLELinkStateStore {
} }
func clearPeripherals() -> [PeerID] { func clearPeripherals() -> [PeerID] {
assertOwned()
let peerIDs = peripherals.compactMap { $0.value.peerID } let peerIDs = peripherals.compactMap { $0.value.peerID }
peripherals.removeAll() peripherals.removeAll()
peerToPeripheralUUID.removeAll() peerToPeripheralUUID.removeAll()
@@ -181,6 +226,7 @@ final class BLELinkStateStore {
} }
func clearCentrals() -> [PeerID] { func clearCentrals() -> [PeerID] {
assertOwned()
let peerIDs = Array(centralToPeerID.values) let peerIDs = Array(centralToPeerID.values)
subscribedCentrals.removeAll() subscribedCentrals.removeAll()
centralToPeerID.removeAll() centralToPeerID.removeAll()
@@ -188,6 +234,7 @@ final class BLELinkStateStore {
} }
func clearAll() { func clearAll() {
assertOwned()
peripherals.removeAll() peripherals.removeAll()
peerToPeripheralUUID.removeAll() peerToPeripheralUUID.removeAll()
subscribedCentrals.removeAll() subscribedCentrals.removeAll()
@@ -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)
}
}
}
@@ -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)
}
}
+262 -428
View File
@@ -110,6 +110,16 @@ final class BLEService: NSObject {
private var pendingDirectedRelays = BLEDirectedRelaySpool() private var pendingDirectedRelays = BLEDirectedRelaySpool()
// Debounce for 'reconnected' logs // Debounce for 'reconnected' logs
private var reconnectLogDebouncer = BLEPeerEventDebouncer() 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 // MARK: - Gossip Sync
private var gossipSyncManager: GossipSyncManager? private var gossipSyncManager: GossipSyncManager?
@@ -195,6 +205,9 @@ final class BLEService: NSObject {
// Tag BLE queue for re-entrancy detection // Tag BLE queue for re-entrancy detection
bleQueue.setSpecific(key: bleQueueKey, value: ()) 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 { if initializeBluetoothManagers {
// Initialize BLE on background queue to prevent main thread blocking. // 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) { 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 } /// Builds the file-transfer handler environment. All queue hops stay here
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer( /// so `BLEFileTransferHandler` remains queue-agnostic and synchronously
peerID: peerID, /// testable.
localPeerID: myPeerID, private func makeFileTransferHandlerEnvironment() -> BLEFileTransferHandlerEnvironment {
localNickname: myNickname, BLEFileTransferHandlerEnvironment(
peers: peersSnapshot, localPeerID: { [weak self] in
allowConnectedUnverified: true self?.myPeerID ?? PeerID(str: "")
) ?? signedSenderDisplayName(for: packet, from: peerID) else { },
SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))", category: .security) localNickname: { [weak self] in
return self?.myNickname ?? ""
} },
peersSnapshot: { [weak self] in
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: myPeerID) else { guard let self = self else { return [:] }
return return self.collectionsQueue.sync { self.peerRegistry.snapshotByID }
} },
if deliveryPlan.shouldTrackForSync { signedSenderDisplayName: { [weak self] packet, peerID in
gossipSyncManager?.onPublicPacketSeen(packet) self?.signedSenderDisplayName(for: packet, from: peerID)
} },
trackPacketSeen: { [weak self] packet in
let filePacket: BitchatFilePacket self?.gossipSyncManager?.onPublicPacketSeen(packet)
let mime: MimeType },
switch BLEIncomingFileValidator.validate(payload: packet.payload) { enforceStorageQuota: { [weak self] reservingBytes in
case .success(let acceptance): self?.incomingFileStore.enforceQuota(reservingBytes: reservingBytes)
filePacket = acceptance.filePacket },
mime = acceptance.mime saveIncomingFile: { [weak self] data, preferredName, subdirectory, fallbackExtension, defaultPrefix in
case .failure(.malformedPayload): self?.incomingFileStore.save(
SecureLogger.error("❌ Failed to decode file transfer payload", category: .session) data: data,
return preferredName: preferredName,
case .failure(.payloadTooLarge(let bytes)): subdirectory: subdirectory,
SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security) fallbackExtension: fallbackExtension,
return defaultPrefix: defaultPrefix
case .failure(.unsupportedMime(let mimeType, let bytes)): )
SecureLogger.warning("🚫 MIME REJECT: '\(mimeType ?? "<empty>")' not supported. Size=\(bytes)b from \(peerID.id.prefix(8))...", category: .security) },
return updatePeerLastSeen: { [weak self] peerID in
case .failure(.magicMismatch(let mime, let bytes, let prefixHex)): self?.updatePeerLastSeen(peerID)
SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(bytes)b prefix=[\(prefixHex)] from \(peerID.id.prefix(8))...", category: .security) },
return deliverMessage: { [weak self] message in
} // Single main-actor hop delivering `.messageReceived`.
self?.emitTransportEvent(.messageReceived(message))
// 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
) )
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
emitTransportEvent(.messageReceived(message))
} }
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
@@ -2775,74 +2759,39 @@ extension BLEService {
private func handleFragment(_ packet: BitchatPacket, from peerID: PeerID) { private func handleFragment(_ packet: BitchatPacket, from peerID: PeerID) {
if DispatchQueue.getSpecific(key: messageQueueKey) != nil { if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
_handleFragment(packet, from: peerID) fragmentHandler.handle(packet, from: peerID)
} else { } else {
messageQueue.async(flags: .barrier) { [weak self] in 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) { /// Builds the fragment handler environment. All queue hops stay here so
// Don't process our own fragments /// `BLEFragmentHandler` remains queue-agnostic and synchronously testable.
if peerID == myPeerID { private func makeFragmentHandlerEnvironment() -> BLEFragmentHandlerEnvironment {
return BLEFragmentHandlerEnvironment(
} localPeerID: { [weak self] in
self?.myPeerID ?? PeerID(str: "")
guard let header = BLEFragmentHeader(packet: packet) else { return } },
trackPacketSeen: { [weak self] packet in
if header.isBroadcastFragment { self?.gossipSyncManager?.onPublicPacketSeen(packet)
gossipSyncManager?.onPublicPacketSeen(packet) },
} appendFragment: { [weak self] header in
guard let self = self else {
let assemblyResult = collectionsQueue.sync(flags: .barrier) { return .stored(header: header, started: false)
fragmentAssemblyBuffer.append(header, maxInFlightAssemblies: maxInFlightAssemblies) }
} return self.collectionsQueue.sync(flags: .barrier) {
self.fragmentAssemblyBuffer.append(header, maxInFlightAssemblies: self.maxInFlightAssemblies)
logFragmentAssemblyResult(assemblyResult) }
},
guard case let .complete(completedHeader, reassembled, _) = assemblyResult else { return } isAcceptedIngressPayload: { [weak self] packet, innerSender in
self?.isAcceptedIngressPayload(packet, from: innerSender) ?? false
// Decode the original packet bytes we reassembled, so flags/compression are preserved },
if var originalPacket = BinaryProtocol.decode(reassembled) { processReassembledPacket: { [weak self] packet, peerID in
self?.handleReceivedPacket(packet, from: peerID)
// 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)
} }
} 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 // MARK: Packet Reception
@@ -2963,165 +2912,96 @@ extension BLEService {
} }
private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) { private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) {
let now = Date() announceHandler.handle(packet, from: peerID)
let preflight = BLEAnnouncePreflightPolicy.evaluate( }
packet: packet,
from: peerID,
localPeerID: myPeerID,
now: now
)
let announcement: AnnouncementPacket /// Builds the announce handler environment. All queue hops stay here so
switch preflight { /// `BLEAnnounceHandler` remains queue-agnostic and synchronously testable.
case .accept(let acceptance): private func makeAnnounceHandlerEnvironment() -> BLEAnnounceHandlerEnvironment {
announcement = acceptance.announcement BLEAnnounceHandlerEnvironment(
case .reject(.malformed): localPeerID: { [weak self] in
SecureLogger.error("❌ Failed to decode announce packet from \(peerID.id.prefix(8))", category: .session) self?.myPeerID ?? PeerID(str: "")
return },
case .reject(.senderMismatch(let derivedFromKey)): messageTTL: messageTTL,
SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))", category: .security) now: { Date() },
return existingNoisePublicKey: { [weak self] peerID in
case .reject(.selfAnnounce): guard let self = self else { return nil }
return return self.collectionsQueue.sync { self.peerRegistry.info(for: peerID)?.noisePublicKey }
case .reject(.stale(let ageSeconds)): },
SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session) verifySignature: { [weak self] packet, signingPublicKey in
return self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false
} },
linkState: { [weak self] peerID in
// Suppress announce logs to reduce noise self?.linkState(for: peerID) ?? (hasPeripheral: false, hasCentral: false)
},
// Precompute signature verification outside barrier to reduce contention withRegistryBarrier: { [weak self] body in
let existingPeerForVerify = collectionsQueue.sync { peerRegistry.info(for: peerID) } self?.collectionsQueue.sync(flags: .barrier) { body() }
let hasSignature = packet.signature != nil },
let signatureValid: Bool upsertVerifiedAnnounce: { [weak self] peerID, announcement, isConnected, now in
if hasSignature { // Called from inside withRegistryBarrier; access registry directly.
signatureValid = noiseService.verifyPacketSignature(packet, publicKey: announcement.signingPublicKey) self?.peerRegistry.upsertVerifiedAnnounce(
if !signatureValid { peerID: peerID,
SecureLogger.warning("⚠️ Signature verification for announce failed \(peerID.id.prefix(8))", category: .security) nickname: announcement.nickname,
} noisePublicKey: announcement.noisePublicKey,
} else { signingPublicKey: announcement.signingPublicKey,
signatureValid = false isConnected: isConnected,
} now: now
let trustDecision = BLEAnnounceTrustPolicy.evaluate( ) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
hasSignature: hasSignature, },
signatureValid: signatureValid, shouldEmitReconnectLog: { [weak self] peerID, now in
existingNoisePublicKey: existingPeerForVerify?.noisePublicKey, // Called from inside withRegistryBarrier; access debouncer directly.
announcedNoisePublicKey: announcement.noisePublicKey self?.reconnectLogDebouncer.shouldEmit(
) peerID: peerID,
if case .reject(.keyMismatch) = trustDecision { now: now,
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security) minimumInterval: TransportConfig.bleReconnectLogDebounceSeconds
} ) ?? false
let verifiedAnnounce = trustDecision.isVerified },
updateTopology: { [weak self] peerID, neighbors in
var isNewPeer = false self?.meshTopology.updateNeighbors(for: peerID.routingData, neighbors: neighbors)
var isReconnectedPeer = false },
let directLinkState = linkState(for: peerID) persistIdentity: { [weak self] announcement in
let isDirectAnnounce = packet.ttl == messageTTL self?.identityManager.upsertCryptographicIdentity(
fingerprint: announcement.noisePublicKey.sha256Fingerprint(),
collectionsQueue.sync(flags: .barrier) { noisePublicKey: announcement.noisePublicKey,
let hasPeripheralConnection = directLinkState.hasPeripheral signingPublicKey: announcement.signingPublicKey,
let hasCentralSubscription = directLinkState.hasCentral claimedNickname: announcement.nickname
)
// Require verified announce; ignore otherwise (no backward compatibility) },
if !verifiedAnnounce { dedupContains: { [weak self] id in
SecureLogger.warning("❌ Ignoring unverified announce from \(peerID.id.prefix(8))", category: .security) self?.messageDeduplicator.contains(id) ?? true
// Reset flags to prevent post-barrier code from acting on unverified announces },
isNewPeer = false dedupMarkProcessed: { [weak self] id in
isReconnectedPeer = false self?.messageDeduplicator.markProcessed(id)
return },
} deliverAnnounceUIEvents: { [weak self] peerID, notifyPeerConnected, scheduleInitialSync in
// Single main-actor hop so event order is guaranteed:
let update = peerRegistry.upsertVerifiedAnnounce( // .peerConnected initial sync scheduling .peerListUpdated.
peerID: peerID, self?.notifyUI { [weak self] in
nickname: announcement.nickname, guard let self = self else { return }
noisePublicKey: announcement.noisePublicKey, if notifyPeerConnected {
signingPublicKey: announcement.signingPublicKey, self.deliverTransportEvent(.peerConnected(peerID))
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)
} }
} else if let previousNickname = update.previousNickname, previousNickname != announcement.nickname { if scheduleInitialSync {
SecureLogger.debug("🔄 Peer \(peerID.id.prefix(8))… changed nickname: \(previousNickname) -> \(announcement.nickname)", category: .session) 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))
} }
} },
} trackPacketSeen: { [weak self] packet in
self?.gossipSyncManager?.onPublicPacketSeen(packet)
// Update topology with verified neighbor claims (only for authenticated announces) },
if verifiedAnnounce, let neighbors = announcement.directNeighbors { sendAnnounceBack: { [weak self] in
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
self?.sendAnnounce(forceSend: true) 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 // Handle REQUEST_SYNC: decode payload and respond with missing packets via sync manager
@@ -3136,156 +3016,110 @@ extension BLEService {
// Mention parsing moved to ChatViewModel // Mention parsing moved to ChatViewModel
private func handleMessage(_ packet: BitchatPacket, from peerID: PeerID) { private func handleMessage(_ packet: BitchatPacket, from peerID: PeerID) {
let now = Date() publicMessageHandler.handle(packet, from: peerID)
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
)
)
}
} }
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) { /// Builds the public-message handler environment. All queue hops stay here
// Use NoiseEncryptionService for handshake processing /// so `BLEPublicMessageHandler` remains queue-agnostic and synchronously
if PeerID(hexData: packet.recipientID) == myPeerID { /// testable.
// Handshake is for us private func makePublicMessageHandlerEnvironment() -> BLEPublicMessageHandlerEnvironment {
do { BLEPublicMessageHandlerEnvironment(
if let response = try noiseService.processHandshakeMessage(from: peerID, message: packet.payload) { localPeerID: { [weak self] in
// Send response self?.myPeerID ?? PeerID(str: "")
let responsePacket = BitchatPacket( },
type: MessageType.noiseHandshake.rawValue, localNickname: { [weak self] in
senderID: myPeerIDData, self?.myNickname ?? ""
recipientID: Data(hexString: peerID.id), },
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), now: { Date() },
payload: response, peersSnapshot: { [weak self] in
signature: nil, guard let self = self else { return [:] }
ttl: messageTTL 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) { private func handleNoiseEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
guard let recipientID = PeerID(hexData: packet.recipientID) else { noisePacketHandler.handleEncrypted(packet, from: peerID)
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()
guard let noisePayloadType = NoisePayloadType(rawValue: payloadType) else { /// Builds the Noise packet handler environment. All queue hops and
SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)") /// `noiseService` crypto calls stay here so `BLENoisePacketHandler`
return /// 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 // MARK: Helper Functions
+60 -16
View File
@@ -13,6 +13,7 @@ final class MessageRouter {
let nickname: String let nickname: String
let messageID: String let messageID: String
let timestamp: Date let timestamp: Date
var sendAttempts: Int = 0
} }
private var outbox: [PeerID: [QueuedMessage]] = [:] private var outbox: [PeerID: [QueuedMessage]] = [:]
@@ -20,6 +21,9 @@ final class MessageRouter {
// Outbox limits to prevent unbounded memory growth // Outbox limits to prevent unbounded memory growth
private static let maxMessagesPerPeer = 100 private static let maxMessagesPerPeer = 100
private static let messageTTLSeconds: TimeInterval = 24 * 60 * 60 // 24 hours 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]) { init(transports: [Transport]) {
self.transports = transports self.transports = transports
@@ -61,26 +65,53 @@ final class MessageRouter {
// MARK: - Message Sending // MARK: - Message Sending
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
if let transport = reachableTransport(for: peerID) { if let transport = connectedTransport(for: peerID) {
SecureLogger.debug("Routing PM via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session) // 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) 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 { } else {
// Queue for later with timestamp for TTL tracking var unsent = message
if outbox[peerID] == nil { outbox[peerID] = [] } unsent.sendAttempts = 0
enqueue(unsent, for: 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)
}
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session) 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) { func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
if let transport = reachableTransport(for: 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) 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 continue
} }
if let transport = reachableTransport(for: peerID) { if let transport = connectedTransport(for: peerID) {
SecureLogger.debug("Outbox -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))", category: .session) // 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) 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 { } else {
remaining.append(message) remaining.append(message)
} }
+8
View File
@@ -18,6 +18,7 @@ enum TransportConfig {
static let meshTimelineCap: Int = 1337 static let meshTimelineCap: Int = 1337
static let geoTimelineCap: Int = 1337 static let geoTimelineCap: Int = 1337
static let contentLRUCap: Int = 2000 static let contentLRUCap: Int = 2000
static let geoSamplingEventLRUCap: Int = 2000
// Timers // Timers
static let networkResetGraceSeconds: TimeInterval = 600 // 10 minutes static let networkResetGraceSeconds: TimeInterval = 600 // 10 minutes
@@ -43,6 +44,9 @@ enum TransportConfig {
// Nostr // Nostr
static let nostrReadAckInterval: TimeInterval = 0.35 // ~3 per second 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 // UI thresholds
static let uiLateInsertThreshold: TimeInterval = 15.0 static let uiLateInsertThreshold: TimeInterval = 15.0
@@ -146,6 +150,10 @@ enum TransportConfig {
static let nostrRelayBackoffMultiplier: Double = 2.0 static let nostrRelayBackoffMultiplier: Double = 2.0
static let nostrRelayMaxReconnectAttempts: Int = 10 static let nostrRelayMaxReconnectAttempts: Int = 10
static let nostrRelayDefaultFetchLimit: Int = 100 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 // Geo relay directory
static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24 static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24
+8
View File
@@ -12,6 +12,11 @@ import CryptoKit
enum GCSFilter { enum GCSFilter {
struct Params { let p: Int; let m: UInt32; let data: Data } 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) // Derive P from FPR (~ 1 / 2^P)
static func deriveP(targetFpr: Double) -> Int { static func deriveP(targetFpr: Double) -> Int {
let f = max(0.000001, min(0.25, targetFpr)) 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] { 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] = [] var values: [UInt64] = []
let reader = BitReader(data) let reader = BitReader(data)
var acc: UInt64 = 0 var acc: UInt64 = 0
+249 -31
View File
@@ -2,29 +2,65 @@ import BitFoundation
import BitLogger import BitLogger
import Foundation import Foundation
final class ChatDeliveryCoordinator { /// The narrow surface `ChatDeliveryCoordinator` needs from its owner.
private unowned let viewModel: ChatViewModel ///
/// 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<String> { 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) { extension ChatViewModel: ChatDeliveryContext {
self.viewModel = viewModel 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<MessageLocation>] = [:]
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 @MainActor
func cleanupOldReadReceipts() { func cleanupOldReadReceipts() {
guard !viewModel.isStartupPhase, !viewModel.privateChats.isEmpty else { guard !context.isStartupPhase, !context.privateChats.isEmpty else {
return return
} }
let validMessageIDs = Set( let validMessageIDs = Set(
viewModel.privateChats.values.flatMap { messages in context.privateChats.values.flatMap { messages in
messages.map(\.id) messages.map(\.id)
} }
) )
let oldCount = viewModel.sentReadReceipts.count let oldCount = context.sentReadReceipts.count
viewModel.sentReadReceipts = viewModel.sentReadReceipts.intersection(validMessageIDs) context.sentReadReceipts = context.sentReadReceipts.intersection(validMessageIDs)
let removedCount = oldCount - viewModel.sentReadReceipts.count let removedCount = oldCount - context.sentReadReceipts.count
if removedCount > 0 { if removedCount > 0 {
SecureLogger.debug("🧹 Cleaned up \(removedCount) old read receipts", category: .session) SecureLogger.debug("🧹 Cleaned up \(removedCount) old read receipts", category: .session)
} }
@@ -45,48 +81,65 @@ final class ChatDeliveryCoordinator {
@MainActor @MainActor
func deliveryStatus(for messageID: String) -> DeliveryStatus? { func deliveryStatus(for messageID: String) -> DeliveryStatus? {
if let message = viewModel.messages.first(where: { $0.id == messageID }) { withValidLocations(for: messageID) { locations in
return message.deliveryStatus 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 @MainActor
@discardableResult @discardableResult
func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool { 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 }) { var didUpdateStatus = false
let currentStatus = viewModel.messages[index].deliveryStatus 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) { if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) {
viewModel.messages[index].deliveryStatus = status context.messages[index].deliveryStatus = status
didUpdateStatus = true didUpdateStatus = true
} }
} }
var privateChats = viewModel.privateChats var privateChats = context.privateChats
for (peerID, chatMessages) in privateChats { for location in locations {
guard let index = chatMessages.firstIndex(where: { $0.id == messageID }) else { continue } 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 let currentStatus = chatMessages[index].deliveryStatus
guard !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) else { continue } guard !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) else { continue }
let updatedMessages = chatMessages chatMessages[index].deliveryStatus = status
updatedMessages[index].deliveryStatus = status privateChats[peerID] = chatMessages
privateChats[peerID] = updatedMessages
didUpdateStatus = true didUpdateStatus = true
didUpdatePrivateStatus = true
}
if didUpdatePrivateStatus {
context.privateChats = privateChats
} }
if didUpdateStatus { if didUpdateStatus {
viewModel.privateChats = privateChats context.notifyUIChanged()
viewModel.objectWillChange.send()
} }
return didUpdateStatus return didUpdateStatus
@@ -94,8 +147,14 @@ final class ChatDeliveryCoordinator {
} }
private extension ChatDeliveryCoordinator { private extension ChatDeliveryCoordinator {
enum MessageLocation: Hashable {
case publicTimeline(index: Int)
case privateChat(peerID: PeerID, index: Int)
}
func shouldSkipUpdate(currentStatus: DeliveryStatus?, newStatus: DeliveryStatus) -> Bool { func shouldSkipUpdate(currentStatus: DeliveryStatus?, newStatus: DeliveryStatus) -> Bool {
guard let currentStatus else { return false } guard let currentStatus else { return false }
if currentStatus == newStatus { return true }
switch (currentStatus, newStatus) { switch (currentStatus, newStatus) {
case (.read, .delivered), (.read, .sent): case (.read, .delivered), (.read, .sent):
@@ -104,4 +163,163 @@ private extension ChatDeliveryCoordinator {
return false return false
} }
} }
@MainActor
func withValidLocations<T>(
for messageID: String,
_ body: (Set<MessageLocation>) -> 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..<context.messages.count {
add(.publicTimeline(index: index), for: context.messages[index].id)
}
indexedPublicMessageCount = context.messages.count
indexedPublicTailMessageID = context.messages.last?.id
}
let currentPeerIDs = Set(context.privateChats.keys)
if !Set(indexedPrivateMessageCounts.keys).isSubset(of: currentPeerIDs) {
rebuildMessageLocationIndex()
return true
}
for (peerID, messages) in context.privateChats {
let indexedCount = indexedPrivateMessageCounts[peerID] ?? 0
if messages.count < indexedCount {
rebuildMessageLocationIndex()
return true
}
if messages.count == indexedCount,
messages.last?.id != indexedPrivateTailMessageIDs[peerID] {
rebuildMessageLocationIndex()
return true
}
guard messages.count > 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..<messages.count {
add(.privateChat(peerID: peerID, index: index), for: messages[index].id)
}
indexedPrivateMessageCounts[peerID] = messages.count
if let tailID = messages.last?.id {
indexedPrivateTailMessageIDs[peerID] = tailID
} else {
indexedPrivateTailMessageIDs.removeValue(forKey: peerID)
}
}
return false
}
@MainActor
func rebuildMessageLocationIndex() {
messageLocationIndex.removeAll(keepingCapacity: true)
for (index, message) in context.messages.enumerated() {
add(.publicTimeline(index: index), for: message.id)
}
indexedPublicMessageCount = context.messages.count
indexedPublicTailMessageID = context.messages.last?.id
indexedPrivateMessageCounts.removeAll(keepingCapacity: true)
indexedPrivateTailMessageIDs.removeAll(keepingCapacity: true)
for (peerID, messages) in context.privateChats {
for (index, message) in messages.enumerated() {
add(.privateChat(peerID: peerID, index: index), for: message.id)
}
indexedPrivateMessageCounts[peerID] = messages.count
if let tailID = messages.last?.id {
indexedPrivateTailMessageIDs[peerID] = tailID
}
}
hasBuiltMessageLocationIndex = true
}
func add(_ location: MessageLocation, for messageID: String) {
messageLocationIndex[messageID, default: []].insert(location)
}
} }
@@ -34,11 +34,11 @@ final class ChatMediaTransferCoordinator {
let transferId = makeTransferID(messageID: messageID) let transferId = makeTransferID(messageID: messageID)
Task.detached(priority: .userInitiated) { [weak self] in Task.detached(priority: .userInitiated) { [weak self] in
guard let self else { return }
do { do {
let packet = try ChatMediaPreparation.prepareVoiceNotePacket(at: url) let packet = try ChatMediaPreparation.prepareVoiceNotePacket(at: url)
await MainActor.run { await MainActor.run { [weak self] in
guard let self else { return }
self.registerTransfer(transferId: transferId, messageID: messageID) self.registerTransfer(transferId: transferId, messageID: messageID)
if let peerID = targetPeer { if let peerID = targetPeer {
self.viewModel.meshService.sendFilePrivate(packet, to: peerID, transferId: transferId) self.viewModel.meshService.sendFilePrivate(packet, to: peerID, transferId: transferId)
@@ -49,12 +49,14 @@ final class ChatMediaTransferCoordinator {
} catch ChatMediaPreparationError.voiceNoteTooLarge(let size) { } catch ChatMediaPreparationError.voiceNoteTooLarge(let size) {
SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session) SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session)
try? FileManager.default.removeItem(at: url) try? FileManager.default.removeItem(at: url)
await MainActor.run { await MainActor.run { [weak self] in
guard let self else { return }
self.handleMediaSendFailure(messageID: messageID, reason: "Voice note too large") self.handleMediaSendFailure(messageID: messageID, reason: "Voice note too large")
} }
} catch { } catch {
SecureLogger.error("Voice note send failed: \(error)", category: .session) SecureLogger.error("Voice note send failed: \(error)", category: .session)
await MainActor.run { await MainActor.run { [weak self] in
guard let self else { return }
self.handleMediaSendFailure(messageID: messageID, reason: "Failed to send voice note") self.handleMediaSendFailure(messageID: messageID, reason: "Failed to send voice note")
} }
} }
@@ -65,10 +67,10 @@ final class ChatMediaTransferCoordinator {
func processThenSendImage(_ image: UIImage?) { func processThenSendImage(_ image: UIImage?) {
guard let image else { return } guard let image else { return }
Task.detached { [weak self] in Task.detached { [weak self] in
guard let self else { return }
do { do {
let processedURL = try ImageUtils.processImage(image) let processedURL = try ImageUtils.processImage(image)
await MainActor.run { await MainActor.run { [weak self] in
guard let self else { return }
self.sendImage(from: processedURL) self.sendImage(from: processedURL)
} }
} catch { } catch {
@@ -80,10 +82,10 @@ final class ChatMediaTransferCoordinator {
func processThenSendImage(from url: URL?) { func processThenSendImage(from url: URL?) {
guard let url else { return } guard let url else { return }
Task.detached { [weak self] in Task.detached { [weak self] in
guard let self else { return }
do { do {
let processedURL = try ImageUtils.processImage(at: url) let processedURL = try ImageUtils.processImage(at: url)
await MainActor.run { await MainActor.run { [weak self] in
guard let self else { return }
self.sendImage(from: processedURL) self.sendImage(from: processedURL)
} }
} catch { } catch {
@@ -112,11 +114,11 @@ final class ChatMediaTransferCoordinator {
} }
Task.detached(priority: .userInitiated) { [weak self] in Task.detached(priority: .userInitiated) { [weak self] in
guard let self else { return }
do { do {
let prepared = try ChatMediaPreparation.prepareImagePacket(from: sourceURL) let prepared = try ChatMediaPreparation.prepareImagePacket(from: sourceURL)
await MainActor.run { await MainActor.run { [weak self] in
guard let self else { return }
let message = self.enqueueMediaMessage( let message = self.enqueueMediaMessage(
content: "\(MimeType.Category.image.messagePrefix)\(prepared.outputURL.lastPathComponent)", content: "\(MimeType.Category.image.messagePrefix)\(prepared.outputURL.lastPathComponent)",
targetPeer: targetPeer targetPeer: targetPeer
@@ -132,12 +134,14 @@ final class ChatMediaTransferCoordinator {
} }
} catch ChatMediaPreparationError.imageTooLarge(let size) { } catch ChatMediaPreparationError.imageTooLarge(let size) {
SecureLogger.warning("Processed image exceeds size limit (\(size) bytes)", category: .session) SecureLogger.warning("Processed image exceeds size limit (\(size) bytes)", category: .session)
await MainActor.run { await MainActor.run { [weak self] in
guard let self else { return }
self.viewModel.addSystemMessage("Image is too large to send.") self.viewModel.addSystemMessage("Image is too large to send.")
} }
} catch { } catch {
SecureLogger.error("Image send preparation failed: \(error)", category: .session) SecureLogger.error("Image send preparation failed: \(error)", category: .session)
await MainActor.run { await MainActor.run { [weak self] in
guard let self else { return }
self.viewModel.addSystemMessage("Failed to prepare image for sending.") self.viewModel.addSystemMessage("Failed to prepare image for sending.")
} }
} }
+386 -165
View File
@@ -4,22 +4,208 @@ import Foundation
import SwiftUI import SwiftUI
import Tor import Tor
final class ChatNostrCoordinator { /// The narrow surface `ChatNostrCoordinator` needs from its owner.
private unowned let viewModel: ChatViewModel ///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
/// minimal context it actually uses instead of holding a back-reference to the
/// whole `ChatViewModel`. This keeps the coordinator independently testable
/// (see `ChatNostrCoordinatorContextTests`) and makes its true dependencies
/// explicit. The surface is intentionally large it documents the
/// coordinator's real coupling to channel/subscription state, the inbound
/// Nostr event pipeline, geohash presence, and the ack transports.
@MainActor
protocol ChatNostrContext: AnyObject {
// MARK: Channel & subscription state
var activeChannel: ChannelID { get set }
var currentGeohash: String? { get set }
var geoSubscriptionID: String? { get set }
var geoDmSubscriptionID: String? { get set }
/// Geohash sampling subscriptions: subscription ID -> 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) { // MARK: Public timeline & pipeline
self.viewModel = viewModel 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<String>()
private var recentGeoSamplingEventIDOrder: [String] = []
init(context: any ChatNostrContext) {
self.context = context
} }
@MainActor @MainActor
func resubscribeCurrentGeohash() { func resubscribeCurrentGeohash() {
guard case .location(let channel) = viewModel.activeChannel else { return } guard let context else { return }
guard let subID = viewModel.geoSubscriptionID else { guard case .location(let channel) = context.activeChannel else { return }
switchLocationChannel(to: viewModel.activeChannel) guard let subID = context.geoSubscriptionID else {
switchLocationChannel(to: context.activeChannel)
return return
} }
viewModel.participantTracker.startRefreshTimer() context.startGeoParticipantRefreshTimer()
NostrRelayManager.shared.unsubscribe(id: subID) NostrRelayManager.shared.unsubscribe(id: subID)
let filter = NostrFilter.geohashEphemeral( let filter = NostrFilter.geohashEphemeral(
channel.geohash, channel.geohash,
@@ -36,14 +222,14 @@ final class ChatNostrCoordinator {
} }
} }
if let dmSub = viewModel.geoDmSubscriptionID { if let dmSub = context.geoDmSubscriptionID {
NostrRelayManager.shared.unsubscribe(id: dmSub) 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)" let dmSub = "geo-dm-\(channel.geohash)"
viewModel.geoDmSubscriptionID = dmSub context.geoDmSubscriptionID = dmSub
let dmFilter = NostrFilter.giftWrapsFor( let dmFilter = NostrFilter.giftWrapsFor(
pubkey: identity.publicKeyHex, pubkey: identity.publicKeyHex,
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
@@ -58,18 +244,19 @@ final class ChatNostrCoordinator {
@MainActor @MainActor
func subscribeNostrEvent(_ event: NostrEvent) { func subscribeNostrEvent(_ event: NostrEvent) {
guard let context else { return }
guard event.isValidSignature() else { return } guard event.isValidSignature() else { return }
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue), || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue),
!viewModel.deduplicationService.hasProcessedNostrEvent(event.id) !context.hasProcessedNostrEvent(event.id)
else { else {
return return
} }
viewModel.deduplicationService.recordNostrEvent(event.id) context.recordProcessedNostrEvent(event.id)
if let gh = viewModel.currentGeohash, if let gh = context.currentGeohash,
let myGeoIdentity = try? viewModel.idBridge.deriveIdentity(forGeohash: gh), let myGeoIdentity = try? context.deriveNostrIdentity(forGeohash: gh),
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() { myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
if Date().timeIntervalSince(eventTime) < 15 { 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 { if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
let nick = nickTag[1].trimmed 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 context.nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
viewModel.nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey context.nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
viewModel.participantTracker.recordParticipant(pubkeyHex: event.pubkey) context.recordGeoParticipant(pubkeyHex: event.pubkey)
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue { if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
return return
@@ -97,24 +284,24 @@ final class ChatNostrCoordinator {
if hasTeleportTag { if hasTeleportTag {
let key = event.pubkey.lowercased() let key = event.pubkey.lowercased()
let isSelf: Bool = { let isSelf: Bool = {
if let gh = viewModel.currentGeohash, if let gh = context.currentGeohash,
let myIdentity = try? viewModel.idBridge.deriveIdentity(forGeohash: gh) { let myIdentity = try? context.deriveNostrIdentity(forGeohash: gh) {
return myIdentity.publicKeyHex.lowercased() == key return myIdentity.publicKeyHex.lowercased() == key
} }
return false return false
}() }()
if !isSelf { if !isSelf {
Task { @MainActor [weak viewModel] in Task { @MainActor [weak context] in
viewModel?.locationPresenceStore.markTeleported(key) context?.markGeoTeleported(key)
} }
} }
} }
let senderName = viewModel.displayNameForNostrPubkey(event.pubkey) let senderName = context.displayNameForNostrPubkey(event.pubkey)
let content = event.content.trimmed let content = event.content.trimmed
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at)) let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let timestamp = min(rawTs, Date()) let timestamp = min(rawTs, Date())
let mentions = viewModel.parseMentions(from: content) let mentions = context.parseMentions(from: content)
let message = BitchatMessage( let message = BitchatMessage(
id: event.id, id: event.id,
sender: senderName, sender: senderName,
@@ -125,22 +312,23 @@ final class ChatNostrCoordinator {
mentions: mentions.isEmpty ? nil : mentions mentions: mentions.isEmpty ? nil : mentions
) )
Task { @MainActor [weak viewModel] in Task { @MainActor [weak context] in
guard let viewModel else { return } guard let context else { return }
let isBlocked = viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) let isBlocked = context.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased())
viewModel.handlePublicMessage(message) context.handlePublicMessage(message)
if !isBlocked { if !isBlocked {
viewModel.checkForMentions(message) context.checkForMentions(message)
viewModel.sendHapticFeedback(for: message) context.sendHapticFeedback(for: message)
} }
} }
} }
@MainActor @MainActor
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
guard let context else { return }
guard giftWrap.isValidSignature() else { return } guard giftWrap.isValidSignature() else { return }
guard !viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id) else { return } guard !context.hasProcessedNostrEvent(giftWrap.id) else { return }
viewModel.deduplicationService.recordNostrEvent(giftWrap.id) context.recordProcessedNostrEvent(giftWrap.id)
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage( guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap, giftWrap: giftWrap,
@@ -155,11 +343,11 @@ final class ChatNostrCoordinator {
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs)) let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
let convKey = PeerID(nostr_: senderPubkey) let convKey = PeerID(nostr_: senderPubkey)
viewModel.nostrKeyMapping[convKey] = senderPubkey context.nostrKeyMapping[convKey] = senderPubkey
switch noisePayload.type { switch noisePayload.type {
case .privateMessage: case .privateMessage:
viewModel.handlePrivateMessage( context.handlePrivateMessage(
noisePayload, noisePayload,
senderPubkey: senderPubkey, senderPubkey: senderPubkey,
convKey: convKey, convKey: convKey,
@@ -167,9 +355,9 @@ final class ChatNostrCoordinator {
messageTimestamp: messageTimestamp messageTimestamp: messageTimestamp
) )
case .delivered: case .delivered:
viewModel.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey) context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
case .readReceipt: case .readReceipt:
viewModel.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey) context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
case .verifyChallenge, .verifyResponse: case .verifyChallenge, .verifyResponse:
break break
} }
@@ -177,67 +365,66 @@ final class ChatNostrCoordinator {
@MainActor @MainActor
func switchLocationChannel(to channel: ChannelID) { func switchLocationChannel(to channel: ChannelID) {
viewModel.publicMessagePipeline.reset() guard let context else { return }
viewModel.activeChannel = channel context.resetPublicMessagePipeline()
viewModel.publicMessagePipeline.updateActiveChannel(channel) context.activeChannel = channel
context.updatePublicMessagePipelineChannel(channel)
viewModel.deduplicationService.clearNostrCaches() context.clearProcessedNostrEvents()
switch channel { switch channel {
case .mesh: case .mesh:
viewModel.refreshVisibleMessages(from: .mesh) context.refreshVisibleMessages(from: .mesh)
let emptyMesh = viewModel.messages.filter { $0.content.trimmed.isEmpty }.count let emptyMesh = context.messages.filter { $0.content.trimmed.isEmpty }.count
if emptyMesh > 0 { if emptyMesh > 0 {
SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session) SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session)
} }
viewModel.participantTracker.stopRefreshTimer() context.stopGeoParticipantRefreshTimer()
viewModel.participantTracker.setActiveGeohash(nil) context.setActiveParticipantGeohash(nil)
viewModel.locationPresenceStore.clearTeleportedGeo() context.clearTeleportedGeo()
case .location: case .location:
viewModel.refreshVisibleMessages(from: channel) context.refreshVisibleMessages(from: channel)
} }
if case .location = channel { if case .location = channel {
for content in viewModel.timelineStore.drainPendingGeohashSystemMessages() { for content in context.drainPendingGeohashSystemMessages() {
viewModel.addPublicSystemMessage(content) context.addPublicSystemMessage(content)
} }
} }
if let sub = viewModel.geoSubscriptionID { if let sub = context.geoSubscriptionID {
NostrRelayManager.shared.unsubscribe(id: sub) 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) NostrRelayManager.shared.unsubscribe(id: dmSub)
viewModel.geoDmSubscriptionID = nil context.geoDmSubscriptionID = nil
} }
viewModel.currentGeohash = nil context.currentGeohash = nil
viewModel.participantTracker.setActiveGeohash(nil) context.setActiveParticipantGeohash(nil)
viewModel.locationPresenceStore.clearGeoNicknames() context.clearGeoNicknames()
guard case .location(let channel) = channel else { return } guard case .location(let channel) = channel else { return }
viewModel.currentGeohash = channel.geohash context.currentGeohash = channel.geohash
viewModel.participantTracker.setActiveGeohash(channel.geohash) context.setActiveParticipantGeohash(channel.geohash)
if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) { if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
viewModel.participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex) context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
let hasRegional = !viewModel.locationManager.availableChannels.isEmpty
let inRegional = viewModel.locationManager.availableChannels.contains { $0.geohash == channel.geohash }
let key = identity.publicKeyHex.lowercased() let key = identity.publicKeyHex.lowercased()
if viewModel.locationManager.teleported && hasRegional && !inRegional { if context.isTeleported && context.isGeohashOutsideRegionalChannels(channel.geohash) {
viewModel.locationPresenceStore.markTeleported(key) context.markGeoTeleported(key)
SecureLogger.info( 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 category: .session
) )
} else { } else {
viewModel.locationPresenceStore.clearTeleported(key) context.clearGeoTeleported(key)
} }
} }
let subID = "geo-\(channel.geohash)" let subID = "geo-\(channel.geohash)"
viewModel.geoSubscriptionID = subID context.geoSubscriptionID = subID
viewModel.participantTracker.startRefreshTimer() context.startGeoParticipantRefreshTimer()
let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds) let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds)
let filter = NostrFilter.geohashEphemeral(channel.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit) let filter = NostrFilter.geohashEphemeral(channel.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit)
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5) let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5)
@@ -252,6 +439,7 @@ final class ChatNostrCoordinator {
@MainActor @MainActor
func handleNostrEvent(_ event: NostrEvent) { func handleNostrEvent(_ event: NostrEvent) {
guard let context else { return }
guard event.isValidSignature() else { return } guard event.isValidSignature() else { return }
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue)
@@ -259,13 +447,13 @@ final class ChatNostrCoordinator {
return return
} }
if viewModel.deduplicationService.hasProcessedNostrEvent(event.id) { return } if context.hasProcessedNostrEvent(event.id) { return }
viewModel.deduplicationService.recordNostrEvent(event.id) context.recordProcessedNostrEvent(event.id)
let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",") let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",")
SecureLogger.debug("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", category: .session) 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 return
} }
@@ -274,8 +462,8 @@ final class ChatNostrCoordinator {
} }
let isSelf: Bool = { let isSelf: Bool = {
if let gh = viewModel.currentGeohash, if let gh = context.currentGeohash,
let my = try? viewModel.idBridge.deriveIdentity(forGeohash: gh) { let my = try? context.deriveNostrIdentity(forGeohash: gh) {
return my.publicKeyHex.lowercased() == event.pubkey.lowercased() return my.publicKeyHex.lowercased() == event.pubkey.lowercased()
} }
return false return false
@@ -283,17 +471,17 @@ final class ChatNostrCoordinator {
if hasTeleportTag, !isSelf { if hasTeleportTag, !isSelf {
let key = event.pubkey.lowercased() let key = event.pubkey.lowercased()
Task { @MainActor [weak viewModel] in Task { @MainActor [weak context] in
guard let viewModel else { return } guard let context else { return }
viewModel.locationPresenceStore.markTeleported(key) context.markGeoTeleported(key)
SecureLogger.info( 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 category: .session
) )
} }
} }
viewModel.participantTracker.recordParticipant(pubkeyHex: event.pubkey) context.recordGeoParticipant(pubkeyHex: event.pubkey)
if isSelf { if isSelf {
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) 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 { 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 context.nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
viewModel.nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey context.nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue { if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
return return
} }
let senderName = viewModel.displayNameForNostrPubkey(event.pubkey) let senderName = context.displayNameForNostrPubkey(event.pubkey)
let content = event.content let content = event.content
if let teleTag = event.tags.first(where: { $0.first == "t" }), 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 rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let mentions = viewModel.parseMentions(from: content) let mentions = context.parseMentions(from: content)
let message = BitchatMessage( let message = BitchatMessage(
id: event.id, id: event.id,
sender: senderName, sender: senderName,
@@ -335,20 +523,21 @@ final class ChatNostrCoordinator {
mentions: mentions.isEmpty ? nil : mentions mentions: mentions.isEmpty ? nil : mentions
) )
Task { @MainActor [weak viewModel] in Task { @MainActor [weak context] in
guard let viewModel else { return } guard let context else { return }
viewModel.handlePublicMessage(message) context.handlePublicMessage(message)
viewModel.checkForMentions(message) context.checkForMentions(message)
viewModel.sendHapticFeedback(for: message) context.sendHapticFeedback(for: message)
} }
} }
@MainActor @MainActor
func subscribeToGeoChat(_ channel: GeohashChannel) { 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)" let dmSub = "geo-dm-\(channel.geohash)"
viewModel.geoDmSubscriptionID = dmSub context.geoDmSubscriptionID = dmSub
if TorManager.shared.isReady { if TorManager.shared.isReady {
SecureLogger.debug("GeoDM: subscribing DMs pub=\(identity.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session) SecureLogger.debug("GeoDM: subscribing DMs pub=\(identity.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session)
} }
@@ -365,11 +554,12 @@ final class ChatNostrCoordinator {
@MainActor @MainActor
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
guard let context else { return }
guard giftWrap.isValidSignature() else { return } guard giftWrap.isValidSignature() else { return }
if viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id) { if context.hasProcessedNostrEvent(giftWrap.id) {
return return
} }
viewModel.deduplicationService.recordNostrEvent(giftWrap.id) context.recordProcessedNostrEvent(giftWrap.id)
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage( guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap, giftWrap: giftWrap,
@@ -392,12 +582,12 @@ final class ChatNostrCoordinator {
} }
let convKey = PeerID(nostr_: senderPubkey) let convKey = PeerID(nostr_: senderPubkey)
viewModel.nostrKeyMapping[convKey] = senderPubkey context.nostrKeyMapping[convKey] = senderPubkey
switch payload.type { switch payload.type {
case .privateMessage: case .privateMessage:
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs)) let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
viewModel.handlePrivateMessage( context.handlePrivateMessage(
payload, payload,
senderPubkey: senderPubkey, senderPubkey: senderPubkey,
convKey: convKey, convKey: convKey,
@@ -405,19 +595,20 @@ final class ChatNostrCoordinator {
messageTimestamp: messageTimestamp messageTimestamp: messageTimestamp
) )
case .delivered: case .delivered:
viewModel.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey) context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
case .readReceipt: case .readReceipt:
viewModel.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey) context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
case .verifyChallenge, .verifyResponse: case .verifyChallenge, .verifyResponse:
break break
} }
} }
@MainActor @MainActor
func sendGeohash(context: ChatViewModel.GeoOutgoingContext) { func sendGeohash(context geoContext: ChatViewModel.GeoOutgoingContext) {
let channel = context.channel guard let context else { return }
let event = context.event let channel = geoContext.channel
let identity = context.identity let event = geoContext.event
let identity = geoContext.identity
let targetRelays = GeoRelayDirectory.shared.closestRelays( let targetRelays = GeoRelayDirectory.shared.closestRelays(
toGeohash: channel.geohash, toGeohash: channel.geohash,
@@ -430,42 +621,41 @@ final class ChatNostrCoordinator {
NostrRelayManager.shared.sendEvent(event, to: targetRelays) NostrRelayManager.shared.sendEvent(event, to: targetRelays)
} }
viewModel.participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex) context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
viewModel.nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = identity.publicKeyHex context.nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = identity.publicKeyHex
SecureLogger.debug( 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 category: .session
) )
let hasRegional = !viewModel.locationManager.availableChannels.isEmpty if geoContext.teleported && context.isGeohashOutsideRegionalChannels(channel.geohash) {
let inRegional = viewModel.locationManager.availableChannels.contains { $0.geohash == channel.geohash }
if context.teleported && hasRegional && !inRegional {
let key = identity.publicKeyHex.lowercased() let key = identity.publicKeyHex.lowercased()
viewModel.locationPresenceStore.markTeleported(key) context.markGeoTeleported(key)
SecureLogger.info( 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 category: .session
) )
} }
viewModel.deduplicationService.recordNostrEvent(event.id) context.recordProcessedNostrEvent(event.id)
} }
@MainActor @MainActor
func beginGeohashSampling(for geohashes: [String]) { func beginGeohashSampling(for geohashes: [String]) {
guard let context else { return }
if !TorManager.shared.isForeground() { if !TorManager.shared.isForeground() {
endGeohashSampling() endGeohashSampling()
return return
} }
let desired = Set(geohashes) let desired = Set(geohashes)
let current = Set(viewModel.geoSamplingSubs.values) let current = Set(context.geoSamplingSubs.values)
let toAdd = desired.subtracting(current) let toAdd = desired.subtracting(current)
let toRemove = current.subtracting(desired) 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) NostrRelayManager.shared.unsubscribe(id: subID)
viewModel.geoSamplingSubs.removeValue(forKey: subID) context.geoSamplingSubs.removeValue(forKey: subID)
} }
for gh in toAdd { for gh in toAdd {
@@ -475,8 +665,9 @@ final class ChatNostrCoordinator {
@MainActor @MainActor
func subscribe(_ gh: String) { func subscribe(_ gh: String) {
guard let context else { return }
let subID = "geo-sample-\(gh)" let subID = "geo-sample-\(gh)"
viewModel.geoSamplingSubs[subID] = gh context.geoSamplingSubs[subID] = gh
let filter = NostrFilter.geohashEphemeral( let filter = NostrFilter.geohashEphemeral(
gh, gh,
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds), since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds),
@@ -492,19 +683,21 @@ final class ChatNostrCoordinator {
@MainActor @MainActor
func subscribeNostrEvent(_ event: NostrEvent, gh: String) { func subscribeNostrEvent(_ event: NostrEvent, gh: String) {
guard event.isValidSignature() else { return } guard let context else { return }
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue)
else { else {
return return
} }
guard event.isValidSignature() else { return }
guard shouldProcessGeoSamplingEvent(event.id) else { return }
let existingCount = viewModel.participantTracker.participantCount(for: gh) let existingCount = context.geoParticipantCount(for: gh)
viewModel.participantTracker.recordParticipant(pubkeyHex: event.pubkey, geohash: gh) context.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh)
guard let content = event.content.trimmedOrNilIfEmpty else { return } guard let content = event.content.trimmedOrNilIfEmpty else { return }
if viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return } if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
if let my = try? viewModel.idBridge.deriveIdentity(forGeohash: gh), if let my = try? context.deriveNostrIdentity(forGeohash: gh),
my.publicKeyHex.lowercased() == event.pubkey.lowercased() { my.publicKeyHex.lowercased() == event.pubkey.lowercased() {
return return
} }
@@ -515,10 +708,10 @@ final class ChatNostrCoordinator {
#if os(iOS) #if os(iOS)
guard UIApplication.shared.applicationState == .active else { return } 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) #elseif os(macOS)
guard NSApplication.shared.isActive else { return } 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 #endif
cooldownPerGeohash(gh, content: content, event: event) cooldownPerGeohash(gh, content: content, event: event)
@@ -526,8 +719,9 @@ final class ChatNostrCoordinator {
@MainActor @MainActor
func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) { func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) {
guard let context else { return }
let now = Date() let now = Date()
let last = viewModel.lastGeoNotificationAt[gh] ?? .distantPast let last = context.lastGeoNotificationAt[gh] ?? .distantPast
if now.timeIntervalSince(last) < TransportConfig.uiGeoNotifyCooldownSeconds { return } if now.timeIntervalSince(last) < TransportConfig.uiGeoNotifyCooldownSeconds { return }
let preview: String = { let preview: String = {
@@ -537,16 +731,16 @@ final class ChatNostrCoordinator {
return String(content[..<idx]) + "" return String(content[..<idx]) + ""
}() }()
Task { @MainActor [weak viewModel] in Task { @MainActor [weak context] in
guard let viewModel else { return } guard let context else { return }
viewModel.lastGeoNotificationAt[gh] = now context.lastGeoNotificationAt[gh] = now
let senderSuffix = String(event.pubkey.suffix(4)) let senderSuffix = String(event.pubkey.suffix(4))
let nick = viewModel.geoNicknames[event.pubkey.lowercased()] let nick = context.geoNicknames[event.pubkey.lowercased()]
let senderName = (nick?.isEmpty == false ? nick! : "anon") + "#" + senderSuffix let senderName = (nick?.isEmpty == false ? nick! : "anon") + "#" + senderSuffix
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at)) let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let ts = min(rawTs, Date()) let ts = min(rawTs, Date())
let mentions = viewModel.parseMentions(from: content) let mentions = context.parseMentions(from: content)
let message = BitchatMessage( let message = BitchatMessage(
id: event.id, id: event.id,
sender: senderName, sender: senderName,
@@ -556,8 +750,8 @@ final class ChatNostrCoordinator {
senderPeerID: PeerID(nostr: event.pubkey), senderPeerID: PeerID(nostr: event.pubkey),
mentions: mentions.isEmpty ? nil : mentions mentions: mentions.isEmpty ? nil : mentions
) )
if viewModel.timelineStore.appendIfAbsent(message, toGeohash: gh) { if context.appendGeohashMessageIfAbsent(message, toGeohash: gh) {
viewModel.synchronizePublicConversationStore(forGeohash: gh) context.synchronizePublicConversationStore(forGeohash: gh)
NotificationService.shared.sendGeohashActivityNotification(geohash: gh, bodyPreview: preview) NotificationService.shared.sendGeohashActivityNotification(geohash: gh, bodyPreview: preview)
} }
} }
@@ -565,15 +759,41 @@ final class ChatNostrCoordinator {
@MainActor @MainActor
func endGeohashSampling() { func endGeohashSampling() {
for subID in viewModel.geoSamplingSubs.keys { guard let context else { return }
for subID in context.geoSamplingSubs.keys {
NostrRelayManager.shared.unsubscribe(id: subID) NostrRelayManager.shared.unsubscribe(id: subID)
} }
viewModel.geoSamplingSubs.removeAll() context.geoSamplingSubs.removeAll()
clearGeoSamplingEventDedup()
}
private func shouldProcessGeoSamplingEvent(_ eventID: String) -> 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 @MainActor
func setupNostrMessageHandling() { 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) SecureLogger.warning("⚠️ No Nostr identity available for message handling", category: .session)
return return
} }
@@ -588,7 +808,7 @@ final class ChatNostrCoordinator {
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) 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 Task { @MainActor [weak self] in
self?.handleNostrMessage(event) self?.handleNostrMessage(event)
} }
@@ -597,8 +817,10 @@ final class ChatNostrCoordinator {
@MainActor @MainActor
func handleNostrMessage(_ giftWrap: NostrEvent) { func handleNostrMessage(_ giftWrap: NostrEvent) {
if viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id) { return } guard let context else { return }
viewModel.deduplicationService.recordNostrEvent(giftWrap.id) guard giftWrap.isValidSignature() else { return }
if context.hasProcessedNostrEvent(giftWrap.id) { return }
context.recordProcessedNostrEvent(giftWrap.id)
Task.detached(priority: .userInitiated) { [weak self] in Task.detached(priority: .userInitiated) { [weak self] in
await self?.processNostrMessage(giftWrap) await self?.processNostrMessage(giftWrap)
@@ -607,8 +829,9 @@ final class ChatNostrCoordinator {
func processNostrMessage(_ giftWrap: NostrEvent) async { func processNostrMessage(_ giftWrap: NostrEvent) async {
guard giftWrap.isValidSignature() else { return } guard giftWrap.isValidSignature() else { return }
guard let context else { return }
let currentIdentity: NostrIdentity? = await MainActor.run { let currentIdentity: NostrIdentity? = await MainActor.run {
try? viewModel.idBridge.getCurrentNostrIdentity() context.currentNostrIdentity()
} }
guard let currentIdentity else { return } guard let currentIdentity else { return }
@@ -640,11 +863,11 @@ final class ChatNostrCoordinator {
let payload = NoisePayload.decode(packet.payload) { let payload = NoisePayload.decode(packet.payload) {
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp)) let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
await MainActor.run { await MainActor.run {
viewModel.nostrKeyMapping[targetPeerID] = senderPubkey context.nostrKeyMapping[targetPeerID] = senderPubkey
switch payload.type { switch payload.type {
case .privateMessage: case .privateMessage:
viewModel.handlePrivateMessage( context.handlePrivateMessage(
payload, payload,
senderPubkey: senderPubkey, senderPubkey: senderPubkey,
convKey: targetPeerID, convKey: targetPeerID,
@@ -652,9 +875,9 @@ final class ChatNostrCoordinator {
messageTimestamp: messageTimestamp messageTimestamp: messageTimestamp
) )
case .delivered: case .delivered:
viewModel.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID) context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
case .readReceipt: case .readReceipt:
viewModel.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID) context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
case .verifyChallenge, .verifyResponse: case .verifyChallenge, .verifyResponse:
break break
} }
@@ -711,33 +934,26 @@ final class ChatNostrCoordinator {
senderPubkey: String, senderPubkey: String,
key: Data? key: Data?
) { ) {
guard let context else { return }
if let _ = key { if let _ = key {
if let identity = try? viewModel.idBridge.getCurrentNostrIdentity() { if let identity = context.currentNostrIdentity() {
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge) context.sendGeohashDeliveryAck(for: message.id, toRecipientHex: senderPubkey, from: identity)
transport.senderPeerID = viewModel.meshService.myPeerID
transport.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: identity)
} }
} else if let identity = try? viewModel.idBridge.getCurrentNostrIdentity() { } else if let identity = context.currentNostrIdentity() {
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge) context.sendGeohashDeliveryAck(for: message.id, toRecipientHex: senderPubkey, from: identity)
transport.senderPeerID = viewModel.meshService.myPeerID
transport.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: identity)
SecureLogger.debug( SecureLogger.debug(
"Sent DELIVERED ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))", "Sent DELIVERED ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))",
category: .session category: .session
) )
} }
if !wasReadBefore && viewModel.selectedPrivateChatPeer == message.senderPeerID { if !wasReadBefore && context.selectedPrivateChatPeer == message.senderPeerID {
if let _ = key { if let _ = key {
if let identity = try? viewModel.idBridge.getCurrentNostrIdentity() { if let identity = context.currentNostrIdentity() {
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge) context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity)
transport.senderPeerID = viewModel.meshService.myPeerID
transport.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: identity)
} }
} else if let identity = try? viewModel.idBridge.getCurrentNostrIdentity() { } else if let identity = context.currentNostrIdentity() {
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge) context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity)
transport.senderPeerID = viewModel.meshService.myPeerID
transport.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: identity)
SecureLogger.debug( SecureLogger.debug(
"Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))", "Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))",
category: .session category: .session
@@ -798,6 +1014,7 @@ final class ChatNostrCoordinator {
@MainActor @MainActor
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) { func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
guard let context else { return }
guard let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey), guard let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey),
relationship.peerNostrPublicKey != nil else { relationship.peerNostrPublicKey != nil else {
SecureLogger.warning("⚠️ Cannot send favorite notification - no Nostr key for peer", category: .session) 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) let peerID = PeerID(hexData: noisePublicKey)
viewModel.messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) context.routeFavoriteNotification(to: peerID, isFavorite: isFavorite)
} }
@MainActor @MainActor
func nostrPubkeyForDisplayName(_ name: String) -> String? { 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 return person.id
} }
for (pub, nick) in viewModel.geoNicknames where nick == name { for (pub, nick) in context.geoNicknames where nick == name {
return pub return pub
} }
return nil return nil
@@ -821,22 +1039,25 @@ final class ChatNostrCoordinator {
@MainActor @MainActor
func startGeohashDM(withPubkeyHex hex: String) { func startGeohashDM(withPubkeyHex hex: String) {
guard let context else { return }
let convKey = PeerID(nostr_: hex) let convKey = PeerID(nostr_: hex)
viewModel.nostrKeyMapping[convKey] = hex context.nostrKeyMapping[convKey] = hex
viewModel.startPrivateChat(with: convKey) context.startPrivateChat(with: convKey)
} }
@MainActor @MainActor
func fullNostrHex(forSenderPeerID senderID: PeerID) -> String? { func fullNostrHex(forSenderPeerID senderID: PeerID) -> String? {
viewModel.nostrKeyMapping[senderID] guard let context else { return nil }
return context.nostrKeyMapping[senderID]
} }
@MainActor @MainActor
func geohashDisplayName(for convKey: PeerID) -> String { 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 convKey.bare
} }
return viewModel.displayNameForNostrPubkey(full) return context.displayNameForNostrPubkey(full)
} }
} }
@@ -2,20 +2,174 @@ import BitFoundation
import BitLogger import BitLogger
import Foundation 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<String> { get set }
var sentGeoDeliveryAcks: Set<String> { get set }
var unreadPrivateMessages: Set<PeerID> { 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 @MainActor
final class ChatPrivateConversationCoordinator { final class ChatPrivateConversationCoordinator {
private unowned let viewModel: ChatViewModel private unowned let context: any ChatPrivateConversationContext
init(viewModel: ChatViewModel) { init(context: any ChatPrivateConversationContext) {
self.viewModel = viewModel self.context = context
} }
func sendPrivateMessage(_ content: String, to peerID: PeerID) { func sendPrivateMessage(_ content: String, to peerID: PeerID) {
guard !content.isEmpty else { return } guard !content.isEmpty else { return }
if viewModel.unifiedPeerService.isBlocked(peerID) { if context.isPeerBlocked(peerID) {
let nickname = viewModel.meshService.peerNickname(peerID: peerID) ?? "user" let nickname = context.peerNickname(for: peerID) ?? "user"
viewModel.addSystemMessage( context.addSystemMessage(
String( String(
format: String(localized: "system.dm.blocked_recipient", comment: "System message when attempting to message a blocked user"), format: String(localized: "system.dm.blocked_recipient", comment: "System message when attempting to message a blocked user"),
locale: .current, locale: .current,
@@ -31,13 +185,13 @@ final class ChatPrivateConversationCoordinator {
} }
guard let noiseKey = Data(hexString: peerID.id) else { return } guard let noiseKey = Data(hexString: peerID.id) else { return }
let isConnected = viewModel.meshService.isPeerConnected(peerID) let isConnected = context.isPeerConnected(peerID)
let isReachable = viewModel.meshService.isPeerReachable(peerID) let isReachable = context.isPeerReachable(peerID)
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
let isMutualFavorite = favoriteStatus?.isMutual ?? false let isMutualFavorite = favoriteStatus?.isMutual ?? false
let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil
var recipientNickname = viewModel.meshService.peerNickname(peerID: peerID) var recipientNickname = context.peerNickname(for: peerID)
if recipientNickname == nil && favoriteStatus != nil { if recipientNickname == nil && favoriteStatus != nil {
recipientNickname = favoriteStatus?.peerNickname recipientNickname = favoriteStatus?.peerNickname
} }
@@ -46,42 +200,42 @@ final class ChatPrivateConversationCoordinator {
let messageID = UUID().uuidString let messageID = UUID().uuidString
let message = BitchatMessage( let message = BitchatMessage(
id: messageID, id: messageID,
sender: viewModel.nickname, sender: context.nickname,
content: content, content: content,
timestamp: Date(), timestamp: Date(),
isRelay: false, isRelay: false,
originalSender: nil, originalSender: nil,
isPrivate: true, isPrivate: true,
recipientNickname: recipientNickname, recipientNickname: recipientNickname,
senderPeerID: viewModel.meshService.myPeerID, senderPeerID: context.myPeerID,
mentions: nil, mentions: nil,
deliveryStatus: .sending deliveryStatus: .sending
) )
if viewModel.privateChats[peerID] == nil { if context.privateChats[peerID] == nil {
viewModel.privateChats[peerID] = [] context.privateChats[peerID] = []
} }
viewModel.privateChats[peerID]?.append(message) context.privateChats[peerID]?.append(message)
viewModel.objectWillChange.send() context.notifyUIChanged()
if isConnected || isReachable || (isMutualFavorite && hasNostrKey) { if isConnected || isReachable || (isMutualFavorite && hasNostrKey) {
viewModel.messageRouter.sendPrivate( context.routePrivateMessage(
content, content,
to: peerID, to: peerID,
recipientNickname: recipientNickname ?? "user", recipientNickname: recipientNickname ?? "user",
messageID: messageID messageID: messageID
) )
if let idx = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { if let idx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
viewModel.privateChats[peerID]?[idx].deliveryStatus = .sent context.privateChats[peerID]?[idx].deliveryStatus = .sent
} }
} else { } else {
if let index = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { if let index = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
viewModel.privateChats[peerID]?[index].deliveryStatus = .failed( context.privateChats[peerID]?[index].deliveryStatus = .failed(
reason: String(localized: "content.delivery.reason.unreachable", comment: "Failure reason when a peer is unreachable") reason: String(localized: "content.delivery.reason.unreachable", comment: "Failure reason when a peer is unreachable")
) )
} }
let name = recipientNickname ?? "user" let name = recipientNickname ?? "user"
viewModel.addSystemMessage( context.addSystemMessage(
String( String(
format: String(localized: "system.dm.unreachable", comment: "System message when a recipient is unreachable"), format: String(localized: "system.dm.unreachable", comment: "System message when a recipient is unreachable"),
locale: .current, locale: .current,
@@ -92,8 +246,8 @@ final class ChatPrivateConversationCoordinator {
} }
func sendGeohashDM(_ content: String, to peerID: PeerID) { func sendGeohashDM(_ content: String, to peerID: PeerID) {
guard case .location(let channel) = viewModel.activeChannel else { guard case .location(let channel) = context.activeChannel else {
viewModel.addSystemMessage( context.addSystemMessage(
String(localized: "system.location.not_in_channel", comment: "System message when attempting to send without being in a location channel") String(localized: "system.location.not_in_channel", comment: "System message when attempting to send without being in a location channel")
) )
return return
@@ -102,49 +256,49 @@ final class ChatPrivateConversationCoordinator {
let messageID = UUID().uuidString let messageID = UUID().uuidString
let message = BitchatMessage( let message = BitchatMessage(
id: messageID, id: messageID,
sender: viewModel.nickname, sender: context.nickname,
content: content, content: content,
timestamp: Date(), timestamp: Date(),
isRelay: false, isRelay: false,
isPrivate: true, isPrivate: true,
recipientNickname: viewModel.nickname, recipientNickname: context.nickname,
senderPeerID: viewModel.meshService.myPeerID, senderPeerID: context.myPeerID,
deliveryStatus: .sending deliveryStatus: .sending
) )
if viewModel.privateChats[peerID] == nil { if context.privateChats[peerID] == nil {
viewModel.privateChats[peerID] = [] context.privateChats[peerID] = []
} }
viewModel.privateChats[peerID]?.append(message) context.privateChats[peerID]?.append(message)
viewModel.objectWillChange.send() context.notifyUIChanged()
guard let recipientHex = viewModel.nostrKeyMapping[peerID] else { guard let recipientHex = context.nostrKeyMapping[peerID] else {
if let msgIdx = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { if let msgIdx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
viewModel.privateChats[peerID]?[msgIdx].deliveryStatus = .failed( context.privateChats[peerID]?[msgIdx].deliveryStatus = .failed(
reason: String(localized: "content.delivery.reason.unknown_recipient", comment: "Failure reason when the recipient is unknown") reason: String(localized: "content.delivery.reason.unknown_recipient", comment: "Failure reason when the recipient is unknown")
) )
} }
return return
} }
if viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: recipientHex) { if context.isNostrBlocked(pubkeyHexLowercased: recipientHex) {
if let msgIdx = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { if let msgIdx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
viewModel.privateChats[peerID]?[msgIdx].deliveryStatus = .failed( context.privateChats[peerID]?[msgIdx].deliveryStatus = .failed(
reason: String(localized: "content.delivery.reason.blocked", comment: "Failure reason when the user is blocked") 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") String(localized: "system.dm.blocked_generic", comment: "System message when sending fails because user is blocked")
) )
return return
} }
do { 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 recipientHex.lowercased() == identity.publicKeyHex.lowercased() {
if let idx = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { if let idx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
viewModel.privateChats[peerID]?[idx].deliveryStatus = .failed( context.privateChats[peerID]?[idx].deliveryStatus = .failed(
reason: String(localized: "content.delivery.reason.self", comment: "Failure reason when attempting to message yourself") 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)", "GeoDM: local send mid=\(messageID.prefix(8))… to=\(recipientHex.prefix(8))… conv=\(peerID)",
category: .session category: .session
) )
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge) context.sendGeohashPrivateMessage(
transport.senderPeerID = viewModel.meshService.myPeerID content,
transport.sendPrivateMessageGeohash(
content: content,
toRecipientHex: recipientHex, toRecipientHex: recipientHex,
from: identity, from: identity,
messageID: messageID messageID: messageID
) )
if let msgIdx = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { if let msgIdx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
viewModel.privateChats[peerID]?[msgIdx].deliveryStatus = .sent context.privateChats[peerID]?[msgIdx].deliveryStatus = .sent
} }
} catch { } catch {
if let idx = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { if let idx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
viewModel.privateChats[peerID]?[idx].deliveryStatus = .failed( context.privateChats[peerID]?[idx].deliveryStatus = .failed(
reason: String(localized: "content.delivery.reason.send_error", comment: "Failure reason for a generic send error") 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) sendDeliveryAckIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id)
if viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: senderPubkey) { if context.isNostrBlocked(pubkeyHexLowercased: senderPubkey) {
return return
} }
if viewModel.privateChats[convKey]?.contains(where: { $0.id == messageId }) == true { return } if context.privateChats[convKey]?.contains(where: { $0.id == messageId }) == true { return }
for (_, arr) in viewModel.privateChats where arr.contains(where: { $0.id == messageId }) { for (_, arr) in context.privateChats where arr.contains(where: { $0.id == messageId }) {
return return
} }
let senderName = viewModel.displayNameForNostrPubkey(senderPubkey) let senderName = context.displayNameForNostrPubkey(senderPubkey)
let message = BitchatMessage( let message = BitchatMessage(
id: messageId, id: messageId,
sender: senderName, sender: senderName,
@@ -206,22 +358,22 @@ final class ChatPrivateConversationCoordinator {
timestamp: messageTimestamp, timestamp: messageTimestamp,
isRelay: false, isRelay: false,
isPrivate: true, isPrivate: true,
recipientNickname: viewModel.nickname, recipientNickname: context.nickname,
senderPeerID: convKey, senderPeerID: convKey,
deliveryStatus: .delivered(to: viewModel.nickname, at: Date()) deliveryStatus: .delivered(to: context.nickname, at: Date())
) )
if viewModel.privateChats[convKey] == nil { if context.privateChats[convKey] == nil {
viewModel.privateChats[convKey] = [] context.privateChats[convKey] = []
} }
viewModel.privateChats[convKey]?.append(message) context.privateChats[convKey]?.append(message)
let isViewing = viewModel.selectedPrivateChatPeer == convKey let isViewing = context.selectedPrivateChatPeer == convKey
let wasReadBefore = viewModel.sentReadReceipts.contains(messageId) let wasReadBefore = context.sentReadReceipts.contains(messageId)
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30 let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage
if shouldMarkUnread { if shouldMarkUnread {
viewModel.unreadPrivateMessages.insert(convKey) context.unreadPrivateMessages.insert(convKey)
} }
if isViewing { if isViewing {
@@ -236,18 +388,18 @@ final class ChatPrivateConversationCoordinator {
) )
} }
viewModel.objectWillChange.send() context.notifyUIChanged()
} }
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) { func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
guard let messageID = String(data: payload.data, encoding: .utf8) else { return } guard let messageID = String(data: payload.data, encoding: .utf8) else { return }
if let idx = viewModel.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) { if let idx = context.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) {
viewModel.privateChats[convKey]?[idx].deliveryStatus = .delivered( context.privateChats[convKey]?[idx].deliveryStatus = .delivered(
to: viewModel.displayNameForNostrPubkey(senderPubkey), to: context.displayNameForNostrPubkey(senderPubkey),
at: Date() at: Date()
) )
viewModel.objectWillChange.send() context.notifyUIChanged()
SecureLogger.info( SecureLogger.info(
"GeoDM: recv DELIVERED for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))", "GeoDM: recv DELIVERED for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))",
category: .session category: .session
@@ -260,12 +412,12 @@ final class ChatPrivateConversationCoordinator {
func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) { func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
guard let messageID = String(data: payload.data, encoding: .utf8) else { return } guard let messageID = String(data: payload.data, encoding: .utf8) else { return }
if let idx = viewModel.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) { if let idx = context.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) {
viewModel.privateChats[convKey]?[idx].deliveryStatus = .read( context.privateChats[convKey]?[idx].deliveryStatus = .read(
by: viewModel.displayNameForNostrPubkey(senderPubkey), by: context.displayNameForNostrPubkey(senderPubkey),
at: Date() at: Date()
) )
viewModel.objectWillChange.send() context.notifyUIChanged()
SecureLogger.info("GeoDM: recv READ for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))", category: .session) SecureLogger.info("GeoDM: recv READ for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))", category: .session)
} else { } else {
SecureLogger.warning("GeoDM: read ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session) 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) { func sendDeliveryAckIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {
guard !viewModel.sentGeoDeliveryAcks.contains(messageId) else { return } guard !context.sentGeoDeliveryAcks.contains(messageId) else { return }
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge) context.sendGeohashDeliveryAck(for: messageId, toRecipientHex: senderPubKey, from: id)
transport.senderPeerID = viewModel.meshService.myPeerID context.sentGeoDeliveryAcks.insert(messageId)
transport.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubKey, from: id)
viewModel.sentGeoDeliveryAcks.insert(messageId)
} }
func sendReadReceiptIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) { func sendReadReceiptIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {
guard !viewModel.sentReadReceipts.contains(messageId) else { return } guard !context.sentReadReceipts.contains(messageId) else { return }
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge) context.sendGeohashReadReceipt(messageId, toRecipientHex: senderPubKey, from: id)
transport.senderPeerID = viewModel.meshService.myPeerID context.sentReadReceipts.insert(messageId)
transport.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubKey, from: id)
viewModel.sentReadReceipts.insert(messageId)
} }
func handlePrivateMessage( func handlePrivateMessage(
@@ -315,15 +463,15 @@ final class ChatPrivateConversationCoordinator {
return return
} }
let wasReadBefore = viewModel.sentReadReceipts.contains(messageId) let wasReadBefore = context.sentReadReceipts.contains(messageId)
var isViewingThisChat = false var isViewingThisChat = false
if viewModel.selectedPrivateChatPeer == targetPeerID { if context.selectedPrivateChatPeer == targetPeerID {
isViewingThisChat = true isViewingThisChat = true
} else if let selectedPeer = viewModel.selectedPrivateChatPeer, } else if let selectedPeer = context.selectedPrivateChatPeer,
let selectedPeerData = viewModel.unifiedPeerService.getPeer(by: selectedPeer), let selectedPeerNoiseKey = context.noisePublicKey(for: selectedPeer),
let key = actualSenderNoiseKey, let key = actualSenderNoiseKey,
selectedPeerData.noisePublicKey == key { selectedPeerNoiseKey == key {
isViewingThisChat = true isViewingThisChat = true
} }
@@ -337,15 +485,15 @@ final class ChatPrivateConversationCoordinator {
timestamp: messageTimestamp, timestamp: messageTimestamp,
isRelay: false, isRelay: false,
isPrivate: true, isPrivate: true,
recipientNickname: viewModel.nickname, recipientNickname: context.nickname,
senderPeerID: targetPeerID, senderPeerID: targetPeerID,
deliveryStatus: .delivered(to: viewModel.nickname, at: Date()) deliveryStatus: .delivered(to: context.nickname, at: Date())
) )
addMessageToPrivateChatsIfNeeded(message, targetPeerID: targetPeerID) addMessageToPrivateChatsIfNeeded(message, targetPeerID: targetPeerID)
mirrorToEphemeralIfNeeded(message, targetPeerID: targetPeerID, key: actualSenderNoiseKey) mirrorToEphemeralIfNeeded(message, targetPeerID: targetPeerID, key: actualSenderNoiseKey)
viewModel.sendDeliveryAckViaNostrEmbedded( context.sendDeliveryAckViaNostrEmbedded(
message, message,
wasReadBefore: wasReadBefore, wasReadBefore: wasReadBefore,
senderPubkey: senderPubkey, senderPubkey: senderPubkey,
@@ -372,12 +520,12 @@ final class ChatPrivateConversationCoordinator {
) )
} }
viewModel.objectWillChange.send() context.notifyUIChanged()
} }
func handlePrivateMessage(_ message: BitchatMessage) { func handlePrivateMessage(_ message: BitchatMessage) {
SecureLogger.debug("📥 handlePrivateMessage called for message from \(message.sender)", category: .session) 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 { guard let peerID = senderPeerID else {
SecureLogger.warning("⚠️ Could not get peer ID for sender \(message.sender)", category: .session) 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) migratePrivateChatsIfNeeded(for: peerID, senderNickname: message.sender)
if peerID.id.count == 16, let peer = viewModel.unifiedPeerService.getPeer(by: peerID) { if peerID.id.count == 16, let peerNoiseKey = context.noisePublicKey(for: peerID) {
let stableKeyHex = PeerID(hexData: peer.noisePublicKey) let stableKeyHex = PeerID(hexData: peerNoiseKey)
if stableKeyHex != peerID, if stableKeyHex != peerID,
let nostrMessages = viewModel.privateChats[stableKeyHex], let nostrMessages = context.privateChats[stableKeyHex],
!nostrMessages.isEmpty { !nostrMessages.isEmpty {
if viewModel.privateChats[peerID] == nil { if context.privateChats[peerID] == nil {
viewModel.privateChats[peerID] = [] 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) { 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 } context.privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
viewModel.privateChats.removeValue(forKey: stableKeyHex) context.privateChats.removeValue(forKey: stableKeyHex)
SecureLogger.info( SecureLogger.info(
"📥 Consolidated \(nostrMessages.count) Nostr messages from stable key to ephemeral peer \(peerID)", "📥 Consolidated \(nostrMessages.count) Nostr messages from stable key to ephemeral peer \(peerID)",
@@ -420,20 +568,20 @@ final class ChatPrivateConversationCoordinator {
} }
addMessageToPrivateChatsIfNeeded(message, targetPeerID: peerID) 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) mirrorToEphemeralIfNeeded(message, targetPeerID: peerID, key: noiseKey)
let isViewing = viewModel.selectedPrivateChatPeer == peerID let isViewing = context.selectedPrivateChatPeer == peerID
if isViewing { if isViewing {
let receipt = ReadReceipt( let receipt = ReadReceipt(
originalMessageID: message.id, originalMessageID: message.id,
readerID: viewModel.meshService.myPeerID, readerID: context.myPeerID,
readerNickname: viewModel.nickname readerNickname: context.nickname
) )
viewModel.meshService.sendReadReceipt(receipt, to: peerID) context.sendMeshReadReceipt(receipt, to: peerID)
viewModel.sentReadReceipts.insert(message.id) context.sentReadReceipts.insert(message.id)
} else { } else {
viewModel.unreadPrivateMessages.insert(peerID) context.unreadPrivateMessages.insert(peerID)
NotificationService.shared.sendPrivateMessageNotification( NotificationService.shared.sendPrivateMessageNotification(
from: message.sender, from: message.sender,
message: message.content, message: message.content,
@@ -441,48 +589,48 @@ final class ChatPrivateConversationCoordinator {
) )
} }
viewModel.objectWillChange.send() context.notifyUIChanged()
} }
func isDuplicateMessage(_ messageId: String, targetPeerID: PeerID) -> Bool { 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 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 true
} }
return false return false
} }
func addMessageToPrivateChatsIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID) { func addMessageToPrivateChatsIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID) {
if viewModel.privateChats[targetPeerID] == nil { if context.privateChats[targetPeerID] == nil {
viewModel.privateChats[targetPeerID] = [] context.privateChats[targetPeerID] = []
} }
if let idx = viewModel.privateChats[targetPeerID]?.firstIndex(where: { $0.id == message.id }) { if let idx = context.privateChats[targetPeerID]?.firstIndex(where: { $0.id == message.id }) {
viewModel.privateChats[targetPeerID]?[idx] = message context.privateChats[targetPeerID]?[idx] = message
} else { } 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?) { func mirrorToEphemeralIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID, key: Data?) {
guard let key, guard let key,
let ephemeralPeerID = viewModel.unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID, let ephemeralPeerID = context.ephemeralPeerID(forNoiseKey: key),
ephemeralPeerID != targetPeerID ephemeralPeerID != targetPeerID
else { else {
return return
} }
if viewModel.privateChats[ephemeralPeerID] == nil { if context.privateChats[ephemeralPeerID] == nil {
viewModel.privateChats[ephemeralPeerID] = [] context.privateChats[ephemeralPeerID] = []
} }
if let idx = viewModel.privateChats[ephemeralPeerID]?.firstIndex(where: { $0.id == message.id }) { if let idx = context.privateChats[ephemeralPeerID]?.firstIndex(where: { $0.id == message.id }) {
viewModel.privateChats[ephemeralPeerID]?[idx] = message context.privateChats[ephemeralPeerID]?[idx] = message
} else { } else {
viewModel.privateChats[ephemeralPeerID]?.append(message) context.privateChats[ephemeralPeerID]?.append(message)
} }
viewModel.privateChatManager.sanitizeChat(for: ephemeralPeerID) context.sanitizeChat(for: ephemeralPeerID)
} }
func handleViewingThisChat( func handleViewingThisChat(
@@ -491,27 +639,25 @@ final class ChatPrivateConversationCoordinator {
key: Data?, key: Data?,
senderPubkey: String senderPubkey: String
) { ) {
viewModel.unreadPrivateMessages.remove(targetPeerID) context.unreadPrivateMessages.remove(targetPeerID)
if let key, if let key,
let ephemeralPeerID = viewModel.unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID { let ephemeralPeerID = context.ephemeralPeerID(forNoiseKey: key) {
viewModel.unreadPrivateMessages.remove(ephemeralPeerID) context.unreadPrivateMessages.remove(ephemeralPeerID)
} }
guard !viewModel.sentReadReceipts.contains(message.id) else { return } guard !context.sentReadReceipts.contains(message.id) else { return }
if let key { if let key {
let receipt = ReadReceipt( let receipt = ReadReceipt(
originalMessageID: message.id, originalMessageID: message.id,
readerID: viewModel.meshService.myPeerID, readerID: context.myPeerID,
readerNickname: viewModel.nickname readerNickname: context.nickname
) )
SecureLogger.debug("Viewing chat; sending READ ack for \(message.id.prefix(8))… via router", category: .session) SecureLogger.debug("Viewing chat; sending READ ack for \(message.id.prefix(8))… via router", category: .session)
viewModel.messageRouter.sendReadReceipt(receipt, to: PeerID(hexData: key)) context.routeReadReceipt(receipt, to: PeerID(hexData: key))
viewModel.sentReadReceipts.insert(message.id) context.sentReadReceipts.insert(message.id)
} else if let identity = try? viewModel.idBridge.getCurrentNostrIdentity() { } else if let identity = context.currentNostrIdentity() {
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge) context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity)
transport.senderPeerID = viewModel.meshService.myPeerID context.sentReadReceipts.insert(message.id)
transport.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: identity)
viewModel.sentReadReceipts.insert(message.id)
SecureLogger.debug( SecureLogger.debug(
"Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))", "Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))",
category: .session category: .session
@@ -529,11 +675,11 @@ final class ChatPrivateConversationCoordinator {
) { ) {
guard shouldMarkAsUnread else { return } guard shouldMarkAsUnread else { return }
viewModel.unreadPrivateMessages.insert(targetPeerID) context.unreadPrivateMessages.insert(targetPeerID)
if let key, if let key,
let ephemeralPeerID = viewModel.unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID, let ephemeralPeerID = context.ephemeralPeerID(forNoiseKey: key),
ephemeralPeerID != targetPeerID { ephemeralPeerID != targetPeerID {
viewModel.unreadPrivateMessages.insert(ephemeralPeerID) context.unreadPrivateMessages.insert(ephemeralPeerID)
} }
if isRecentMessage { if isRecentMessage {
NotificationService.shared.sendPrivateMessageNotification( NotificationService.shared.sendPrivateMessageNotification(
@@ -554,7 +700,7 @@ final class ChatPrivateConversationCoordinator {
SecureLogger.info("📝 Received Nostr npub in favorite notification: \(nostrPubkey ?? "none")", category: .session) 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 { guard let finalNoiseKey = noiseKey else {
SecureLogger.warning("⚠️ Cannot get Noise key for peer \(peerID)", category: .session) SecureLogger.warning("⚠️ Cannot get Noise key for peer \(peerID)", category: .session)
return return
@@ -577,7 +723,7 @@ final class ChatPrivateConversationCoordinator {
if prior != isFavorite { if prior != isFavorite {
let action = isFavorite ? "favorited" : "unfavorited" 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) { 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 migratedMessages: [BitchatMessage] = []
var oldPeerIDsToRemove: [PeerID] = [] var oldPeerIDsToRemove: [PeerID] = []
let cutoffTime = Date().addingTimeInterval(-TransportConfig.uiMigrationCutoffSeconds) let cutoffTime = Date().addingTimeInterval(-TransportConfig.uiMigrationCutoffSeconds)
for (oldPeerID, messages) in viewModel.privateChats where oldPeerID != peerID { for (oldPeerID, messages) in context.privateChats where oldPeerID != peerID {
let oldFingerprint = viewModel.peerIDToPublicKeyFingerprint[oldPeerID] let oldFingerprint = context.storedFingerprint(for: oldPeerID)
let recentMessages = messages.filter { $0.timestamp > cutoffTime } let recentMessages = messages.filter { $0.timestamp > cutoffTime }
guard !recentMessages.isEmpty else { continue } guard !recentMessages.isEmpty else { continue }
@@ -637,8 +783,8 @@ final class ChatPrivateConversationCoordinator {
) )
} else if currentFingerprint == nil || oldFingerprint == nil { } else if currentFingerprint == nil || oldFingerprint == nil {
let isRelevantChat = recentMessages.contains { msg in let isRelevantChat = recentMessages.contains { msg in
(msg.sender == senderNickname && msg.sender != viewModel.nickname) (msg.sender == senderNickname && msg.sender != context.nickname)
|| (msg.sender == viewModel.nickname && msg.recipientNickname == senderNickname) || (msg.sender == context.nickname && msg.recipientNickname == senderNickname)
} }
if isRelevantChat { if isRelevantChat {
@@ -656,27 +802,27 @@ final class ChatPrivateConversationCoordinator {
} }
if !oldPeerIDsToRemove.isEmpty { if !oldPeerIDsToRemove.isEmpty {
let needsSelectedUpdate = oldPeerIDsToRemove.contains { viewModel.selectedPrivateChatPeer == $0 } let needsSelectedUpdate = oldPeerIDsToRemove.contains { context.selectedPrivateChatPeer == $0 }
for oldID in oldPeerIDsToRemove { for oldID in oldPeerIDsToRemove {
viewModel.privateChats.removeValue(forKey: oldID) context.privateChats.removeValue(forKey: oldID)
viewModel.unreadPrivateMessages.remove(oldID) context.unreadPrivateMessages.remove(oldID)
viewModel.peerIdentityStore.setFingerprint(nil, for: oldID) context.clearStoredFingerprint(for: oldID)
} }
if needsSelectedUpdate { if needsSelectedUpdate {
viewModel.selectedPrivateChatPeer = peerID context.selectedPrivateChatPeer = peerID
} }
} }
if !migratedMessages.isEmpty { if !migratedMessages.isEmpty {
if viewModel.privateChats[peerID] == nil { if context.privateChats[peerID] == nil {
viewModel.privateChats[peerID] = [] context.privateChats[peerID] = []
} }
viewModel.privateChats[peerID]?.append(contentsOf: migratedMessages) context.privateChats[peerID]?.append(contentsOf: migratedMessages)
viewModel.privateChats[peerID]?.sort { $0.timestamp < $1.timestamp } context.privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
viewModel.privateChatManager.sanitizeChat(for: peerID) context.sanitizeChat(for: peerID)
viewModel.objectWillChange.send() context.notifyUIChanged()
} }
} }
} }
@@ -686,26 +832,26 @@ final class ChatPrivateConversationCoordinator {
if let hexKey = Data(hexString: peerID.id) { if let hexKey = Data(hexString: peerID.id) {
noiseKey = hexKey noiseKey = hexKey
} else if let peer = viewModel.unifiedPeerService.getPeer(by: peerID) { } else if let peerNoiseKey = context.noisePublicKey(for: peerID) {
noiseKey = peer.noisePublicKey noiseKey = peerNoiseKey
} }
if viewModel.meshService.isPeerConnected(peerID) { if context.isPeerConnected(peerID) {
viewModel.messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) context.routeFavoriteNotification(to: peerID, isFavorite: isFavorite)
SecureLogger.debug("📤 Sent favorite notification via BLE to \(peerID)", category: .session) SecureLogger.debug("📤 Sent favorite notification via BLE to \(peerID)", category: .session)
} else if let key = noiseKey { } else if let key = noiseKey {
viewModel.messageRouter.sendFavoriteNotification(to: PeerID(hexData: key), isFavorite: isFavorite) context.routeFavoriteNotification(to: PeerID(hexData: key), isFavorite: isFavorite)
} else { } else {
SecureLogger.warning("⚠️ Cannot send favorite notification - peer not connected and no Nostr pubkey", category: .session) SecureLogger.warning("⚠️ Cannot send favorite notification - peer not connected and no Nostr pubkey", category: .session)
} }
} }
func isMessageBlocked(_ message: BitchatMessage) -> Bool { func isMessageBlocked(_ message: BitchatMessage) -> Bool {
if let peerID = message.senderPeerID ?? viewModel.getPeerIDForNickname(message.sender) { if let peerID = message.senderPeerID ?? context.getPeerIDForNickname(message.sender) {
if viewModel.isPeerBlocked(peerID) { return true } if context.isPeerBlocked(peerID) { return true }
if peerID.isGeoChat || peerID.isGeoDM, if peerID.isGeoChat || peerID.isGeoDM,
let full = viewModel.nostrKeyMapping[peerID]?.lowercased(), let full = context.nostrKeyMapping[peerID]?.lowercased(),
viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: full) { context.isNostrBlocked(pubkeyHexLowercased: full) {
return true return true
} }
} }
@@ -7,16 +7,190 @@ import SwiftUI
import UIKit import UIKit
#endif #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<PeerID> { 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 @MainActor
final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
private unowned let viewModel: ChatViewModel private unowned let context: any ChatPublicConversationContext
init(viewModel: ChatViewModel) { init(context: any ChatPublicConversationContext) {
self.viewModel = viewModel self.context = context
} }
func visibleGeohashPeople() -> [GeoPerson] { func visibleGeohashPeople() -> [GeoPerson] {
viewModel.participantTracker.getVisiblePeople() context.visibleGeoPeople()
} }
func getVisibleGeoParticipants() -> [CommandGeoParticipant] { func getVisibleGeoParticipants() -> [CommandGeoParticipant] {
@@ -24,7 +198,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
} }
func geohashParticipantCount(for geohash: String) -> Int { func geohashParticipantCount(for geohash: String) -> Int {
viewModel.participantTracker.participantCount(for: geohash) context.geoParticipantCount(for: geohash)
} }
func displayNameForPubkey(_ pubkeyHex: String) -> String { func displayNameForPubkey(_ pubkeyHex: String) -> String {
@@ -32,49 +206,49 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
} }
func isBlocked(_ pubkeyHexLowercased: String) -> Bool { func isBlocked(_ pubkeyHexLowercased: String) -> Bool {
viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased) context.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased)
} }
func isGeohashUserBlocked(pubkeyHexLowercased: String) -> Bool { func isGeohashUserBlocked(pubkeyHexLowercased: String) -> Bool {
viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased) context.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased)
} }
func blockGeohashUser(pubkeyHexLowercased: String, displayName: String) { func blockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
let hex = pubkeyHexLowercased.lowercased() let hex = pubkeyHexLowercased.lowercased()
viewModel.identityManager.setNostrBlocked(hex, isBlocked: true) context.setNostrBlocked(hex, isBlocked: true)
viewModel.participantTracker.removeParticipant(pubkeyHex: hex) context.removeGeoParticipant(pubkeyHex: hex)
if let gh = viewModel.currentGeohash { if let gh = context.currentGeohash {
let predicate: (BitchatMessage) -> Bool = { [unowned viewModel] message in let predicate: (BitchatMessage) -> Bool = { [unowned context] message in
guard let senderPeerID = message.senderPeerID, guard let senderPeerID = message.senderPeerID,
senderPeerID.isGeoDM || senderPeerID.isGeoChat else { senderPeerID.isGeoDM || senderPeerID.isGeoChat else {
return false return false
} }
if let full = viewModel.nostrKeyMapping[senderPeerID]?.lowercased() { if let full = context.nostrKeyMapping[senderPeerID]?.lowercased() {
return full == hex return full == hex
} }
return false return false
} }
viewModel.timelineStore.removeMessages(in: gh, where: predicate) context.removeGeohashTimelineMessages(in: gh, where: predicate)
synchronizePublicConversationStore(forGeohash: gh) synchronizePublicConversationStore(forGeohash: gh)
if case .location = viewModel.activeChannel { if case .location = context.activeChannel {
viewModel.messages.removeAll(where: predicate) context.messages.removeAll(where: predicate)
} }
} }
let conversationPeerID = PeerID(nostr_: hex) let conversationPeerID = PeerID(nostr_: hex)
if viewModel.privateChats[conversationPeerID] != nil { if context.privateChats[conversationPeerID] != nil {
var privateChats = viewModel.privateChats var privateChats = context.privateChats
privateChats.removeValue(forKey: conversationPeerID) privateChats.removeValue(forKey: conversationPeerID)
viewModel.privateChats = privateChats context.privateChats = privateChats
var unread = viewModel.unreadPrivateMessages var unread = context.unreadPrivateMessages
unread.remove(conversationPeerID) unread.remove(conversationPeerID)
viewModel.unreadPrivateMessages = unread context.unreadPrivateMessages = unread
} }
for (key, value) in viewModel.nostrKeyMapping where value.lowercased() == hex { for (key, value) in context.nostrKeyMapping where value.lowercased() == hex {
viewModel.nostrKeyMapping.removeValue(forKey: key) context.nostrKeyMapping.removeValue(forKey: key)
} }
addSystemMessage( addSystemMessage(
@@ -90,7 +264,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
} }
func unblockGeohashUser(pubkeyHexLowercased: String, displayName: String) { func unblockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
viewModel.identityManager.setNostrBlocked(pubkeyHexLowercased, isBlocked: false) context.setNostrBlocked(pubkeyHexLowercased, isBlocked: false)
addSystemMessage( addSystemMessage(
String( String(
format: String( format: String(
@@ -105,24 +279,24 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
func displayNameForNostrPubkey(_ pubkeyHex: String) -> String { func displayNameForNostrPubkey(_ pubkeyHex: String) -> String {
let suffix = String(pubkeyHex.suffix(4)) let suffix = String(pubkeyHex.suffix(4))
if let geohash = viewModel.currentGeohash, if let geohash = context.currentGeohash,
let myGeoIdentity = try? viewModel.idBridge.deriveIdentity(forGeohash: geohash), let myGeoIdentity = try? context.deriveNostrIdentity(forGeohash: geohash),
myGeoIdentity.publicKeyHex.lowercased() == pubkeyHex.lowercased() { 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 nick + "#" + suffix
} }
return "anon#\(suffix)" return "anon#\(suffix)"
} }
func currentPublicSender() -> (name: String, peerID: PeerID) { func currentPublicSender() -> (name: String, peerID: PeerID) {
var displaySender = viewModel.nickname var displaySender = context.nickname
var senderPeerID = viewModel.meshService.myPeerID var senderPeerID = context.myPeerID
if case .location(let channel) = viewModel.activeChannel, if case .location(let channel) = context.activeChannel,
let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) { let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
let suffix = String(identity.publicKeyHex.suffix(4)) let suffix = String(identity.publicKeyHex.suffix(4))
displaySender = viewModel.nickname + "#" + suffix displaySender = context.nickname + "#" + suffix
senderPeerID = PeerID(nostr: identity.publicKeyHex) senderPeerID = PeerID(nostr: identity.publicKeyHex)
} }
return (displaySender, senderPeerID) return (displaySender, senderPeerID)
@@ -131,16 +305,16 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
func removeMessage(withID messageID: String, cleanupFile: Bool = false) { func removeMessage(withID messageID: String, cleanupFile: Bool = false) {
var removedMessage: BitchatMessage? var removedMessage: BitchatMessage?
if let index = viewModel.messages.firstIndex(where: { $0.id == messageID }) { if let index = context.messages.firstIndex(where: { $0.id == messageID }) {
removedMessage = viewModel.messages.remove(at: index) removedMessage = context.messages.remove(at: index)
} }
if let storeRemoved = viewModel.timelineStore.removeMessage(withID: messageID) { if let storeRemoved = context.removeTimelineMessage(withID: messageID) {
removedMessage = removedMessage ?? storeRemoved removedMessage = removedMessage ?? storeRemoved
synchronizeAllPublicConversationStores() synchronizeAllPublicConversationStores()
} }
var chats = viewModel.privateChats var chats = context.privateChats
for (peerID, items) in chats { for (peerID, items) in chats {
let filtered = items.filter { $0.id != messageID } let filtered = items.filter { $0.id != messageID }
if filtered.count != items.count { if filtered.count != items.count {
@@ -154,55 +328,55 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
} }
} }
} }
viewModel.privateChats = chats context.privateChats = chats
if cleanupFile, let removedMessage { if cleanupFile, let removedMessage {
viewModel.cleanupLocalFile(forMessage: removedMessage) context.cleanupLocalFile(forMessage: removedMessage)
} }
viewModel.objectWillChange.send() context.notifyUIChanged()
} }
func initializeConversationStore() { func initializeConversationStore() {
viewModel.conversationStore.setActiveChannel(viewModel.activeChannel) context.setConversationActiveChannel(context.activeChannel)
synchronizePublicConversationStore(for: viewModel.activeChannel) synchronizePublicConversationStore(for: context.activeChannel)
viewModel.synchronizePrivateConversationStore() context.synchronizePrivateConversationStore()
viewModel.synchronizeConversationSelectionStore() context.synchronizeConversationSelectionStore()
} }
func synchronizePublicConversationStore(for channel: ChannelID) { func synchronizePublicConversationStore(for channel: ChannelID) {
let publicMessages = viewModel.timelineStore.messages(for: channel) let publicMessages = context.timelineMessages(for: channel)
viewModel.conversationStore.replaceMessages(publicMessages, for: channel) context.replaceConversationMessages(publicMessages, for: channel)
if channel == viewModel.activeChannel { if channel == context.activeChannel {
viewModel.conversationStore.setActiveChannel(viewModel.activeChannel) context.setConversationActiveChannel(context.activeChannel)
} }
} }
func synchronizePublicConversationStore(forGeohash geohash: String) { func synchronizePublicConversationStore(forGeohash geohash: String) {
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash)) let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))
let publicMessages = viewModel.timelineStore.messages(for: channel) let publicMessages = context.timelineMessages(for: channel)
viewModel.conversationStore.replaceMessages(publicMessages, for: .geohash(geohash.lowercased())) context.replaceConversationMessages(publicMessages, for: .geohash(geohash.lowercased()))
} }
func synchronizeAllPublicConversationStores() { func synchronizeAllPublicConversationStores() {
synchronizePublicConversationStore(for: .mesh) synchronizePublicConversationStore(for: .mesh)
for geohash in viewModel.timelineStore.geohashKeys() { for geohash in context.timelineGeohashKeys() {
synchronizePublicConversationStore(forGeohash: geohash) synchronizePublicConversationStore(forGeohash: geohash)
} }
} }
func refreshVisibleMessages(from channel: ChannelID? = nil) { func refreshVisibleMessages(from channel: ChannelID? = nil) {
let target = channel ?? viewModel.activeChannel let target = channel ?? context.activeChannel
viewModel.messages = viewModel.timelineStore.messages(for: target) context.messages = context.timelineMessages(for: target)
viewModel.conversationStore.replaceMessages(viewModel.messages, for: target) context.replaceConversationMessages(context.messages, for: target)
if target == viewModel.activeChannel { if target == context.activeChannel {
viewModel.conversationStore.setActiveChannel(viewModel.activeChannel) context.setConversationActiveChannel(context.activeChannel)
} }
} }
func clearCurrentPublicTimeline() { func clearCurrentPublicTimeline() {
viewModel.messages.removeAll() context.messages.removeAll()
viewModel.timelineStore.clear(channel: viewModel.activeChannel) context.clearTimeline(for: context.activeChannel)
Task.detached(priority: .utility) { Task.detached(priority: .utility) {
do { do {
@@ -242,7 +416,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
timestamp: timestamp, timestamp: timestamp,
isRelay: false isRelay: false
) )
viewModel.messages.append(systemMessage) context.messages.append(systemMessage)
} }
func addMeshOnlySystemMessage(_ content: String) { func addMeshOnlySystemMessage(_ content: String) {
@@ -252,11 +426,11 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
timestamp: Date(), timestamp: Date(),
isRelay: false isRelay: false
) )
viewModel.timelineStore.append(systemMessage, to: .mesh) context.appendTimelineMessage(systemMessage, to: .mesh)
synchronizePublicConversationStore(for: .mesh) synchronizePublicConversationStore(for: .mesh)
refreshVisibleMessages() refreshVisibleMessages()
viewModel.trimMessagesIfNeeded() context.trimMessagesIfNeeded()
viewModel.objectWillChange.send() context.notifyUIChanged()
} }
func addPublicSystemMessage(_ content: String) { func addPublicSystemMessage(_ content: String) {
@@ -266,34 +440,34 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
timestamp: Date(), timestamp: Date(),
isRelay: false isRelay: false
) )
viewModel.timelineStore.append(systemMessage, to: viewModel.activeChannel) context.appendTimelineMessage(systemMessage, to: context.activeChannel)
refreshVisibleMessages(from: viewModel.activeChannel) refreshVisibleMessages(from: context.activeChannel)
let contentKey = viewModel.deduplicationService.normalizedContentKey(systemMessage.content) let contentKey = context.normalizedContentKey(systemMessage.content)
viewModel.deduplicationService.recordContentKey(contentKey, timestamp: systemMessage.timestamp) context.recordContentKey(contentKey, timestamp: systemMessage.timestamp)
viewModel.trimMessagesIfNeeded() context.trimMessagesIfNeeded()
viewModel.objectWillChange.send() context.notifyUIChanged()
} }
func addGeohashOnlySystemMessage(_ content: String) { func addGeohashOnlySystemMessage(_ content: String) {
if case .location = viewModel.activeChannel { if case .location = context.activeChannel {
addPublicSystemMessage(content) addPublicSystemMessage(content)
} else { } else {
viewModel.timelineStore.queueGeohashSystemMessage(content) context.queueGeohashSystemMessage(content)
} }
} }
func sendPublicRaw(_ content: String) { func sendPublicRaw(_ content: String) {
if case .location(let channel) = viewModel.activeChannel { if case .location(let channel) = context.activeChannel {
Task { @MainActor [weak viewModel] in Task { @MainActor [weak context] in
guard let viewModel else { return } guard let context else { return }
do { do {
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
let event = try NostrProtocol.createEphemeralGeohashEvent( let event = try NostrProtocol.createEphemeralGeohashEvent(
content: content, content: content,
geohash: channel.geohash, geohash: channel.geohash,
senderIdentity: identity, senderIdentity: identity,
nickname: viewModel.nickname, nickname: context.nickname,
teleported: viewModel.locationManager.teleported teleported: context.isTeleported
) )
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5) let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5)
if targetRelays.isEmpty { if targetRelays.isEmpty {
@@ -308,7 +482,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
return return
} }
viewModel.meshService.sendMessage( context.sendMeshMessage(
content, content,
mentions: [], mentions: [],
messageID: UUID().uuidString, messageID: UUID().uuidString,
@@ -317,15 +491,15 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
} }
func handlePublicMessage(_ message: BitchatMessage) { func handlePublicMessage(_ message: BitchatMessage) {
let finalMessage = viewModel.processActionMessage(message) let finalMessage = context.processActionMessage(message)
if viewModel.isMessageBlocked(finalMessage) { return } if context.isMessageBlocked(finalMessage) { return }
let isGeo = finalMessage.senderPeerID?.isGeoChat == true let isGeo = finalMessage.senderPeerID?.isGeoChat == true
let shouldRateLimit = finalMessage.sender != "system" || finalMessage.senderPeerID != nil let shouldRateLimit = finalMessage.sender != "system" || finalMessage.senderPeerID != nil
if shouldRateLimit { if shouldRateLimit {
let senderKey = normalizedSenderKey(for: finalMessage) let senderKey = normalizedSenderKey(for: finalMessage)
let contentKey = viewModel.deduplicationService.normalizedContentKey(finalMessage.content) let contentKey = context.normalizedContentKey(finalMessage.content)
if !viewModel.publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey) { if !context.allowPublicMessage(senderKey: senderKey, contentKey: contentKey) {
return return
} }
} }
@@ -333,19 +507,19 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
if finalMessage.sender != "system" && finalMessage.content.count > 16000 { return } if finalMessage.sender != "system" && finalMessage.content.count > 16000 { return }
if !isGeo && finalMessage.sender != "system" { if !isGeo && finalMessage.sender != "system" {
viewModel.timelineStore.append(finalMessage, to: .mesh) context.appendTimelineMessage(finalMessage, to: .mesh)
synchronizePublicConversationStore(for: .mesh) synchronizePublicConversationStore(for: .mesh)
} }
if isGeo && finalMessage.sender != "system", if isGeo && finalMessage.sender != "system",
let geohash = viewModel.currentGeohash, let geohash = context.currentGeohash,
viewModel.timelineStore.appendIfAbsent(finalMessage, toGeohash: geohash) { context.appendGeohashMessageIfAbsent(finalMessage, toGeohash: geohash) {
synchronizePublicConversationStore(forGeohash: geohash) synchronizePublicConversationStore(forGeohash: geohash)
} }
let isSystem = finalMessage.sender == "system" let isSystem = finalMessage.sender == "system"
let channelMatches: Bool = { let channelMatches: Bool = {
switch viewModel.activeChannel { switch context.activeChannel {
case .mesh: return !isGeo || isSystem case .mesh: return !isGeo || isSystem
case .location: return isGeo || isSystem case .location: return isGeo || isSystem
} }
@@ -354,22 +528,22 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
guard channelMatches else { return } guard channelMatches else { return }
if !finalMessage.content.trimmed.isEmpty, if !finalMessage.content.trimmed.isEmpty,
!viewModel.messages.contains(where: { $0.id == finalMessage.id }) { !context.messages.contains(where: { $0.id == finalMessage.id }) {
viewModel.publicMessagePipeline.enqueue(finalMessage) context.enqueuePublicMessage(finalMessage)
} }
} }
func checkForMentions(_ message: BitchatMessage) { func checkForMentions(_ message: BitchatMessage) {
var myTokens: Set<String> = [viewModel.nickname] var myTokens: Set<String> = [context.nickname]
let meshPeers = viewModel.meshService.getPeerNicknames() let meshPeers = context.meshPeerNicknames()
let collisions = meshPeers.values.filter { $0.hasPrefix(viewModel.nickname + "#") } let collisions = meshPeers.values.filter { $0.hasPrefix(context.nickname + "#") }
if !collisions.isEmpty { if !collisions.isEmpty {
let suffix = "#" + String(viewModel.meshService.myPeerID.id.prefix(4)) let suffix = "#" + String(context.myPeerID.id.prefix(4))
myTokens = [viewModel.nickname + suffix] myTokens = [context.nickname + suffix]
} }
let isMentioned = message.mentions?.contains(where: myTokens.contains) ?? false 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) SecureLogger.info("🔔 Mention from \(message.sender)", category: .session)
NotificationService.shared.sendMentionNotification(from: message.sender, message: message.content) NotificationService.shared.sendMentionNotification(from: message.sender, message: message.content)
} }
@@ -379,11 +553,11 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
#if os(iOS) #if os(iOS)
guard UIApplication.shared.applicationState == .active else { return } guard UIApplication.shared.applicationState == .active else { return }
var tokens: [String] = [viewModel.nickname] var tokens: [String] = [context.nickname]
switch viewModel.activeChannel { switch context.activeChannel {
case .location(let channel): case .location(let channel):
if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) { if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
tokens.append(viewModel.nickname + "#" + String(identity.publicKeyHex.suffix(4))) tokens.append(context.nickname + "#" + String(identity.publicKeyHex.suffix(4)))
} }
case .mesh: case .mesh:
break break
@@ -394,7 +568,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
let isHugForMe = message.content.contains("🫂") && hugsMe let isHugForMe = message.content.contains("🫂") && hugsMe
let isSlapForMe = message.content.contains("🐟") && slapsMe let isSlapForMe = message.content.contains("🐟") && slapsMe
if isHugForMe && message.sender != viewModel.nickname { if isHugForMe && message.sender != context.nickname {
let impactFeedback = UIImpactFeedbackGenerator(style: .medium) let impactFeedback = UIImpactFeedbackGenerator(style: .medium)
impactFeedback.prepare() impactFeedback.prepare()
@@ -405,7 +579,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
impactFeedback.impactOccurred() impactFeedback.impactOccurred()
} }
} }
} else if isSlapForMe && message.sender != viewModel.nickname { } else if isSlapForMe && message.sender != context.nickname {
let impactFeedback = UIImpactFeedbackGenerator(style: .heavy) let impactFeedback = UIImpactFeedbackGenerator(style: .heavy)
impactFeedback.prepare() impactFeedback.prepare()
impactFeedback.impactOccurred() impactFeedback.impactOccurred()
@@ -414,35 +588,35 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
} }
func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage] { func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage] {
viewModel.messages context.messages
} }
func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage]) { func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage]) {
viewModel.messages = messages context.messages = messages
} }
func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String { func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String {
viewModel.deduplicationService.normalizedContentKey(content) context.normalizedContentKey(content)
} }
func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date? { 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) { func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) {
viewModel.deduplicationService.recordContentKey(key, timestamp: timestamp) context.recordContentKey(key, timestamp: timestamp)
} }
func pipelineTrimMessages(_ pipeline: PublicMessagePipeline) { func pipelineTrimMessages(_ pipeline: PublicMessagePipeline) {
viewModel.trimMessagesIfNeeded() context.trimMessagesIfNeeded()
} }
func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage) { func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage) {
_ = viewModel.formatMessageAsText(message, colorScheme: viewModel.currentColorScheme) context.prewarmMessageFormatting(message)
} }
func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) { 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 { func normalizedSenderKey(for message: BitchatMessage) -> String {
if let senderPeerID = message.senderPeerID { if let senderPeerID = message.senderPeerID {
if senderPeerID.isGeoChat || senderPeerID.isGeoDM { if senderPeerID.isGeoChat || senderPeerID.isGeoDM {
let full = (viewModel.nostrKeyMapping[senderPeerID] ?? senderPeerID.bare).lowercased() let full = (context.nostrKeyMapping[senderPeerID] ?? senderPeerID.bare).lowercased()
return "nostr:" + full return "nostr:" + full
} else if senderPeerID.id.count == 16, } 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 return "noise:" + full
} else { } else {
return "mesh:" + senderPeerID.id.lowercased() return "mesh:" + senderPeerID.id.lowercased()
@@ -66,7 +66,7 @@ final class ChatVerificationCoordinator {
let noiseService = viewModel.meshService.getNoiseService() let noiseService = viewModel.meshService.getNoiseService()
noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in
DispatchQueue.main.async { DispatchQueue.main.async { [weak self] in
guard let self else { return } guard let self else { return }
SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security) SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security)
@@ -103,7 +103,7 @@ final class ChatVerificationCoordinator {
} }
noiseService.onHandshakeRequired = { [weak self] peerID in noiseService.onHandshakeRequired = { [weak self] peerID in
DispatchQueue.main.async { DispatchQueue.main.async { [weak self] in
guard let self else { return } guard let self else { return }
self.viewModel.peerIdentityStore.setEncryptionStatus(.noiseHandshaking, for: peerID) self.viewModel.peerIdentityStore.setEncryptionStatus(.noiseHandshaking, for: peerID)
self.viewModel.invalidateEncryptionCache(for: peerID) self.viewModel.invalidateEncryptionCache(for: peerID)
+18 -6
View File
@@ -152,11 +152,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
private lazy var peerListCoordinator = ChatPeerListCoordinator(viewModel: self) private lazy var peerListCoordinator = ChatPeerListCoordinator(viewModel: self)
private lazy var messageFormatter = ChatMessageFormatter(viewModel: self) private lazy var messageFormatter = ChatMessageFormatter(viewModel: self)
lazy var peerIdentityCoordinator = ChatPeerIdentityCoordinator(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 composerCoordinator = ChatComposerCoordinator(viewModel: self)
lazy var publicConversationCoordinator = ChatPublicConversationCoordinator(viewModel: self) lazy var publicConversationCoordinator = ChatPublicConversationCoordinator(context: self)
lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(viewModel: self) lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(context: self)
lazy var nostrCoordinator = ChatNostrCoordinator(viewModel: self) lazy var nostrCoordinator = ChatNostrCoordinator(context: self)
lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(viewModel: self) lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(viewModel: self)
lazy var verificationCoordinator = ChatVerificationCoordinator(viewModel: self) lazy var verificationCoordinator = ChatVerificationCoordinator(viewModel: self)
@@ -168,7 +168,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
get { privateChatManager.privateChats } get { privateChatManager.privateChats }
set { set {
privateChatManager.privateChats = newValue privateChatManager.privateChats = newValue
synchronizePrivateConversationStore() schedulePrivateConversationStoreSynchronization()
} }
} }
var selectedPrivateChatPeer: PeerID? { var selectedPrivateChatPeer: PeerID? {
@@ -187,7 +187,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
get { privateChatManager.unreadMessages } get { privateChatManager.unreadMessages }
set { set {
privateChatManager.unreadMessages = newValue privateChatManager.unreadMessages = newValue
synchronizePrivateConversationStore() schedulePrivateConversationStoreSynchronization()
} }
} }
@@ -372,6 +372,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// MARK: - Message Delivery Tracking // MARK: - Message Delivery Tracking
var cancellables = Set<AnyCancellable>() var cancellables = Set<AnyCancellable>()
private var pendingPrivateConversationStoreSyncTask: Task<Void, Never>?
var transferIdToMessageIDs: [String: [String]] { var transferIdToMessageIDs: [String: [String]] {
mediaTransferCoordinator.transferIdToMessageIDs mediaTransferCoordinator.transferIdToMessageIDs
@@ -967,6 +968,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
publicConversationCoordinator.synchronizeAllPublicConversationStores() 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 @MainActor
func synchronizePrivateConversationStore() { func synchronizePrivateConversationStore() {
conversationStore.synchronizePrivateChats( conversationStore.synchronizePrivateChats(
@@ -93,7 +93,7 @@ private extension ChatViewModelBootstrapper {
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak viewModel] _ in .sink { [weak viewModel] _ in
Task { @MainActor [weak viewModel] in Task { @MainActor [weak viewModel] in
viewModel?.synchronizePrivateConversationStore() viewModel?.schedulePrivateConversationStoreSynchronization()
} }
} }
.store(in: &viewModel.cancellables) .store(in: &viewModel.cancellables)
@@ -102,7 +102,7 @@ private extension ChatViewModelBootstrapper {
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak viewModel] _ in .sink { [weak viewModel] _ in
Task { @MainActor [weak viewModel] in Task { @MainActor [weak viewModel] in
viewModel?.synchronizePrivateConversationStore() viewModel?.schedulePrivateConversationStoreSynchronization()
} }
} }
.store(in: &viewModel.cancellables) .store(in: &viewModel.cancellables)
+41 -18
View File
@@ -10,7 +10,9 @@ import Foundation
struct PublicTimelineStore { struct PublicTimelineStore {
private var meshTimeline: [BitchatMessage] = [] private var meshTimeline: [BitchatMessage] = []
private var meshMessageIDs: Set<String> = []
private var geohashTimelines: [String: [BitchatMessage]] = [:] private var geohashTimelines: [String: [BitchatMessage]] = [:]
private var geohashMessageIDs: [String: Set<String>] = [:]
private var pendingGeohashSystemMessages: [String] = [] private var pendingGeohashSystemMessages: [String] = []
private let meshCap: Int private let meshCap: Int
@@ -24,8 +26,9 @@ struct PublicTimelineStore {
mutating func append(_ message: BitchatMessage, to channel: ChannelID) { mutating func append(_ message: BitchatMessage, to channel: ChannelID) {
switch channel { switch channel {
case .mesh: case .mesh:
guard !meshTimeline.contains(where: { $0.id == message.id }) else { return } guard !meshMessageIDs.contains(message.id) else { return }
meshTimeline.append(message) meshTimeline.append(message)
meshMessageIDs.insert(message.id)
trimMeshTimelineIfNeeded() trimMeshTimelineIfNeeded()
case .location(let channel): case .location(let channel):
append(message, toGeohash: channel.geohash) append(message, toGeohash: channel.geohash)
@@ -33,21 +36,12 @@ struct PublicTimelineStore {
} }
mutating func append(_ message: BitchatMessage, toGeohash geohash: String) { mutating func append(_ message: BitchatMessage, toGeohash geohash: String) {
var timeline = geohashTimelines[geohash] ?? [] _ = appendGeohashMessageIfAbsent(message, geohash: geohash)
guard !timeline.contains(where: { $0.id == message.id }) else { return }
timeline.append(message)
trimGeohashTimelineIfNeeded(&timeline)
geohashTimelines[geohash] = timeline
} }
/// Append message if absent, returning true when stored. /// Append message if absent, returning true when stored.
mutating func appendIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool { mutating func appendIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool {
var timeline = geohashTimelines[geohash] ?? [] appendGeohashMessageIfAbsent(message, geohash: geohash)
guard !timeline.contains(where: { $0.id == message.id }) else { return false }
timeline.append(message)
trimGeohashTimelineIfNeeded(&timeline)
geohashTimelines[geohash] = timeline
return true
} }
mutating func messages(for channel: ChannelID) -> [BitchatMessage] { mutating func messages(for channel: ChannelID) -> [BitchatMessage] {
@@ -56,7 +50,7 @@ struct PublicTimelineStore {
return meshTimeline return meshTimeline
case .location(let channel): case .location(let channel):
let cleaned = geohashTimelines[channel.geohash]?.cleanedAndDeduped() ?? [] let cleaned = geohashTimelines[channel.geohash]?.cleanedAndDeduped() ?? []
geohashTimelines[channel.geohash] = cleaned replaceGeohashTimeline(cleaned, for: channel.geohash, keepEmpty: true)
return cleaned return cleaned
} }
} }
@@ -65,22 +59,26 @@ struct PublicTimelineStore {
switch channel { switch channel {
case .mesh: case .mesh:
meshTimeline.removeAll() meshTimeline.removeAll()
meshMessageIDs.removeAll()
case .location(let channel): case .location(let channel):
geohashTimelines[channel.geohash] = [] geohashTimelines[channel.geohash] = []
geohashMessageIDs[channel.geohash] = []
} }
} }
@discardableResult @discardableResult
mutating func removeMessage(withID id: String) -> BitchatMessage? { mutating func removeMessage(withID id: String) -> BitchatMessage? {
if let index = meshTimeline.firstIndex(where: { $0.id == id }) { 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) { for key in Array(geohashTimelines.keys) {
var timeline = geohashTimelines[key] ?? [] var timeline = geohashTimelines[key] ?? []
if let index = timeline.firstIndex(where: { $0.id == id }) { if let index = timeline.firstIndex(where: { $0.id == id }) {
let removed = timeline.remove(at: index) let removed = timeline.remove(at: index)
geohashTimelines[key] = timeline.isEmpty ? nil : timeline replaceGeohashTimeline(timeline, for: key, keepEmpty: false)
return removed return removed
} }
} }
@@ -91,13 +89,13 @@ struct PublicTimelineStore {
mutating func removeMessages(in geohash: String, where predicate: (BitchatMessage) -> Bool) { mutating func removeMessages(in geohash: String, where predicate: (BitchatMessage) -> Bool) {
var timeline = geohashTimelines[geohash] ?? [] var timeline = geohashTimelines[geohash] ?? []
timeline.removeAll(where: predicate) 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) { mutating func mutateGeohash(_ geohash: String, _ transform: (inout [BitchatMessage]) -> Void) {
var timeline = geohashTimelines[geohash] ?? [] var timeline = geohashTimelines[geohash] ?? []
transform(&timeline) transform(&timeline)
geohashTimelines[geohash] = timeline.isEmpty ? nil : timeline replaceGeohashTimeline(timeline, for: geohash, keepEmpty: false)
} }
mutating func queueGeohashSystemMessage(_ content: String) { mutating func queueGeohashSystemMessage(_ content: String) {
@@ -116,10 +114,35 @@ struct PublicTimelineStore {
private mutating func trimMeshTimelineIfNeeded() { private mutating func trimMeshTimelineIfNeeded() {
guard meshTimeline.count > meshCap else { return } guard meshTimeline.count > meshCap else { return }
meshTimeline = Array(meshTimeline.suffix(meshCap)) 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<String>) {
guard timeline.count > geohashCap else { return } guard timeline.count > geohashCap else { return }
timeline = Array(timeline.suffix(geohashCap)) 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))
} }
} }
+5 -6
View File
@@ -305,10 +305,10 @@ private extension MessageListView {
var targetPeerID: String? { var targetPeerID: String? {
if let peer = privatePeer, 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)" 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 "\(locationChannelsModel.selectedChannel.contextKey)|\(last)"
} }
return nil return nil
@@ -329,7 +329,7 @@ private extension MessageListView {
func scrollIfNeeded(date: Date) { func scrollIfNeeded(date: Date) {
lastScrollTime = date lastScrollTime = date
let contextKey = locationChannelsModel.selectedChannel.contextKey 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) proxy.scrollTo(target, anchor: .bottom)
} }
} }
@@ -368,8 +368,7 @@ private extension MessageListView {
func scrollIfNeeded(date: Date) { func scrollIfNeeded(date: Date) {
lastScrollTime = date lastScrollTime = date
let contextKey = "dm:\(peerID)" let contextKey = "dm:\(peerID)"
let count = windowCountPrivate[peerID] ?? 300 if let target = messages.last.map({ "\(contextKey)|\($0.id)" }) {
if let target = messages.suffix(count).last.map({ "\(contextKey)|\($0.id)" }){
proxy.scrollTo(target, anchor: .bottom) proxy.scrollTo(target, anchor: .bottom)
} }
} }
@@ -399,7 +398,7 @@ private extension MessageListView {
isAtBottom = true isAtBottom = true
windowCountPublic = TransportConfig.uiWindowInitialCountPublic windowCountPublic = TransportConfig.uiWindowInitialCountPublic
let contextKey = "geo:\(ch.geohash)" 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) proxy.scrollTo(target, anchor: .bottom)
} }
} }
@@ -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<String> = []
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<String> = []
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<String> = []
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<String> = []
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)
}
}
@@ -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<String> = []
var sentGeoDeliveryAcks: Set<String> = []
var unreadPrivateMessages: Set<PeerID> = []
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<PeerID> = []
var reachablePeers: Set<PeerID> = []
var blockedPeers: Set<PeerID> = []
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<String> = []
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)
}
}
@@ -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<PeerID> = []
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<String> = []
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<String> = []
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))
}
}
@@ -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 @Test @MainActor
func deliveryStatus_upgrade_deliveredToRead() async { func deliveryStatus_upgrade_deliveredToRead() async {
let (viewModel, transport) = makeTestableViewModel() 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) // MARK: - Status Rank Tests (for deduplication)
@Test @MainActor @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<String> = []
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
}
@@ -398,6 +398,26 @@ struct ChatViewModelNostrExtensionTests {
#expect(viewModel.privateChats.isEmpty) #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 @Test @MainActor
func switchLocationChannel_clearsNostrDedupCache() async { func switchLocationChannel_clearsNostrDedupCache() async {
let (viewModel, _) = makeTestableViewModel() let (viewModel, _) = makeTestableViewModel()
+28
View File
@@ -20,4 +20,32 @@ struct GCSFilterTests {
#expect(bucket != 0) #expect(bucket != 0)
#expect(bucket < 2) #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)
}
} }
+69
View File
@@ -278,6 +278,75 @@ struct ChatViewModelPresenceHandlingTests {
#expect(count >= 1) #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 // MARK: - Test Helper
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) { private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
@@ -0,0 +1,220 @@
//
// LargeTopologyTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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..<names.count {
helper.connect(names[i], names[(i + 1) % names.count])
}
for i in [0, 3, 6, 9] {
helper.connect(names[i], names[(i + 5) % names.count])
}
let log = installDeliveryLog()
helper.nodes[names[0]]!.sendMessage("mesh broadcast")
let deliveries = log.deliveries(of: "mesh broadcast")
for name in names.dropFirst() {
#expect(deliveries[name] == 1, "\(name) should receive exactly once despite redundant paths, got \(String(describing: deliveries[name]))")
}
#expect(deliveries[names[0]] == nil, "sender must not receive its own broadcast")
}
// MARK: - Partition and heal
@Test func partitionIsolatesComponentsAndBridgeHeals() {
// Two 5-node components, each with a redundant internal edge.
let names = (0..<10).map { "P\($0)" }
makeNodes(names)
for i in 0..<4 { helper.connect("P\(i)", "P\(i + 1)") }
helper.connect("P0", "P2")
for i in 5..<9 { helper.connect("P\(i)", "P\(i + 1)") }
helper.connect("P7", "P9")
let log = installDeliveryLog()
// While partitioned, nothing crosses.
helper.nodes["P0"]!.sendMessage("pre-heal")
var deliveries = log.deliveries(of: "pre-heal")
for i in 1...4 { #expect(deliveries["P\(i)"] == 1, "P\(i) is in the sender's component") }
for i in 5...9 { #expect(deliveries["P\(i)"] == nil, "P\(i) must not receive across the partition") }
// Heal with a single bridge edge; newly sent messages flow across.
helper.connect("P4", "P5")
helper.nodes["P0"]!.sendMessage("post-heal")
deliveries = log.deliveries(of: "post-heal")
for i in 1...9 { #expect(deliveries["P\(i)"] == 1, "P\(i) should receive after the bridge heals the partition") }
// The harness has no store-and-forward: the pre-partition message is
// not retroactively delivered once the bridge appears.
let preHeal = log.deliveries(of: "pre-heal")
for i in 5...9 { #expect(preHeal["P\(i)"] == nil, "no store-and-forward of pre-partition messages") }
}
// MARK: - Churn
@Test func churnDeliversToCurrentlyConnectedComponentOnly() {
// 10-peer ring, then repeatedly mutate the topology between broadcasts.
let names = (0..<10).map { "C\($0)" }
makeNodes(names)
for i in 0..<10 { helper.connect("C\(i)", "C\((i + 1) % 10)") }
let log = installDeliveryLog()
// Round 1: cut the ring in two places ->
// 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")
}
}
@@ -1,4 +1,5 @@
import Foundation import Foundation
import BitFoundation
import Testing import Testing
@testable import bitchat @testable import bitchat
@@ -21,6 +22,38 @@ struct PublicTimelineStoreTests {
#expect(messages.map(\.content) == ["two", "three"]) #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") @Test("Geohash appendIfAbsent remove and clear work together")
func geohashStoreSupportsAppendRemoveAndClear() { func geohashStoreSupportsAppendRemoveAndClear() {
var store = PublicTimelineStore(meshCap: 2, geohashCap: 3) var store = PublicTimelineStore(meshCap: 2, geohashCap: 3)
@@ -69,4 +102,14 @@ struct PublicTimelineStoreTests {
#expect(store.drainPendingGeohashSystemMessages() == ["first", "second"]) #expect(store.drainPendingGeohashSystemMessages() == ["first", "second"])
#expect(store.drainPendingGeohashSystemMessages().isEmpty) #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
)
}
} }
@@ -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<String> = []
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)
}
}
@@ -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
)
}
}
@@ -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
)
}
}
@@ -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<Data?, Error> = .success(nil)
var hasSession = false
var decryptResult: Result<Data, Error> = .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
)
}
}
@@ -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)
}
}
@@ -42,6 +42,74 @@ struct MessageRouterTests {
#expect(transport.sentPrivateMessages.count == 1) #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 @Test @MainActor
func sendReadReceipt_usesReachableTransport() async { func sendReadReceipt_usesReachableTransport() async {
let peerID = PeerID(str: "0000000000000003") let peerID = PeerID(str: "0000000000000003")
@@ -90,6 +90,90 @@ final class NostrRelayManagerTests: XCTestCase {
XCTAssertFalse(context.manager.isConnected) 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..<TransportConfig.nostrTorReadyMaxWaitAttempts {
context.torWaiter.resolve(false)
}
// Fail-closed (no sessions), but the EOSE caller is unblocked.
XCTAssertEqual(eoseCount, 1)
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
}
func test_sendEvent_survivesFailedTorWaitAndSendsWhenTorRecovers() async throws {
let relayURL = "wss://tor-send-retry.example"
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
let event = try makeSignedEvent(content: "queued through tor stall")
context.manager.sendEvent(event, to: [relayURL])
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 1)
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
context.torWaiter.resolve(false)
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 1)
context.torWaiter.resolve(true)
let sent = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1 &&
context.manager.debugPendingMessageQueueCount == 0
}
XCTAssertTrue(sent)
}
func test_sendEvent_pendingQueueDropsOldestBeyondCap() async throws {
let relayURL = "wss://tor-send-cap.example"
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
let identity = try NostrIdentity.generate()
for i in 0...(TransportConfig.nostrPendingSendQueueCap + 4) {
let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(),
kind: .textNote,
tags: [],
content: "cap-\(i)"
)
context.manager.sendEvent(try event.sign(with: identity.schnorrSigningKey()), to: [relayURL])
}
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, TransportConfig.nostrPendingSendQueueCap)
}
func test_sendEvent_waitsForTorReadinessBeforeSending() async throws { func test_sendEvent_waitsForTorReadinessBeforeSending() async throws {
let relayURL = "wss://tor-ready.example" let relayURL = "wss://tor-ready.example"
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false) let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
@@ -439,6 +523,114 @@ final class NostrRelayManagerTests: XCTestCase {
XCTAssertEqual(receivedEvent?.id, event.id) XCTAssertEqual(receivedEvent?.id, event.id)
} }
func test_receiveEvent_deduplicatesSameSubscriptionEventAcrossRelays() async throws {
let firstRelayURL = "wss://events-one.example"
let secondRelayURL = "wss://events-two.example"
let context = makeContext(permission: .denied)
let event = try makeSignedEvent(content: "duplicate")
var receivedIDs: [String] = []
context.manager.subscribe(
filter: makeFilter(),
id: "events",
relayUrls: [firstRelayURL, secondRelayURL]
) { event in
receivedIDs.append(event.id)
}
let subscriptionsSent = await waitUntil {
context.sessionFactory.latestConnection(for: firstRelayURL)?.sentStrings.count == 1 &&
context.sessionFactory.latestConnection(for: secondRelayURL)?.sentStrings.count == 1
}
XCTAssertTrue(subscriptionsSent)
try context.sessionFactory.latestConnection(for: firstRelayURL)?.emitEventMessage(subscriptionID: "events", event: event)
try context.sessionFactory.latestConnection(for: secondRelayURL)?.emitEventMessage(subscriptionID: "events", event: event)
let countedOnBothRelays = await waitUntil {
context.manager.relays.first(where: { $0.url == firstRelayURL })?.messagesReceived == 1 &&
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1
}
XCTAssertTrue(countedOnBothRelays)
XCTAssertEqual(receivedIDs, [event.id])
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 1)
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount(forSubscriptionID: "events"), 1)
}
func test_receiveEvent_duplicateFanInDeliversOnceAndCountsDrops() async throws {
let relayURLs = (0..<8).map { "wss://fan-in-\($0).example" }
let context = makeContext(permission: .denied)
let event = try makeSignedEvent(content: "fan-in")
var receivedIDs: [String] = []
context.manager.subscribe(
filter: makeFilter(),
id: "presence",
relayUrls: relayURLs
) { event in
receivedIDs.append(event.id)
}
let subscriptionsSent = await waitUntil {
relayURLs.allSatisfy { relayURL in
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
}
}
XCTAssertTrue(subscriptionsSent)
for relayURL in relayURLs {
try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(
subscriptionID: "presence",
event: event
)
}
let countedOnEveryRelay = await waitUntil {
relayURLs.allSatisfy { relayURL in
context.manager.relays.first(where: { $0.url == relayURL })?.messagesReceived == 1
}
}
XCTAssertTrue(countedOnEveryRelay)
XCTAssertEqual(receivedIDs, [event.id])
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, relayURLs.count - 1)
XCTAssertEqual(
context.manager.debugDuplicateInboundEventDropCount(forSubscriptionID: "presence"),
relayURLs.count - 1
)
}
func test_receiveEvent_invalidSignatureDoesNotPoisonDuplicateCache() async throws {
let firstRelayURL = "wss://invalid-first-one.example"
let secondRelayURL = "wss://invalid-first-two.example"
let context = makeContext(permission: .denied)
let event = try makeSignedEvent(content: "valid-after-invalid")
let invalidEvent = invalidSignatureCopy(of: event)
var receivedIDs: [String] = []
context.manager.subscribe(
filter: makeFilter(),
id: "events",
relayUrls: [firstRelayURL, secondRelayURL]
) { event in
receivedIDs.append(event.id)
}
let subscriptionsSent = await waitUntil {
context.sessionFactory.latestConnection(for: firstRelayURL)?.sentStrings.count == 1 &&
context.sessionFactory.latestConnection(for: secondRelayURL)?.sentStrings.count == 1
}
XCTAssertTrue(subscriptionsSent)
try context.sessionFactory.latestConnection(for: firstRelayURL)?.emitEventMessage(subscriptionID: "events", event: invalidEvent)
try context.sessionFactory.latestConnection(for: secondRelayURL)?.emitEventMessage(subscriptionID: "events", event: event)
let countedOnBothRelays = await waitUntil {
context.manager.relays.first(where: { $0.url == firstRelayURL })?.messagesReceived == 1 &&
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1
}
XCTAssertTrue(countedOnBothRelays)
XCTAssertEqual(receivedIDs, [event.id])
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 0)
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount(forSubscriptionID: "events"), 0)
}
func test_receiveEvent_withoutHandlerStillTracksReceivedCount() async throws { func test_receiveEvent_withoutHandlerStillTracksReceivedCount() async throws {
let relayURL = "wss://missing-handler.example" let relayURL = "wss://missing-handler.example"
let context = makeContext(permission: .denied) let context = makeContext(permission: .denied)
@@ -880,6 +1072,12 @@ final class NostrRelayManagerTests: XCTestCase {
return try event.sign(with: identity.schnorrSigningKey()) return try event.sign(with: identity.schnorrSigningKey())
} }
private func invalidSignatureCopy(of event: NostrEvent) -> NostrEvent {
var invalid = event
invalid.sig = String(repeating: "0", count: 128)
return invalid
}
private func waitUntil( private func waitUntil(
timeout: TimeInterval = 1.0, timeout: TimeInterval = 1.0,
condition: @escaping @MainActor () -> Bool condition: @escaping @MainActor () -> Bool
+88
View File
@@ -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.
+7 -26
View File
@@ -2,7 +2,7 @@ Tor-by-default integration (scaffold)
Overview Overview
- All network traffic is routed via a local Tor SOCKS5 proxy by default, with fail-closed behavior when Tor isnt ready. There are no user-visible settings. - All network traffic is routed via a local Tor SOCKS5 proxy by default, with fail-closed behavior when Tor isnt 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 Key pieces
- TorManager - TorManager
@@ -12,36 +12,17 @@ Key pieces
- Provides a shared URLSession configured with a SOCKS5 proxy when Tor is enforced/ready. - 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. - NostrRelayManager and GeoRelayDirectory now use this session and await Tor readiness before starting network activity.
Dropin steps Artifact maintenance
1) Build or obtain a small Tor framework - Binary provenance, rebuild steps, and current hashes are documented in `docs/ARTI-BINARY-PROVENANCE.md`.
- Recommended: Tor C (client-only) with static linking and dead-strip. - The xcframework must include iOS device, iOS simulator, and macOS arm64 slices.
- Configure Tor with a minimal feature set: - Any refresh should review the Rust source, `Cargo.lock`, generated header, build script, and new hashes together.
./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.
2) Add the framework to Xcode targets Verification
- 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
- On app launch, TorManager.startIfNeeded() is called implicitly by awaitReady(). - On app launch, TorManager.startIfNeeded() is called implicitly by awaitReady().
- NostrRelayManager.connect() awaits readiness, then creates WebSocket tasks via TorURLSession.shared. - NostrRelayManager.connect() awaits readiness, then creates WebSocket tasks via TorURLSession.shared.
- GeoRelayDirectory.fetchRemote() awaits readiness, then fetches 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. - 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 torrc template
+18 -3
View File
@@ -4,14 +4,16 @@ BitChat Privacy Assessment
Scope Scope
- Mesh transport (BLE) behavior and metadata minimization - Mesh transport (BLE) behavior and metadata minimization
- Nostr-based private message fallback (gift-wrapped, end-to-end encrypted) - 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 - Read receipts and delivery acknowledgments
- Logging/telemetry posture and controls - Logging/telemetry posture and controls
Summary 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. - 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. - 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. - 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 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. - 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. - 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 Read Receipts and Delivery Acks
- Routing policy: Prefer mesh if Noise session established; otherwise use Nostr when mapping exists. - 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. - 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. - 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. - 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. - 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. - Panic: Triple-tap clears keys, sessions, cached state, and disconnects transports.
Logging and Telemetry 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. - 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. - 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. - 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) Recommendations (Next)
- Add optional coalesced READ behavior for large backlogs. - Add optional coalesced READ behavior for large backlogs.
- Expose a “low-visibility mode” to reduce scanning aggressiveness in sensitive contexts. - Expose a “low-visibility mode” to reduce scanning aggressiveness in sensitive contexts.
- Allow user-configurable Nostr relay set with a “private relays only” toggle. - 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.
+16 -16
View File
@@ -4,22 +4,6 @@
<dict> <dict>
<key>AvailableLibraries</key> <key>AvailableLibraries</key>
<array> <array>
<dict>
<key>BinaryPath</key>
<string>libarti_bitchat.a</string>
<key>HeadersPath</key>
<string>Headers</string>
<key>LibraryIdentifier</key>
<string>macos-arm64</string>
<key>LibraryPath</key>
<string>libarti_bitchat.a</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
</array>
<key>SupportedPlatform</key>
<string>macos</string>
</dict>
<dict> <dict>
<key>BinaryPath</key> <key>BinaryPath</key>
<string>libarti_bitchat.a</string> <string>libarti_bitchat.a</string>
@@ -54,6 +38,22 @@
<key>SupportedPlatformVariant</key> <key>SupportedPlatformVariant</key>
<string>simulator</string> <string>simulator</string>
</dict> </dict>
<dict>
<key>BinaryPath</key>
<string>libarti_bitchat.a</string>
<key>HeadersPath</key>
<string>Headers</string>
<key>LibraryIdentifier</key>
<string>macos-arm64</string>
<key>LibraryPath</key>
<string>libarti_bitchat.a</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
</array>
<key>SupportedPlatform</key>
<string>macos</string>
</dict>
</array> </array>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>XFWK</string> <string>XFWK</string>
+2 -1
View File
@@ -37,7 +37,8 @@ let package = Package(
.linkedLibrary("sqlite3"), .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( .binaryTarget(
name: "arti", name: "arti",
path: "Frameworks/arti.xcframework" path: "Frameworks/arti.xcframework"
+3 -1
View File
@@ -110,7 +110,9 @@ public final class TorManager: ObservableObject {
public func isForeground() -> Bool { isAppForeground } public func isForeground() -> Bool { isAppForeground }
nonisolated 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 { await MainActor.run {
if self.isAppForeground { self.startIfNeeded() } if self.isAppForeground { self.startIfNeeded() }
} }
+1 -1
View File
@@ -2,7 +2,7 @@
name = "arti-bitchat" name = "arti-bitchat"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
rust-version = "1.86" rust-version = "1.90"
[lib] [lib]
crate-type = ["staticlib"] crate-type = ["staticlib"]
+70
View File
@@ -133,6 +133,11 @@ create_xcframework() {
log_info "Stripping $target library..." log_info "Stripping $target library..."
strip -x "$lib_path" 2>/dev/null || true 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" cmd="$cmd -library $lib_path"
# Add headers if they exist # Add headers if they exist
@@ -151,6 +156,71 @@ create_xcframework() {
eval "$cmd" eval "$cmd"
if [[ -d "$xcframework_path" ]]; then if [[ -d "$xcframework_path" ]]; then
cat > "$xcframework_path/Info.plist" << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>AvailableLibraries</key>
<array>
<dict>
<key>BinaryPath</key>
<string>libarti_bitchat.a</string>
<key>HeadersPath</key>
<string>Headers</string>
<key>LibraryIdentifier</key>
<string>ios-arm64</string>
<key>LibraryPath</key>
<string>libarti_bitchat.a</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
</array>
<key>SupportedPlatform</key>
<string>ios</string>
</dict>
<dict>
<key>BinaryPath</key>
<string>libarti_bitchat.a</string>
<key>HeadersPath</key>
<string>Headers</string>
<key>LibraryIdentifier</key>
<string>ios-arm64-simulator</string>
<key>LibraryPath</key>
<string>libarti_bitchat.a</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
</array>
<key>SupportedPlatform</key>
<string>ios</string>
<key>SupportedPlatformVariant</key>
<string>simulator</string>
</dict>
<dict>
<key>BinaryPath</key>
<string>libarti_bitchat.a</string>
<key>HeadersPath</key>
<string>Headers</string>
<key>LibraryIdentifier</key>
<string>macos-arm64</string>
<key>LibraryPath</key>
<string>libarti_bitchat.a</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
</array>
<key>SupportedPlatform</key>
<string>macos</string>
</dict>
</array>
<key>CFBundlePackageType</key>
<string>XFWK</string>
<key>XCFrameworkFormatVersion</key>
<string>1.0</string>
</dict>
</plist>
EOF
local size=$(du -sh "$xcframework_path" | cut -f1) local size=$(du -sh "$xcframework_path" | cut -f1)
log_info "Created $xcframework_path ($size)" log_info "Created $xcframework_path ($size)"
else else