mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
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:
@@ -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
|
||||
@@ -40,3 +40,25 @@ jobs:
|
||||
|
||||
- name: Run Tests
|
||||
run: swift test --parallel --quiet --package-path ${{ matrix.path }}
|
||||
|
||||
# SPM tests above only compile the macOS slice; this job covers the
|
||||
# iOS-conditional code paths (UIKit, CoreBluetooth restoration, etc.).
|
||||
ios-build:
|
||||
name: Build iOS app (simulator)
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Build iOS (simulator, no signing)
|
||||
# arm64 only: the vendored arti.xcframework has no x86_64 simulator slice.
|
||||
run: |
|
||||
set -o pipefail
|
||||
xcodebuild -project bitchat.xcodeproj \
|
||||
-scheme "bitchat (iOS)" \
|
||||
-sdk iphonesimulator \
|
||||
-destination 'generic/platform=iOS Simulator' \
|
||||
ARCHS=arm64 \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
build
|
||||
|
||||
+42
-16
@@ -1,6 +1,6 @@
|
||||
# bitchat Privacy Policy
|
||||
|
||||
*Last updated: January 2025*
|
||||
*Last updated: June 2026*
|
||||
|
||||
## Our Commitment
|
||||
|
||||
@@ -9,7 +9,7 @@ bitchat is designed with privacy as its foundation. We believe private communica
|
||||
## Summary
|
||||
|
||||
- **No personal data collection** - We don't collect names, emails, or phone numbers
|
||||
- **No servers** - Everything happens on your device and through peer-to-peer connections
|
||||
- **No accounts or company servers** - Mesh chat works peer-to-peer; optional Nostr features use public or user-selected relays
|
||||
- **No tracking** - We have no analytics, telemetry, or user tracking
|
||||
- **Open source** - You can verify these claims by reading our code
|
||||
|
||||
@@ -17,11 +17,11 @@ bitchat is designed with privacy as its foundation. We believe private communica
|
||||
|
||||
### On Your Device Only
|
||||
|
||||
1. **Identity Key**
|
||||
- A cryptographic key generated on first launch
|
||||
1. **Identity Keys**
|
||||
- Cryptographic private keys generated on first launch or when optional Nostr identities are created
|
||||
- Stored locally in your device's secure storage
|
||||
- Allows you to maintain "favorite" relationships across app restarts
|
||||
- Never leaves your device
|
||||
- Private keys never leave your device; public keys are shared when needed for messaging
|
||||
|
||||
2. **Nickname**
|
||||
- The display name you choose (or auto-generated)
|
||||
@@ -38,12 +38,19 @@ bitchat is designed with privacy as its foundation. We believe private communica
|
||||
- Stored only on your device
|
||||
- Allows you to recognize these peers in future sessions
|
||||
|
||||
5. **Optional Location Channel State**
|
||||
- Your selected geohash channel, bookmarked geohashes, teleport flags, and bookmark display names
|
||||
- Stored locally on your device so the location-channel UI can restore your choices
|
||||
- Per-geohash Nostr identities are derived locally from a device seed stored in secure storage
|
||||
- Exact latitude and longitude are not persisted by bitchat
|
||||
|
||||
### Temporary Session Data
|
||||
|
||||
During each session, bitchat temporarily maintains:
|
||||
- Active peer connections (forgotten when app closes)
|
||||
- Routing information for message delivery
|
||||
- Cached messages for offline peers (12 hours max)
|
||||
- Your current location while optional location channels are enabled, used locally to compute geohash channels and friendly place names
|
||||
|
||||
## What Information is Shared
|
||||
|
||||
@@ -62,13 +69,21 @@ When you join a password-protected room:
|
||||
- Your nickname appears in the member list
|
||||
- Room owners can see you've joined
|
||||
|
||||
### With Nostr Relays (Optional Features)
|
||||
|
||||
If you enable Nostr-backed features:
|
||||
- Private fallback messages to mutual favorites are sent as encrypted NIP-17 gift wraps. Relays can see event metadata, but not message content.
|
||||
- Public location-channel messages, location notes, and presence are scoped with geohash tags. Relays and other participants can see the geohash tag, event kind, timestamp, and public key used for that geohash.
|
||||
- Exact GPS coordinates are not included in Nostr events by bitchat. The geohash precision you choose can still reveal an approximate area, from region-level to building-level.
|
||||
- Automatic presence heartbeats are limited to low-precision geohashes (region, province, and city). More precise geohash posts happen only when you use those channels or location notes.
|
||||
|
||||
## What We DON'T Do
|
||||
|
||||
bitchat **never**:
|
||||
- Collects personal information
|
||||
- Tracks your location
|
||||
- Stores data on servers
|
||||
- Shares data with third parties
|
||||
- Sells or shares your exact GPS location
|
||||
- Stores data on servers we operate
|
||||
- Sells your data to advertisers or data brokers
|
||||
- Uses analytics or telemetry
|
||||
- Creates user profiles
|
||||
- Requires registration
|
||||
@@ -84,19 +99,27 @@ All private messages use end-to-end encryption:
|
||||
## Your Rights
|
||||
|
||||
You have complete control:
|
||||
- **Delete Everything**: Triple-tap the logo to instantly wipe all data
|
||||
- **Leave Anytime**: Close the app and your presence disappears
|
||||
- **No Account**: Nothing to delete from servers because there are none
|
||||
- **Portability**: Your data never leaves your device unless you export it
|
||||
- **Delete Local State**: Triple-tap the logo to instantly wipe local keys, sessions, caches, and preferences
|
||||
- **Leave Anytime**: Close the app and local presence stops; relay-backed presence ages out
|
||||
- **No Account**: No account record exists for you to delete from us
|
||||
- **Portability**: Your local state stays on your device unless you send messages, use optional relay-backed features, or export it
|
||||
|
||||
## Bluetooth & Permissions
|
||||
|
||||
bitchat requires Bluetooth permission to function:
|
||||
- Used only for peer-to-peer communication
|
||||
- No location data is accessed or stored
|
||||
- Bluetooth is not used for tracking
|
||||
- You can revoke this permission at any time in system settings
|
||||
|
||||
## Location Permission
|
||||
|
||||
Location permission is optional and is used only for location channels:
|
||||
- Used to compute local geohash channels and display names
|
||||
- Requested as when-in-use permission
|
||||
- Exact coordinates are not shared in messages or stored by bitchat
|
||||
- Selected and bookmarked geohashes may persist locally until you remove them, use panic wipe, or delete the app
|
||||
- You can revoke this permission at any time in system settings
|
||||
|
||||
## Children's Privacy
|
||||
|
||||
bitchat does not knowingly collect information from children. The app has no age verification because it collects no personal information from anyone.
|
||||
@@ -106,12 +129,15 @@ bitchat does not knowingly collect information from children. The app has no age
|
||||
- **Messages**: Deleted from memory when app closes (unless room retention is enabled)
|
||||
- **Identity Key**: Persists until you delete the app
|
||||
- **Favorites**: Persist until you remove them or delete the app
|
||||
- **Location channel choices**: Selected/bookmarked geohashes persist locally until removed, panic-wiped, or the app is deleted
|
||||
- **Nostr relay data**: Public geohash events and encrypted gift wraps may be retained by relays according to each relay's policy
|
||||
- **Everything Else**: Exists only during active sessions
|
||||
|
||||
## Security Measures
|
||||
|
||||
- All communication is encrypted
|
||||
- No data transmitted to servers (there are none)
|
||||
- No accounts or company servers
|
||||
- Optional Nostr relays receive only the events needed for Nostr-backed private fallback or public location channels
|
||||
- Open source code for public audit
|
||||
- Regular security updates
|
||||
- Cryptographic signatures prevent tampering
|
||||
@@ -121,7 +147,7 @@ bitchat does not knowingly collect information from children. The app has no age
|
||||
If we update this policy:
|
||||
- The "Last updated" date will change
|
||||
- The updated policy will be included in the app
|
||||
- No retroactive changes can affect data (since we don't collect any)
|
||||
- No retroactive changes can make us collect data already held only in your app
|
||||
|
||||
## Contact
|
||||
|
||||
@@ -132,7 +158,7 @@ bitchat is an open source project. For privacy questions:
|
||||
|
||||
## Philosophy
|
||||
|
||||
Privacy isn't just a feature—it's the entire point. bitchat proves that modern communication doesn't require surrendering your privacy. No accounts, no servers, no surveillance. Just people talking freely.
|
||||
Privacy isn't just a feature—it's the entire point. bitchat proves that modern communication doesn't require surrendering your privacy. No accounts, no company servers, no analytics. Just people talking freely.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -207,9 +207,14 @@ final class ConversationStore: ObservableObject {
|
||||
private var directHandlesByConversation: [ConversationID: PeerHandle] = [:]
|
||||
|
||||
func setActiveChannel(_ channelID: ChannelID) {
|
||||
if activeChannel != channelID {
|
||||
activeChannel = channelID
|
||||
}
|
||||
if selectedPrivatePeerID == nil {
|
||||
selectedConversationID = ConversationID(channelID: channelID)
|
||||
let conversationID = ConversationID(channelID: channelID)
|
||||
if selectedConversationID != conversationID {
|
||||
selectedConversationID = conversationID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,21 +223,33 @@ final class ConversationStore: ObservableObject {
|
||||
activeChannel: ChannelID,
|
||||
identityResolver: IdentityResolver
|
||||
) {
|
||||
if self.activeChannel != activeChannel {
|
||||
self.activeChannel = activeChannel
|
||||
}
|
||||
if selectedPrivatePeerID != peerID {
|
||||
selectedPrivatePeerID = peerID
|
||||
}
|
||||
|
||||
if let peerID {
|
||||
selectedConversationID = directConversationID(
|
||||
let conversationID = directConversationID(
|
||||
for: peerID,
|
||||
identityResolver: identityResolver
|
||||
)
|
||||
if selectedConversationID != conversationID {
|
||||
selectedConversationID = conversationID
|
||||
}
|
||||
} else {
|
||||
selectedConversationID = ConversationID(channelID: activeChannel)
|
||||
let conversationID = ConversationID(channelID: activeChannel)
|
||||
if selectedConversationID != conversationID {
|
||||
selectedConversationID = conversationID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func replaceMessages(_ messages: [BitchatMessage], for conversationID: ConversationID) {
|
||||
messagesByConversation[conversationID] = normalized(messages)
|
||||
let normalizedMessages = normalized(messages)
|
||||
guard messagesByConversation[conversationID] != normalizedMessages else { return }
|
||||
messagesByConversation[conversationID] = normalizedMessages
|
||||
}
|
||||
|
||||
func replaceMessages(_ messages: [BitchatMessage], for channelID: ChannelID) {
|
||||
@@ -296,7 +313,7 @@ final class ConversationStore: ObservableObject {
|
||||
let conversationID = ConversationID.direct(handle)
|
||||
liveConversations.insert(conversationID)
|
||||
directHandlesByConversation[conversationID] = handle
|
||||
messagesByConversation[conversationID] = normalized(messages)
|
||||
replaceMessages(messages, for: conversationID)
|
||||
}
|
||||
|
||||
let staleDirectConversations = messagesByConversation.keys.filter { conversationID in
|
||||
@@ -319,10 +336,13 @@ final class ConversationStore: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
unreadConversations = unreadPeerIDs.reduce(into: publicUnread) { result, peerID in
|
||||
let nextUnreadConversations = unreadPeerIDs.reduce(into: publicUnread) { result, peerID in
|
||||
let handle = identityResolver.canonicalHandle(for: peerID)
|
||||
result.insert(.direct(handle))
|
||||
}
|
||||
if unreadConversations != nextUnreadConversations {
|
||||
unreadConversations = nextUnreadConversations
|
||||
}
|
||||
}
|
||||
|
||||
func markRead(_ conversationID: ConversationID) {
|
||||
|
||||
@@ -63,6 +63,7 @@ final class PrivateInboxModel: ObservableObject {
|
||||
nextMessagesByPeerID[peerID] = []
|
||||
}
|
||||
|
||||
guard messagesByPeerID != nextMessagesByPeerID else { return }
|
||||
messagesByPeerID = nextMessagesByPeerID
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ final class PublicChatModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func refreshMessages() {
|
||||
messages = conversationStore.messages(for: ConversationID(channelID: activeChannel))
|
||||
let nextMessages = conversationStore.messages(for: ConversationID(channelID: activeChannel))
|
||||
guard messages != nextMessages else { return }
|
||||
messages = nextMessages
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24397,7 +24397,7 @@
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "chat with people near you using geohash channels. only a coarse geohash is shared, never exact GPS. your IP address is hidden by routing all traffic over tor."
|
||||
"value" : "chat with people near you using geohash channels. the selected geohash is public and may reveal an approximate area; exact GPS is never shared. your IP address is hidden by routing all traffic over tor."
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,11 +142,22 @@ final class NostrRelayManager: ObservableObject {
|
||||
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 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.
|
||||
private let subscribeCoalesceInterval: TimeInterval = 1.0
|
||||
private var subscribeCoalesce: [String: Date] = [:]
|
||||
private var pendingTorConnectionURLs = Set<String>()
|
||||
private var awaitingTorForConnections = false
|
||||
private var torReadyWaitAttempts = 0
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private struct SubscriptionRequestState: Equatable {
|
||||
@@ -263,6 +274,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
eoseTrackers.removeAll()
|
||||
pendingTorConnectionURLs.removeAll()
|
||||
awaitingTorForConnections = false
|
||||
torReadyWaitAttempts = 0
|
||||
updateConnectionStatus()
|
||||
}
|
||||
|
||||
@@ -285,11 +297,14 @@ final class NostrRelayManager: ObservableObject {
|
||||
// Global network policy gate
|
||||
guard dependencies.activationAllowed() else { return }
|
||||
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
||||
// Defer sends until Tor is ready to avoid premature queueing
|
||||
dependencies.awaitTorReady { [weak self] ready in
|
||||
guard let self = self else { return }
|
||||
if ready { self.sendEvent(event, to: relayUrls) }
|
||||
}
|
||||
// Fail-closed: nothing touches the network until Tor is up. Queue the
|
||||
// event locally so it survives a slow bootstrap (queued sends flush
|
||||
// when relays connect), then kick off connection setup, which itself
|
||||
// waits for Tor readiness.
|
||||
let targetRelays = allowedRelayList(from: relayUrls ?? Self.defaultRelays)
|
||||
guard !targetRelays.isEmpty else { return }
|
||||
enqueuePendingSend(event, pendingRelays: Set(targetRelays))
|
||||
ensureConnections(to: targetRelays)
|
||||
return
|
||||
}
|
||||
let requestedRelays = relayUrls ?? Self.defaultRelays
|
||||
@@ -307,12 +322,20 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
if !stillPending.isEmpty {
|
||||
messageQueueLock.lock()
|
||||
messageQueue.append(PendingSend(event: event, pendingRelays: stillPending))
|
||||
messageQueueLock.unlock()
|
||||
enqueuePendingSend(event, pendingRelays: stillPending)
|
||||
}
|
||||
}
|
||||
|
||||
private func enqueuePendingSend(_ event: NostrEvent, pendingRelays: Set<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.
|
||||
private func flushMessageQueue(for relayUrl: String? = nil) {
|
||||
messageQueueLock.lock()
|
||||
@@ -486,6 +509,8 @@ final class NostrRelayManager: ObservableObject {
|
||||
/// Unsubscribe from a subscription
|
||||
func unsubscribe(id: String) {
|
||||
messageHandlers.removeValue(forKey: id)
|
||||
removeRecentInboundEvents(forSubscriptionID: id)
|
||||
duplicateInboundEventDropCountBySubscription.removeValue(forKey: id)
|
||||
// Allow immediate re-subscription by clearing coalescer timestamp
|
||||
subscribeCoalesce.removeValue(forKey: id)
|
||||
subscriptionRequestState.removeValue(forKey: id)
|
||||
@@ -561,14 +586,40 @@ final class NostrRelayManager: ObservableObject {
|
||||
self.awaitingTorForConnections = false
|
||||
|
||||
guard ready else {
|
||||
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
|
||||
self.torReadyWaitAttempts += 1
|
||||
if self.torReadyWaitAttempts < TransportConfig.nostrTorReadyMaxWaitAttempts {
|
||||
SecureLogger.warning("Tor not ready; re-queueing \(pending.count) relay connection(s) (attempt \(self.torReadyWaitAttempts))", category: .session)
|
||||
self.queueConnectionsUntilTorReady(pending)
|
||||
} else {
|
||||
// Still fail-closed (no network), but unblock any callers
|
||||
// waiting on EOSE so the UI doesn't hang indefinitely.
|
||||
// Queued subscriptions/sends are kept and flush if a later
|
||||
// trigger (e.g. app foreground) brings Tor up.
|
||||
SecureLogger.error("❌ Tor not ready after \(self.torReadyWaitAttempts) wait(s); aborting relay connections (fail-closed)", category: .session)
|
||||
self.torReadyWaitAttempts = 0
|
||||
self.unblockPendingEOSECallbacks(reason: "tor-unavailable")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
self.torReadyWaitAttempts = 0
|
||||
self.connectToRelays(pending, shouldLog: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire and clear all EOSE callbacks that are parked waiting for Tor.
|
||||
/// Callers treat EOSE as "initial fetch finished"; firing with no data is
|
||||
/// safe and prevents indefinite hangs when Tor cannot bootstrap.
|
||||
private func unblockPendingEOSECallbacks(reason: String) {
|
||||
guard !pendingEOSECallbacks.isEmpty else { return }
|
||||
let callbacks = pendingEOSECallbacks
|
||||
pendingEOSECallbacks.removeAll()
|
||||
SecureLogger.warning("Unblocking \(callbacks.count) pending EOSE callback(s) without data (\(reason))", category: .session)
|
||||
for (_, callback) in callbacks {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
private func subscriptionStateExists(id: String, requestState: SubscriptionRequestState) -> Bool {
|
||||
guard !requestState.relayURLs.isEmpty else { return true }
|
||||
return requestState.relayURLs.allSatisfy { url in
|
||||
@@ -609,6 +660,53 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldDeliverInboundEvent(subscriptionID: String, eventID: String) -> Bool {
|
||||
guard !eventID.isEmpty else { return true }
|
||||
let key = InboundEventKey(subscriptionID: subscriptionID, eventID: eventID)
|
||||
guard recentInboundEventKeys.insert(key).inserted else {
|
||||
recordDuplicateInboundEventDrop(subscriptionID: subscriptionID)
|
||||
return false
|
||||
}
|
||||
recentInboundEventKeyOrder.append(key)
|
||||
|
||||
if recentInboundEventKeyOrder.count > recentInboundEventKeyLimit {
|
||||
let removeCount = recentInboundEventKeyOrder.count - recentInboundEventKeyTrimTarget
|
||||
for staleKey in recentInboundEventKeyOrder.prefix(removeCount) {
|
||||
recentInboundEventKeys.remove(staleKey)
|
||||
}
|
||||
recentInboundEventKeyOrder.removeFirst(removeCount)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func recordDuplicateInboundEventDrop(subscriptionID: String) {
|
||||
duplicateInboundEventDropCount += 1
|
||||
let subscriptionCount = (duplicateInboundEventDropCountBySubscription[subscriptionID] ?? 0) + 1
|
||||
duplicateInboundEventDropCountBySubscription[subscriptionID] = subscriptionCount
|
||||
|
||||
if duplicateInboundEventDropCount == 1 ||
|
||||
duplicateInboundEventDropCount.isMultiple(of: TransportConfig.nostrDuplicateEventLogInterval) {
|
||||
SecureLogger.debug(
|
||||
"Dropped duplicate Nostr event deliveries total=\(duplicateInboundEventDropCount) sub=\(subscriptionID) sub_total=\(subscriptionCount)",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func removeRecentInboundEvents(forSubscriptionID subscriptionID: String) {
|
||||
guard !recentInboundEventKeyOrder.isEmpty else { return }
|
||||
var retainedKeys: [InboundEventKey] = []
|
||||
retainedKeys.reserveCapacity(recentInboundEventKeyOrder.count)
|
||||
for key in recentInboundEventKeyOrder {
|
||||
if key.subscriptionID == subscriptionID {
|
||||
recentInboundEventKeys.remove(key)
|
||||
} else {
|
||||
retainedKeys.append(key)
|
||||
}
|
||||
}
|
||||
recentInboundEventKeyOrder = retainedKeys
|
||||
}
|
||||
|
||||
private func connectToRelay(_ urlString: String) {
|
||||
// Global network policy gate
|
||||
guard dependencies.activationAllowed() else { return }
|
||||
@@ -722,12 +820,22 @@ final class NostrRelayManager: ObservableObject {
|
||||
private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) {
|
||||
switch parsed {
|
||||
case .event(let subId, let event):
|
||||
if event.kind != 1059 {
|
||||
SecureLogger.debug("📥 Event kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
|
||||
}
|
||||
if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) {
|
||||
self.relays[index].messagesReceived += 1
|
||||
}
|
||||
guard event.isValidSignature() else {
|
||||
SecureLogger.warning(
|
||||
"⚠️ Dropped invalid Nostr event id=\(event.id.prefix(16))… sub=\(subId) relay=\(relayUrl)",
|
||||
category: .session
|
||||
)
|
||||
return
|
||||
}
|
||||
guard shouldDeliverInboundEvent(subscriptionID: subId, eventID: event.id) else {
|
||||
return
|
||||
}
|
||||
if event.kind != 1059 {
|
||||
SecureLogger.debug("📥 Event kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
|
||||
}
|
||||
if let handler = self.messageHandlers[subId] {
|
||||
handler(event)
|
||||
} else {
|
||||
@@ -919,6 +1027,14 @@ final class NostrRelayManager: ObservableObject {
|
||||
pendingSubscriptions[relayUrl]?.count ?? 0
|
||||
}
|
||||
|
||||
var debugDuplicateInboundEventDropCount: Int {
|
||||
duplicateInboundEventDropCount
|
||||
}
|
||||
|
||||
func debugDuplicateInboundEventDropCount(forSubscriptionID subscriptionID: String) -> Int {
|
||||
duplicateInboundEventDropCountBySubscription[subscriptionID] ?? 0
|
||||
}
|
||||
|
||||
func debugFlushMessageQueue() {
|
||||
flushMessageQueue(for: nil)
|
||||
}
|
||||
@@ -974,8 +1090,7 @@ private enum ParsedInbound {
|
||||
if array.count >= 3,
|
||||
let subId = array[1] as? String,
|
||||
let eventDict = array[2] as? [String: Any],
|
||||
let event = try? NostrEvent(from: eventDict),
|
||||
event.isValidSignature() {
|
||||
let event = try? NostrEvent(from: eventDict) {
|
||||
self = .event(subId: subId, event: event)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,36 +26,69 @@ struct BLESubscribedCentralSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns all BLE link state (peripheral connections we hold as central, and
|
||||
/// central subscriptions we serve as peripheral). The store has no internal
|
||||
/// locking: every access must happen on the single owning queue (the BLE
|
||||
/// queue). Other queues must go through BLEService's `readLinkState`, which
|
||||
/// hops to that queue. Call `assumeOwnership(of:)` to have debug builds trap
|
||||
/// any access from the wrong queue.
|
||||
final class BLELinkStateStore {
|
||||
private(set) var peripherals: [String: BLEPeripheralLinkState] = [:]
|
||||
private(set) var peerToPeripheralUUID: [PeerID: String] = [:]
|
||||
private(set) var subscribedCentrals: [CBCentral] = []
|
||||
private(set) var centralToPeerID: [String: PeerID] = [:]
|
||||
|
||||
#if DEBUG
|
||||
private var ownerQueue: DispatchQueue?
|
||||
#endif
|
||||
|
||||
/// Pin the store to its owning queue. Debug-only enforcement; release
|
||||
/// builds are unchanged.
|
||||
func assumeOwnership(of queue: DispatchQueue) {
|
||||
#if DEBUG
|
||||
ownerQueue = queue
|
||||
#endif
|
||||
}
|
||||
|
||||
@inline(__always)
|
||||
private func assertOwned() {
|
||||
#if DEBUG
|
||||
if let queue = ownerQueue {
|
||||
dispatchPrecondition(condition: .onQueue(queue))
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
var peripheralStates: [BLEPeripheralLinkState] {
|
||||
Array(peripherals.values)
|
||||
assertOwned()
|
||||
return Array(peripherals.values)
|
||||
}
|
||||
|
||||
var subscribedCentralSnapshot: BLESubscribedCentralSnapshot {
|
||||
BLESubscribedCentralSnapshot(
|
||||
assertOwned()
|
||||
return BLESubscribedCentralSnapshot(
|
||||
centrals: subscribedCentrals,
|
||||
peerIDsByCentralUUID: centralToPeerID
|
||||
)
|
||||
}
|
||||
|
||||
var subscribedCentralCount: Int {
|
||||
subscribedCentrals.count
|
||||
assertOwned()
|
||||
return subscribedCentrals.count
|
||||
}
|
||||
|
||||
var connectedOrConnectingPeripheralCount: Int {
|
||||
peripherals.values.filter { $0.isConnected || $0.isConnecting }.count
|
||||
assertOwned()
|
||||
return peripherals.values.filter { $0.isConnected || $0.isConnecting }.count
|
||||
}
|
||||
|
||||
func state(forPeripheralID peripheralID: String) -> BLEPeripheralLinkState? {
|
||||
peripherals[peripheralID]
|
||||
assertOwned()
|
||||
return peripherals[peripheralID]
|
||||
}
|
||||
|
||||
func setPeripheralState(_ state: BLEPeripheralLinkState, for peripheralID: String) {
|
||||
assertOwned()
|
||||
peripherals[peripheralID] = state
|
||||
}
|
||||
|
||||
@@ -64,6 +97,7 @@ final class BLELinkStateStore {
|
||||
_ peripheralID: String,
|
||||
_ update: (inout BLEPeripheralLinkState) -> Void
|
||||
) -> BLEPeripheralLinkState? {
|
||||
assertOwned()
|
||||
guard var state = peripherals[peripheralID] else { return nil }
|
||||
update(&state)
|
||||
peripherals[peripheralID] = state
|
||||
@@ -113,10 +147,12 @@ final class BLELinkStateStore {
|
||||
}
|
||||
|
||||
func directPeripheralState(for peerID: PeerID) -> BLEPeripheralLinkState? {
|
||||
peerToPeripheralUUID[peerID].flatMap { peripherals[$0] }
|
||||
assertOwned()
|
||||
return peerToPeripheralUUID[peerID].flatMap { peripherals[$0] }
|
||||
}
|
||||
|
||||
func directLinkState(for peerID: PeerID) -> BLEDirectLinkState {
|
||||
assertOwned()
|
||||
let peripheralUUID = peerToPeripheralUUID[peerID]
|
||||
let hasPeripheral = peripheralUUID.flatMap { peripherals[$0]?.isConnected } ?? false
|
||||
let hasCentral = centralToPeerID.values.contains(peerID)
|
||||
@@ -124,6 +160,7 @@ final class BLELinkStateStore {
|
||||
}
|
||||
|
||||
func links(to peerID: PeerID?) -> Set<BLEIngressLinkID> {
|
||||
assertOwned()
|
||||
guard let peerID else { return [] }
|
||||
|
||||
var links: Set<BLEIngressLinkID> = []
|
||||
@@ -137,35 +174,42 @@ final class BLELinkStateStore {
|
||||
}
|
||||
|
||||
func peerID(forPeripheralID peripheralID: String) -> PeerID? {
|
||||
peripherals[peripheralID]?.peerID
|
||||
assertOwned()
|
||||
return peripherals[peripheralID]?.peerID
|
||||
}
|
||||
|
||||
func peerID(forCentralUUID centralUUID: String) -> PeerID? {
|
||||
centralToPeerID[centralUUID]
|
||||
assertOwned()
|
||||
return centralToPeerID[centralUUID]
|
||||
}
|
||||
|
||||
func addSubscribedCentral(_ central: CBCentral) {
|
||||
assertOwned()
|
||||
guard !subscribedCentrals.contains(central) else { return }
|
||||
subscribedCentrals.append(central)
|
||||
}
|
||||
|
||||
func removeSubscribedCentral(_ central: CBCentral) -> PeerID? {
|
||||
assertOwned()
|
||||
let centralUUID = central.identifier.uuidString
|
||||
subscribedCentrals.removeAll { $0.identifier == central.identifier }
|
||||
return centralToPeerID.removeValue(forKey: centralUUID)
|
||||
}
|
||||
|
||||
func bindCentral(_ centralUUID: String, to peerID: PeerID) {
|
||||
assertOwned()
|
||||
centralToPeerID[centralUUID] = peerID
|
||||
}
|
||||
|
||||
func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) {
|
||||
assertOwned()
|
||||
if updatePeripheral(peripheralUUID, { $0.peerID = peerID }) != nil {
|
||||
peerToPeripheralUUID[peerID] = peripheralUUID
|
||||
}
|
||||
}
|
||||
|
||||
func removePeripheral(_ peripheralID: String) -> PeerID? {
|
||||
assertOwned()
|
||||
let peerID = peripherals.removeValue(forKey: peripheralID)?.peerID
|
||||
if let peerID {
|
||||
peerToPeripheralUUID.removeValue(forKey: peerID)
|
||||
@@ -174,6 +218,7 @@ final class BLELinkStateStore {
|
||||
}
|
||||
|
||||
func clearPeripherals() -> [PeerID] {
|
||||
assertOwned()
|
||||
let peerIDs = peripherals.compactMap { $0.value.peerID }
|
||||
peripherals.removeAll()
|
||||
peerToPeripheralUUID.removeAll()
|
||||
@@ -181,6 +226,7 @@ final class BLELinkStateStore {
|
||||
}
|
||||
|
||||
func clearCentrals() -> [PeerID] {
|
||||
assertOwned()
|
||||
let peerIDs = Array(centralToPeerID.values)
|
||||
subscribedCentrals.removeAll()
|
||||
centralToPeerID.removeAll()
|
||||
@@ -188,6 +234,7 @@ final class BLELinkStateStore {
|
||||
}
|
||||
|
||||
func clearAll() {
|
||||
assertOwned()
|
||||
peripherals.removeAll()
|
||||
peerToPeripheralUUID.removeAll()
|
||||
subscribedCentrals.removeAll()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,16 @@ final class BLEService: NSObject {
|
||||
private var pendingDirectedRelays = BLEDirectedRelaySpool()
|
||||
// Debounce for 'reconnected' logs
|
||||
private var reconnectLogDebouncer = BLEPeerEventDebouncer()
|
||||
// Announce-packet orchestration (queue hops stay in the environment closures)
|
||||
private lazy var announceHandler = BLEAnnounceHandler(environment: makeAnnounceHandlerEnvironment())
|
||||
// Public-message orchestration (queue hops stay in the environment closures)
|
||||
private lazy var publicMessageHandler = BLEPublicMessageHandler(environment: makePublicMessageHandlerEnvironment())
|
||||
// Noise handshake/encrypted orchestration (queue hops and crypto stay in the environment closures)
|
||||
private lazy var noisePacketHandler = BLENoisePacketHandler(environment: makeNoisePacketHandlerEnvironment())
|
||||
// Fragment-assembly orchestration (queue hops stay in the environment closures)
|
||||
private lazy var fragmentHandler = BLEFragmentHandler(environment: makeFragmentHandlerEnvironment())
|
||||
// File-transfer orchestration (queue hops stay in the environment closures)
|
||||
private lazy var fileTransferHandler = BLEFileTransferHandler(environment: makeFileTransferHandlerEnvironment())
|
||||
|
||||
// MARK: - Gossip Sync
|
||||
private var gossipSyncManager: GossipSyncManager?
|
||||
@@ -195,6 +205,9 @@ final class BLEService: NSObject {
|
||||
|
||||
// Tag BLE queue for re-entrancy detection
|
||||
bleQueue.setSpecific(key: bleQueueKey, value: ())
|
||||
// Link state is owned exclusively by bleQueue; debug builds trap
|
||||
// any access from another queue (cross-queue reads use readLinkState).
|
||||
linkStateStore.assumeOwnership(of: bleQueue)
|
||||
|
||||
if initializeBluetoothManagers {
|
||||
// Initialize BLE on background queue to prevent main thread blocking.
|
||||
@@ -1005,79 +1018,50 @@ final class BLEService: NSObject {
|
||||
}
|
||||
|
||||
private func handleFileTransfer(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: myPeerID) { return }
|
||||
|
||||
let peersSnapshot = collectionsQueue.sync { peerRegistry.snapshotByID }
|
||||
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||
peerID: peerID,
|
||||
localPeerID: myPeerID,
|
||||
localNickname: myNickname,
|
||||
peers: peersSnapshot,
|
||||
allowConnectedUnverified: true
|
||||
) ?? signedSenderDisplayName(for: packet, from: peerID) else {
|
||||
SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security)
|
||||
return
|
||||
fileTransferHandler.handle(packet, from: peerID)
|
||||
}
|
||||
|
||||
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: myPeerID) else {
|
||||
return
|
||||
/// Builds the file-transfer handler environment. All queue hops stay here
|
||||
/// so `BLEFileTransferHandler` remains queue-agnostic and synchronously
|
||||
/// testable.
|
||||
private func makeFileTransferHandlerEnvironment() -> BLEFileTransferHandlerEnvironment {
|
||||
BLEFileTransferHandlerEnvironment(
|
||||
localPeerID: { [weak self] in
|
||||
self?.myPeerID ?? PeerID(str: "")
|
||||
},
|
||||
localNickname: { [weak self] in
|
||||
self?.myNickname ?? ""
|
||||
},
|
||||
peersSnapshot: { [weak self] in
|
||||
guard let self = self else { return [:] }
|
||||
return self.collectionsQueue.sync { self.peerRegistry.snapshotByID }
|
||||
},
|
||||
signedSenderDisplayName: { [weak self] packet, peerID in
|
||||
self?.signedSenderDisplayName(for: packet, from: peerID)
|
||||
},
|
||||
trackPacketSeen: { [weak self] packet in
|
||||
self?.gossipSyncManager?.onPublicPacketSeen(packet)
|
||||
},
|
||||
enforceStorageQuota: { [weak self] reservingBytes in
|
||||
self?.incomingFileStore.enforceQuota(reservingBytes: reservingBytes)
|
||||
},
|
||||
saveIncomingFile: { [weak self] data, preferredName, subdirectory, fallbackExtension, defaultPrefix in
|
||||
self?.incomingFileStore.save(
|
||||
data: data,
|
||||
preferredName: preferredName,
|
||||
subdirectory: subdirectory,
|
||||
fallbackExtension: fallbackExtension,
|
||||
defaultPrefix: defaultPrefix
|
||||
)
|
||||
},
|
||||
updatePeerLastSeen: { [weak self] peerID in
|
||||
self?.updatePeerLastSeen(peerID)
|
||||
},
|
||||
deliverMessage: { [weak self] message in
|
||||
// Single main-actor hop delivering `.messageReceived`.
|
||||
self?.emitTransportEvent(.messageReceived(message))
|
||||
}
|
||||
if deliveryPlan.shouldTrackForSync {
|
||||
gossipSyncManager?.onPublicPacketSeen(packet)
|
||||
}
|
||||
|
||||
let filePacket: BitchatFilePacket
|
||||
let mime: MimeType
|
||||
switch BLEIncomingFileValidator.validate(payload: packet.payload) {
|
||||
case .success(let acceptance):
|
||||
filePacket = acceptance.filePacket
|
||||
mime = acceptance.mime
|
||||
case .failure(.malformedPayload):
|
||||
SecureLogger.error("❌ Failed to decode file transfer payload", category: .session)
|
||||
return
|
||||
case .failure(.payloadTooLarge(let bytes)):
|
||||
SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security)
|
||||
return
|
||||
case .failure(.unsupportedMime(let mimeType, let bytes)):
|
||||
SecureLogger.warning("🚫 MIME REJECT: '\(mimeType ?? "<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
|
||||
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) {
|
||||
@@ -2775,75 +2759,40 @@ extension BLEService {
|
||||
|
||||
private func handleFragment(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
||||
_handleFragment(packet, from: peerID)
|
||||
fragmentHandler.handle(packet, from: peerID)
|
||||
} else {
|
||||
messageQueue.async(flags: .barrier) { [weak self] in
|
||||
self?._handleFragment(packet, from: peerID)
|
||||
self?.fragmentHandler.handle(packet, from: peerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func _handleFragment(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
// Don't process our own fragments
|
||||
if peerID == myPeerID {
|
||||
return
|
||||
/// Builds the fragment handler environment. All queue hops stay here so
|
||||
/// `BLEFragmentHandler` remains queue-agnostic and synchronously testable.
|
||||
private func makeFragmentHandlerEnvironment() -> BLEFragmentHandlerEnvironment {
|
||||
BLEFragmentHandlerEnvironment(
|
||||
localPeerID: { [weak self] in
|
||||
self?.myPeerID ?? PeerID(str: "")
|
||||
},
|
||||
trackPacketSeen: { [weak self] packet in
|
||||
self?.gossipSyncManager?.onPublicPacketSeen(packet)
|
||||
},
|
||||
appendFragment: { [weak self] header in
|
||||
guard let self = self else {
|
||||
return .stored(header: header, started: false)
|
||||
}
|
||||
|
||||
guard let header = BLEFragmentHeader(packet: packet) else { return }
|
||||
|
||||
if header.isBroadcastFragment {
|
||||
gossipSyncManager?.onPublicPacketSeen(packet)
|
||||
return self.collectionsQueue.sync(flags: .barrier) {
|
||||
self.fragmentAssemblyBuffer.append(header, maxInFlightAssemblies: self.maxInFlightAssemblies)
|
||||
}
|
||||
|
||||
let assemblyResult = collectionsQueue.sync(flags: .barrier) {
|
||||
fragmentAssemblyBuffer.append(header, maxInFlightAssemblies: maxInFlightAssemblies)
|
||||
},
|
||||
isAcceptedIngressPayload: { [weak self] packet, innerSender in
|
||||
self?.isAcceptedIngressPayload(packet, from: innerSender) ?? false
|
||||
},
|
||||
processReassembledPacket: { [weak self] packet, peerID in
|
||||
self?.handleReceivedPacket(packet, from: peerID)
|
||||
}
|
||||
|
||||
logFragmentAssemblyResult(assemblyResult)
|
||||
|
||||
guard case let .complete(completedHeader, reassembled, _) = assemblyResult else { return }
|
||||
|
||||
// Decode the original packet bytes we reassembled, so flags/compression are preserved
|
||||
if var originalPacket = BinaryProtocol.decode(reassembled) {
|
||||
|
||||
// Reassembled packet validation
|
||||
let innerSender = PeerID(hexData: originalPacket.senderID)
|
||||
if !isAcceptedIngressPayload(originalPacket, from: innerSender) {
|
||||
// Cleanup below
|
||||
} else {
|
||||
SecureLogger.debug("✅ Reassembled packet id=\(completedHeader.idLogString) type=\(originalPacket.type) bytes=\(reassembled.count)", category: .session)
|
||||
originalPacket.ttl = 0
|
||||
handleReceivedPacket(originalPacket, from: peerID)
|
||||
}
|
||||
} else {
|
||||
SecureLogger.error("❌ Failed to decode reassembled packet (type=\(completedHeader.originalType), total=\(completedHeader.total))", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
private func logFragmentAssemblyResult(_ result: BLEFragmentAssemblyBuffer.AppendResult) {
|
||||
func logStartedIfNeeded(header: BLEFragmentHeader, started: Bool) {
|
||||
if started {
|
||||
SecureLogger.debug("📦 Started fragment assembly id=\(header.idLogString) total=\(header.total)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
switch result {
|
||||
case let .stored(header, started):
|
||||
logStartedIfNeeded(header: header, started: started)
|
||||
SecureLogger.debug("📦 Fragment \(header.index + 1)/\(header.total) (len=\(header.fragmentData.count)) for id=\(header.idLogString)", category: .session)
|
||||
|
||||
case let .complete(header, _, started):
|
||||
logStartedIfNeeded(header: header, started: started)
|
||||
SecureLogger.debug("📦 Fragment \(header.index + 1)/\(header.total) (len=\(header.fragmentData.count)) for id=\(header.idLogString)", category: .session)
|
||||
|
||||
case let .oversized(header, projectedSize, limit, started):
|
||||
logStartedIfNeeded(header: header, started: started)
|
||||
SecureLogger.warning(
|
||||
"🚫 Fragment assembly exceeds size limit (\(projectedSize) bytes > \(limit)), evicting. Type=\(header.originalType) Index=\(header.index)/\(header.total)",
|
||||
category: .security
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Packet Reception
|
||||
|
||||
@@ -2963,165 +2912,96 @@ extension BLEService {
|
||||
}
|
||||
|
||||
private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
let now = Date()
|
||||
let preflight = BLEAnnouncePreflightPolicy.evaluate(
|
||||
packet: packet,
|
||||
from: peerID,
|
||||
localPeerID: myPeerID,
|
||||
now: now
|
||||
)
|
||||
|
||||
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
|
||||
announceHandler.handle(packet, from: peerID)
|
||||
}
|
||||
|
||||
// Suppress announce logs to reduce noise
|
||||
|
||||
// Precompute signature verification outside barrier to reduce contention
|
||||
let existingPeerForVerify = collectionsQueue.sync { peerRegistry.info(for: peerID) }
|
||||
let hasSignature = packet.signature != nil
|
||||
let signatureValid: Bool
|
||||
if hasSignature {
|
||||
signatureValid = noiseService.verifyPacketSignature(packet, publicKey: announcement.signingPublicKey)
|
||||
if !signatureValid {
|
||||
SecureLogger.warning("⚠️ Signature verification for announce failed \(peerID.id.prefix(8))", category: .security)
|
||||
}
|
||||
} else {
|
||||
signatureValid = false
|
||||
}
|
||||
let trustDecision = BLEAnnounceTrustPolicy.evaluate(
|
||||
hasSignature: hasSignature,
|
||||
signatureValid: signatureValid,
|
||||
existingNoisePublicKey: existingPeerForVerify?.noisePublicKey,
|
||||
announcedNoisePublicKey: announcement.noisePublicKey
|
||||
)
|
||||
if case .reject(.keyMismatch) = trustDecision {
|
||||
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security)
|
||||
}
|
||||
let verifiedAnnounce = trustDecision.isVerified
|
||||
|
||||
var isNewPeer = false
|
||||
var isReconnectedPeer = false
|
||||
let directLinkState = linkState(for: peerID)
|
||||
let isDirectAnnounce = packet.ttl == messageTTL
|
||||
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
let hasPeripheralConnection = directLinkState.hasPeripheral
|
||||
let hasCentralSubscription = directLinkState.hasCentral
|
||||
|
||||
// Require verified announce; ignore otherwise (no backward compatibility)
|
||||
if !verifiedAnnounce {
|
||||
SecureLogger.warning("❌ Ignoring unverified announce from \(peerID.id.prefix(8))…", category: .security)
|
||||
// Reset flags to prevent post-barrier code from acting on unverified announces
|
||||
isNewPeer = false
|
||||
isReconnectedPeer = false
|
||||
return
|
||||
}
|
||||
|
||||
let update = peerRegistry.upsertVerifiedAnnounce(
|
||||
/// Builds the announce handler environment. All queue hops stay here so
|
||||
/// `BLEAnnounceHandler` remains queue-agnostic and synchronously testable.
|
||||
private func makeAnnounceHandlerEnvironment() -> BLEAnnounceHandlerEnvironment {
|
||||
BLEAnnounceHandlerEnvironment(
|
||||
localPeerID: { [weak self] in
|
||||
self?.myPeerID ?? PeerID(str: "")
|
||||
},
|
||||
messageTTL: messageTTL,
|
||||
now: { Date() },
|
||||
existingNoisePublicKey: { [weak self] peerID in
|
||||
guard let self = self else { return nil }
|
||||
return self.collectionsQueue.sync { self.peerRegistry.info(for: peerID)?.noisePublicKey }
|
||||
},
|
||||
verifySignature: { [weak self] packet, signingPublicKey in
|
||||
self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false
|
||||
},
|
||||
linkState: { [weak self] peerID in
|
||||
self?.linkState(for: peerID) ?? (hasPeripheral: false, hasCentral: false)
|
||||
},
|
||||
withRegistryBarrier: { [weak self] body in
|
||||
self?.collectionsQueue.sync(flags: .barrier) { body() }
|
||||
},
|
||||
upsertVerifiedAnnounce: { [weak self] peerID, announcement, isConnected, now in
|
||||
// Called from inside withRegistryBarrier; access registry directly.
|
||||
self?.peerRegistry.upsertVerifiedAnnounce(
|
||||
peerID: peerID,
|
||||
nickname: announcement.nickname,
|
||||
noisePublicKey: announcement.noisePublicKey,
|
||||
signingPublicKey: announcement.signingPublicKey,
|
||||
isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
|
||||
isConnected: isConnected,
|
||||
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(
|
||||
) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
|
||||
},
|
||||
shouldEmitReconnectLog: { [weak self] peerID, now in
|
||||
// Called from inside withRegistryBarrier; access debouncer directly.
|
||||
self?.reconnectLogDebouncer.shouldEmit(
|
||||
peerID: peerID,
|
||||
now: now,
|
||||
minimumInterval: TransportConfig.bleReconnectLogDebounceSeconds
|
||||
) {
|
||||
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 {
|
||||
meshTopology.updateNeighbors(for: peerID.routingData, neighbors: neighbors)
|
||||
}
|
||||
|
||||
// Persist cryptographic identity and signing key for robust offline verification
|
||||
identityManager.upsertCryptographicIdentity(
|
||||
) ?? false
|
||||
},
|
||||
updateTopology: { [weak self] peerID, neighbors in
|
||||
self?.meshTopology.updateNeighbors(for: peerID.routingData, neighbors: neighbors)
|
||||
},
|
||||
persistIdentity: { [weak self] announcement in
|
||||
self?.identityManager.upsertCryptographicIdentity(
|
||||
fingerprint: announcement.noisePublicKey.sha256Fingerprint(),
|
||||
noisePublicKey: announcement.noisePublicKey,
|
||||
signingPublicKey: announcement.signingPublicKey,
|
||||
claimedNickname: announcement.nickname
|
||||
)
|
||||
|
||||
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
|
||||
},
|
||||
dedupContains: { [weak self] id in
|
||||
self?.messageDeduplicator.contains(id) ?? true
|
||||
},
|
||||
dedupMarkProcessed: { [weak self] id in
|
||||
self?.messageDeduplicator.markProcessed(id)
|
||||
},
|
||||
deliverAnnounceUIEvents: { [weak self] peerID, notifyPeerConnected, scheduleInitialSync in
|
||||
// Single main-actor hop so event order is guaranteed:
|
||||
// .peerConnected → initial sync scheduling → .peerListUpdated.
|
||||
self?.notifyUI { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// 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 {
|
||||
if notifyPeerConnected {
|
||||
self.deliverTransportEvent(.peerConnected(peerID))
|
||||
// Schedule initial unicast sync to this peer
|
||||
if responsePlan.shouldScheduleInitialSync {
|
||||
}
|
||||
if scheduleInitialSync {
|
||||
self.gossipSyncManager?.scheduleInitialSyncToPeer(peerID, delaySeconds: 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
// Get current peer list (after addition)
|
||||
let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs }
|
||||
self.requestPeerDataPublish()
|
||||
self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
|
||||
}
|
||||
|
||||
// Track for sync (include our own and others' announces)
|
||||
gossipSyncManager?.onPublicPacketSeen(packet)
|
||||
|
||||
if responsePlan.shouldSendAnnounceBack {
|
||||
// Reciprocate announce for bidirectional discovery
|
||||
// Force send to ensure the peer receives our announce
|
||||
sendAnnounce(forceSend: true)
|
||||
}
|
||||
|
||||
// Afterglow: on first-seen peers, schedule a short re-announce to push presence one more hop
|
||||
if responsePlan.shouldScheduleAfterglow {
|
||||
let delay = Double.random(in: 0.3...0.6)
|
||||
messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
},
|
||||
trackPacketSeen: { [weak self] packet in
|
||||
self?.gossipSyncManager?.onPublicPacketSeen(packet)
|
||||
},
|
||||
sendAnnounceBack: { [weak self] in
|
||||
self?.sendAnnounce(forceSend: true)
|
||||
},
|
||||
scheduleAfterglow: { [weak self] delay in
|
||||
self?.messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
self?.sendAnnounce(forceSend: true)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Handle REQUEST_SYNC: decode payload and respond with missing packets via sync manager
|
||||
@@ -3136,156 +3016,110 @@ extension BLEService {
|
||||
// Mention parsing moved to ChatViewModel
|
||||
|
||||
private func handleMessage(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
let now = Date()
|
||||
let messageDecision = BLEPublicMessagePolicy.evaluate(
|
||||
packet: packet,
|
||||
from: peerID,
|
||||
localPeerID: myPeerID,
|
||||
now: now
|
||||
)
|
||||
|
||||
let messagePolicy: BLEPublicMessageAcceptance
|
||||
switch messageDecision {
|
||||
case .accept(let acceptance):
|
||||
messagePolicy = acceptance
|
||||
case .reject(.selfEcho):
|
||||
return
|
||||
case .reject(.staleBroadcast(let ageSeconds)):
|
||||
SecureLogger.debug("⏰ Ignoring stale broadcast message from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session)
|
||||
return
|
||||
publicMessageHandler.handle(packet, from: peerID)
|
||||
}
|
||||
|
||||
// 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
|
||||
/// Builds the public-message handler environment. All queue hops stay here
|
||||
/// so `BLEPublicMessageHandler` remains queue-agnostic and synchronously
|
||||
/// testable.
|
||||
private func makePublicMessageHandlerEnvironment() -> BLEPublicMessageHandlerEnvironment {
|
||||
BLEPublicMessageHandlerEnvironment(
|
||||
localPeerID: { [weak self] in
|
||||
self?.myPeerID ?? PeerID(str: "")
|
||||
},
|
||||
localNickname: { [weak self] in
|
||||
self?.myNickname ?? ""
|
||||
},
|
||||
now: { Date() },
|
||||
peersSnapshot: { [weak self] in
|
||||
guard let self = self else { return [:] }
|
||||
return self.collectionsQueue.sync { self.peerRegistry.snapshotByID }
|
||||
},
|
||||
signedSenderDisplayName: { [weak self] packet, peerID in
|
||||
self?.signedSenderDisplayName(for: packet, from: peerID)
|
||||
},
|
||||
trackPacketSeen: { [weak self] packet in
|
||||
self?.gossipSyncManager?.onPublicPacketSeen(packet)
|
||||
},
|
||||
linkState: { [weak self] peerID in
|
||||
self?.linkState(for: peerID) ?? (hasPeripheral: false, hasCentral: false)
|
||||
},
|
||||
takeSelfBroadcastMessageID: { [weak self] packet in
|
||||
// Caller is on messageQueue, where the tracker is owned.
|
||||
self?.selfBroadcastTracker.takeMessageID(for: packet)
|
||||
},
|
||||
deliverPublicMessage: { [weak self] peerID, nickname, content, timestamp, messageID in
|
||||
// Single main-actor hop delivering `.publicMessageReceived`.
|
||||
self?.notifyUI { [weak self] in
|
||||
self?.deliverTransportEvent(
|
||||
.publicMessageReceived(
|
||||
peerID: peerID,
|
||||
nickname: senderNickname,
|
||||
nickname: nickname,
|
||||
content: content,
|
||||
timestamp: ts,
|
||||
messageID: resolvedSelfMessageID
|
||||
timestamp: timestamp,
|
||||
messageID: messageID
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
// Use NoiseEncryptionService for handshake processing
|
||||
if PeerID(hexData: packet.recipientID) == myPeerID {
|
||||
// Handshake is for us
|
||||
do {
|
||||
if let response = try noiseService.processHandshakeMessage(from: peerID, message: packet.payload) {
|
||||
// Send response
|
||||
let responsePacket = BitchatPacket(
|
||||
type: MessageType.noiseHandshake.rawValue,
|
||||
senderID: myPeerIDData,
|
||||
recipientID: Data(hexString: peerID.id),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: response,
|
||||
signature: nil,
|
||||
ttl: messageTTL
|
||||
)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
noisePacketHandler.handleHandshake(packet, from: peerID)
|
||||
}
|
||||
|
||||
private func handleNoiseEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
guard let recipientID = PeerID(hexData: packet.recipientID) else {
|
||||
SecureLogger.warning("⚠️ Encrypted message has no recipient ID", category: .session)
|
||||
return
|
||||
noisePacketHandler.handleEncrypted(packet, from: peerID)
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
notifyUI { [weak self] in
|
||||
/// Builds the Noise packet handler environment. All queue hops and
|
||||
/// `noiseService` crypto calls stay here so `BLENoisePacketHandler`
|
||||
/// remains queue-agnostic and synchronously testable.
|
||||
private func makeNoisePacketHandlerEnvironment() -> BLENoisePacketHandlerEnvironment {
|
||||
BLENoisePacketHandlerEnvironment(
|
||||
localPeerID: { [weak self] in
|
||||
self?.myPeerID ?? PeerID(str: "")
|
||||
},
|
||||
localPeerIDData: { [weak self] in
|
||||
self?.myPeerIDData ?? Data()
|
||||
},
|
||||
messageTTL: messageTTL,
|
||||
now: { Date() },
|
||||
processHandshakeMessage: { [weak self] peerID, message in
|
||||
try self?.noiseService.processHandshakeMessage(from: peerID, message: message)
|
||||
},
|
||||
hasNoiseSession: { [weak self] peerID in
|
||||
self?.noiseService.hasSession(with: peerID) ?? false
|
||||
},
|
||||
initiateHandshake: { [weak self] peerID in
|
||||
self?.initiateNoiseHandshake(with: peerID)
|
||||
},
|
||||
broadcastPacket: { [weak self] packet in
|
||||
self?.broadcastPacket(packet)
|
||||
},
|
||||
updatePeerLastSeen: { [weak self] peerID in
|
||||
self?.updatePeerLastSeen(peerID)
|
||||
},
|
||||
decrypt: { [weak self] payload, peerID in
|
||||
guard let self = self else { throw NoiseEncryptionError.sessionNotEstablished }
|
||||
return try self.noiseService.decrypt(payload, from: peerID)
|
||||
},
|
||||
clearSession: { [weak self] peerID in
|
||||
self?.noiseService.clearSession(for: peerID)
|
||||
},
|
||||
deliverNoisePayload: { [weak self] peerID, type, payload, timestamp in
|
||||
// Single main-actor hop delivering `.noisePayloadReceived`.
|
||||
self?.notifyUI { [weak self] in
|
||||
self?.deliverTransportEvent(.noisePayloadReceived(
|
||||
peerID: peerID,
|
||||
type: noisePayloadType,
|
||||
payload: Data(payloadData),
|
||||
timestamp: ts
|
||||
type: type,
|
||||
payload: payload,
|
||||
timestamp: timestamp
|
||||
))
|
||||
}
|
||||
} 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
|
||||
|
||||
@@ -13,6 +13,7 @@ final class MessageRouter {
|
||||
let nickname: String
|
||||
let messageID: String
|
||||
let timestamp: Date
|
||||
var sendAttempts: Int = 0
|
||||
}
|
||||
|
||||
private var outbox: [PeerID: [QueuedMessage]] = [:]
|
||||
@@ -20,6 +21,9 @@ final class MessageRouter {
|
||||
// Outbox limits to prevent unbounded memory growth
|
||||
private static let maxMessagesPerPeer = 100
|
||||
private static let messageTTLSeconds: TimeInterval = 24 * 60 * 60 // 24 hours
|
||||
// Bound resends of messages sent on a weak reachability signal that never
|
||||
// get a delivery ack (e.g. peer on an old client that doesn't ack).
|
||||
private static let maxSendAttempts = 8
|
||||
|
||||
init(transports: [Transport]) {
|
||||
self.transports = transports
|
||||
@@ -61,26 +65,53 @@ final class MessageRouter {
|
||||
// MARK: - Message Sending
|
||||
|
||||
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||
if let transport = reachableTransport(for: peerID) {
|
||||
SecureLogger.debug("Routing PM via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
if let transport = connectedTransport(for: peerID) {
|
||||
// A live link is a strong delivery signal; trust it outright.
|
||||
SecureLogger.debug("Routing PM via \(type(of: transport)) (connected) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
} else {
|
||||
// Queue for later with timestamp for TTL tracking
|
||||
if outbox[peerID] == nil { outbox[peerID] = [] }
|
||||
|
||||
let message = QueuedMessage(content: content, nickname: recipientNickname, messageID: messageID, timestamp: Date())
|
||||
outbox[peerID]?.append(message)
|
||||
|
||||
// Enforce per-peer size limit with FIFO eviction
|
||||
if let count = outbox[peerID]?.count, count > Self.maxMessagesPerPeer {
|
||||
let evicted = outbox[peerID]?.removeFirst()
|
||||
SecureLogger.warning("📤 Outbox overflow for \(peerID.id.prefix(8))… - evicted oldest message: \(evicted?.messageID.prefix(8) ?? "?")…", category: .session)
|
||||
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 {
|
||||
var unsent = message
|
||||
unsent.sendAttempts = 0
|
||||
enqueue(unsent, for: peerID)
|
||||
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
/// A delivery or read ack confirms receipt; stop retaining the message.
|
||||
func markDelivered(_ messageID: String) {
|
||||
for (peerID, queue) in outbox {
|
||||
let filtered = queue.filter { $0.messageID != messageID }
|
||||
guard filtered.count != queue.count else { continue }
|
||||
outbox[peerID] = filtered.isEmpty ? nil : filtered
|
||||
}
|
||||
}
|
||||
|
||||
private func enqueue(_ message: QueuedMessage, for peerID: PeerID) {
|
||||
var queue = outbox[peerID] ?? []
|
||||
// Re-sending an already-queued ID replaces the entry (keeps attempt count fresh)
|
||||
queue.removeAll { $0.messageID == message.messageID }
|
||||
queue.append(message)
|
||||
|
||||
// Enforce per-peer size limit with FIFO eviction
|
||||
if queue.count > Self.maxMessagesPerPeer {
|
||||
let evicted = queue.removeFirst()
|
||||
SecureLogger.warning("📤 Outbox overflow for \(peerID.id.prefix(8))… - evicted oldest message: \(evicted.messageID.prefix(8))…", category: .session)
|
||||
}
|
||||
outbox[peerID] = queue
|
||||
}
|
||||
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||
if let transport = reachableTransport(for: peerID) {
|
||||
SecureLogger.debug("Routing READ ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
||||
@@ -121,9 +152,22 @@ final class MessageRouter {
|
||||
continue
|
||||
}
|
||||
|
||||
if let transport = reachableTransport(for: peerID) {
|
||||
SecureLogger.debug("Outbox -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
||||
if let transport = connectedTransport(for: peerID) {
|
||||
// Live link: send and stop retaining.
|
||||
SecureLogger.debug("Outbox -> \(type(of: transport)) (connected) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
||||
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
|
||||
} else if let transport = reachableTransport(for: peerID) {
|
||||
// Weak signal: send but keep retaining until an ack clears it,
|
||||
// bounded by attempt count for peers that never ack.
|
||||
guard message.sendAttempts < Self.maxSendAttempts else {
|
||||
SecureLogger.warning("📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session)
|
||||
continue
|
||||
}
|
||||
SecureLogger.debug("Outbox -> \(type(of: transport)) (reachable) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
||||
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
|
||||
var retained = message
|
||||
retained.sendAttempts += 1
|
||||
remaining.append(retained)
|
||||
} else {
|
||||
remaining.append(message)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ enum TransportConfig {
|
||||
static let meshTimelineCap: Int = 1337
|
||||
static let geoTimelineCap: Int = 1337
|
||||
static let contentLRUCap: Int = 2000
|
||||
static let geoSamplingEventLRUCap: Int = 2000
|
||||
|
||||
// Timers
|
||||
static let networkResetGraceSeconds: TimeInterval = 600 // 10 minutes
|
||||
@@ -43,6 +44,9 @@ enum TransportConfig {
|
||||
|
||||
// Nostr
|
||||
static let nostrReadAckInterval: TimeInterval = 0.35 // ~3 per second
|
||||
static let nostrInboundEventDedupCap: Int = 4096
|
||||
static let nostrInboundEventDedupTrimTarget: Int = 3072
|
||||
static let nostrDuplicateEventLogInterval: Int = 50
|
||||
|
||||
// UI thresholds
|
||||
static let uiLateInsertThreshold: TimeInterval = 15.0
|
||||
@@ -146,6 +150,10 @@ enum TransportConfig {
|
||||
static let nostrRelayBackoffMultiplier: Double = 2.0
|
||||
static let nostrRelayMaxReconnectAttempts: Int = 10
|
||||
static let nostrRelayDefaultFetchLimit: Int = 100
|
||||
// How many consecutive Tor-readiness waits (each bounded by TorManager's
|
||||
// bootstrap deadline) to attempt before unblocking pending EOSE callers.
|
||||
static let nostrTorReadyMaxWaitAttempts: Int = 3
|
||||
static let nostrPendingSendQueueCap: Int = 200
|
||||
|
||||
// Geo relay directory
|
||||
static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24
|
||||
|
||||
@@ -12,6 +12,11 @@ import CryptoKit
|
||||
enum GCSFilter {
|
||||
struct Params { let p: Int; let m: UInt32; let data: Data }
|
||||
|
||||
// Highest Golomb-Rice parameter we accept from the wire. P maps to an FPR
|
||||
// of ~1/2^P; beyond 32 the remainder width exceeds any practical filter
|
||||
// and shifts in decode would silently overflow to garbage values.
|
||||
static let maxP = 32
|
||||
|
||||
// Derive P from FPR (~ 1 / 2^P)
|
||||
static func deriveP(targetFpr: Double) -> Int {
|
||||
let f = max(0.000001, min(0.25, targetFpr))
|
||||
@@ -66,6 +71,9 @@ enum GCSFilter {
|
||||
}
|
||||
|
||||
static func decodeToSortedSet(p: Int, m: UInt32, data: Data) -> [UInt64] {
|
||||
// Reject out-of-range parameters rather than decoding garbage: callers
|
||||
// treat the result as "peer has nothing" and fall back to sending data.
|
||||
guard p >= 1, p <= maxP, m > 1 else { return [] }
|
||||
var values: [UInt64] = []
|
||||
let reader = BitReader(data)
|
||||
var acc: UInt64 = 0
|
||||
|
||||
@@ -2,29 +2,65 @@ import BitFoundation
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
final class ChatDeliveryCoordinator {
|
||||
private unowned let viewModel: ChatViewModel
|
||||
/// The narrow surface `ChatDeliveryCoordinator` needs from its owner.
|
||||
///
|
||||
/// Coordinators should depend on the minimal context they actually use rather
|
||||
/// than holding an `unowned` back-reference to the whole `ChatViewModel`. This
|
||||
/// keeps the coordinator independently testable (see
|
||||
/// `ChatDeliveryCoordinatorContextTests`) and makes its true dependencies
|
||||
/// explicit. This protocol is the exemplar for migrating the other
|
||||
/// coordinators off their `unowned let viewModel: ChatViewModel` back-refs.
|
||||
@MainActor
|
||||
protocol ChatDeliveryContext: AnyObject {
|
||||
var messages: [BitchatMessage] { get set }
|
||||
var privateChats: [PeerID: [BitchatMessage]] { get set }
|
||||
var sentReadReceipts: Set<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) {
|
||||
self.viewModel = viewModel
|
||||
extension ChatViewModel: ChatDeliveryContext {
|
||||
func notifyUIChanged() {
|
||||
objectWillChange.send()
|
||||
}
|
||||
|
||||
func markMessageDelivered(_ messageID: String) {
|
||||
messageRouter.markDelivered(messageID)
|
||||
}
|
||||
}
|
||||
|
||||
final class ChatDeliveryCoordinator {
|
||||
private unowned let context: any ChatDeliveryContext
|
||||
private var messageLocationIndex: [String: Set<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
|
||||
func cleanupOldReadReceipts() {
|
||||
guard !viewModel.isStartupPhase, !viewModel.privateChats.isEmpty else {
|
||||
guard !context.isStartupPhase, !context.privateChats.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
let validMessageIDs = Set(
|
||||
viewModel.privateChats.values.flatMap { messages in
|
||||
context.privateChats.values.flatMap { messages in
|
||||
messages.map(\.id)
|
||||
}
|
||||
)
|
||||
|
||||
let oldCount = viewModel.sentReadReceipts.count
|
||||
viewModel.sentReadReceipts = viewModel.sentReadReceipts.intersection(validMessageIDs)
|
||||
let oldCount = context.sentReadReceipts.count
|
||||
context.sentReadReceipts = context.sentReadReceipts.intersection(validMessageIDs)
|
||||
|
||||
let removedCount = oldCount - viewModel.sentReadReceipts.count
|
||||
let removedCount = oldCount - context.sentReadReceipts.count
|
||||
if removedCount > 0 {
|
||||
SecureLogger.debug("🧹 Cleaned up \(removedCount) old read receipts", category: .session)
|
||||
}
|
||||
@@ -45,48 +81,65 @@ final class ChatDeliveryCoordinator {
|
||||
|
||||
@MainActor
|
||||
func deliveryStatus(for messageID: String) -> DeliveryStatus? {
|
||||
if let message = viewModel.messages.first(where: { $0.id == messageID }) {
|
||||
return message.deliveryStatus
|
||||
withValidLocations(for: messageID) { locations in
|
||||
locations.lazy.compactMap { self.deliveryStatus(at: $0) }.first
|
||||
}
|
||||
|
||||
for messages in viewModel.privateChats.values {
|
||||
if let message = messages.first(where: { $0.id == messageID }) {
|
||||
return message.deliveryStatus
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@discardableResult
|
||||
func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool {
|
||||
var didUpdateStatus = false
|
||||
switch status {
|
||||
case .delivered, .read:
|
||||
// Confirmed receipt — stop retaining the message for resend.
|
||||
context.markMessageDelivered(messageID)
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
if let index = viewModel.messages.firstIndex(where: { $0.id == messageID }) {
|
||||
let currentStatus = viewModel.messages[index].deliveryStatus
|
||||
var didUpdateStatus = false
|
||||
var didUpdatePrivateStatus = false
|
||||
let locations = withValidLocations(for: messageID) { $0 }
|
||||
guard !locations.isEmpty else { return false }
|
||||
|
||||
for location in locations {
|
||||
guard case .publicTimeline(let index) = location,
|
||||
index < context.messages.count,
|
||||
context.messages[index].id == messageID else {
|
||||
continue
|
||||
}
|
||||
|
||||
let currentStatus = context.messages[index].deliveryStatus
|
||||
if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) {
|
||||
viewModel.messages[index].deliveryStatus = status
|
||||
context.messages[index].deliveryStatus = status
|
||||
didUpdateStatus = true
|
||||
}
|
||||
}
|
||||
|
||||
var privateChats = viewModel.privateChats
|
||||
for (peerID, chatMessages) in privateChats {
|
||||
guard let index = chatMessages.firstIndex(where: { $0.id == messageID }) else { continue }
|
||||
var privateChats = context.privateChats
|
||||
for location in locations {
|
||||
guard case .privateChat(let peerID, let index) = location,
|
||||
let chatMessages = privateChats[peerID],
|
||||
index < chatMessages.count,
|
||||
chatMessages[index].id == messageID else {
|
||||
continue
|
||||
}
|
||||
|
||||
let currentStatus = chatMessages[index].deliveryStatus
|
||||
guard !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) else { continue }
|
||||
|
||||
let updatedMessages = chatMessages
|
||||
updatedMessages[index].deliveryStatus = status
|
||||
privateChats[peerID] = updatedMessages
|
||||
chatMessages[index].deliveryStatus = status
|
||||
privateChats[peerID] = chatMessages
|
||||
didUpdateStatus = true
|
||||
didUpdatePrivateStatus = true
|
||||
}
|
||||
|
||||
if didUpdatePrivateStatus {
|
||||
context.privateChats = privateChats
|
||||
}
|
||||
|
||||
if didUpdateStatus {
|
||||
viewModel.privateChats = privateChats
|
||||
viewModel.objectWillChange.send()
|
||||
context.notifyUIChanged()
|
||||
}
|
||||
|
||||
return didUpdateStatus
|
||||
@@ -94,8 +147,14 @@ final class ChatDeliveryCoordinator {
|
||||
}
|
||||
|
||||
private extension ChatDeliveryCoordinator {
|
||||
enum MessageLocation: Hashable {
|
||||
case publicTimeline(index: Int)
|
||||
case privateChat(peerID: PeerID, index: Int)
|
||||
}
|
||||
|
||||
func shouldSkipUpdate(currentStatus: DeliveryStatus?, newStatus: DeliveryStatus) -> Bool {
|
||||
guard let currentStatus else { return false }
|
||||
if currentStatus == newStatus { return true }
|
||||
|
||||
switch (currentStatus, newStatus) {
|
||||
case (.read, .delivered), (.read, .sent):
|
||||
@@ -104,4 +163,163 @@ private extension ChatDeliveryCoordinator {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func withValidLocations<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)
|
||||
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
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)
|
||||
if let peerID = targetPeer {
|
||||
self.viewModel.meshService.sendFilePrivate(packet, to: peerID, transferId: transferId)
|
||||
@@ -49,12 +49,14 @@ final class ChatMediaTransferCoordinator {
|
||||
} catch ChatMediaPreparationError.voiceNoteTooLarge(let size) {
|
||||
SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session)
|
||||
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")
|
||||
}
|
||||
} catch {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -65,10 +67,10 @@ final class ChatMediaTransferCoordinator {
|
||||
func processThenSendImage(_ image: UIImage?) {
|
||||
guard let image else { return }
|
||||
Task.detached { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
let processedURL = try ImageUtils.processImage(image)
|
||||
await MainActor.run {
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.sendImage(from: processedURL)
|
||||
}
|
||||
} catch {
|
||||
@@ -80,10 +82,10 @@ final class ChatMediaTransferCoordinator {
|
||||
func processThenSendImage(from url: URL?) {
|
||||
guard let url else { return }
|
||||
Task.detached { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
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)
|
||||
}
|
||||
} catch {
|
||||
@@ -112,11 +114,11 @@ final class ChatMediaTransferCoordinator {
|
||||
}
|
||||
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
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(
|
||||
content: "\(MimeType.Category.image.messagePrefix)\(prepared.outputURL.lastPathComponent)",
|
||||
targetPeer: targetPeer
|
||||
@@ -132,12 +134,14 @@ final class ChatMediaTransferCoordinator {
|
||||
}
|
||||
} catch ChatMediaPreparationError.imageTooLarge(let size) {
|
||||
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.")
|
||||
}
|
||||
} catch {
|
||||
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.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,22 +4,208 @@ import Foundation
|
||||
import SwiftUI
|
||||
import Tor
|
||||
|
||||
final class ChatNostrCoordinator {
|
||||
private unowned let viewModel: ChatViewModel
|
||||
/// The narrow surface `ChatNostrCoordinator` needs from its owner.
|
||||
///
|
||||
/// 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) {
|
||||
self.viewModel = viewModel
|
||||
// MARK: Public timeline & pipeline
|
||||
var messages: [BitchatMessage] { get }
|
||||
func resetPublicMessagePipeline()
|
||||
func updatePublicMessagePipelineChannel(_ channel: ChannelID)
|
||||
func refreshVisibleMessages(from channel: ChannelID?)
|
||||
func addPublicSystemMessage(_ content: String)
|
||||
func drainPendingGeohashSystemMessages() -> [String]
|
||||
func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool
|
||||
func synchronizePublicConversationStore(forGeohash geohash: String)
|
||||
|
||||
// MARK: Inbound public messages
|
||||
func handlePublicMessage(_ message: BitchatMessage)
|
||||
func checkForMentions(_ message: BitchatMessage)
|
||||
func sendHapticFeedback(for message: BitchatMessage)
|
||||
func parseMentions(from content: String) -> [String]
|
||||
|
||||
// MARK: Inbound private (geohash DM) payloads
|
||||
var selectedPrivateChatPeer: PeerID? { get }
|
||||
var nostrKeyMapping: [PeerID: String] { get set }
|
||||
func handlePrivateMessage(
|
||||
_ payload: NoisePayload,
|
||||
senderPubkey: String,
|
||||
convKey: PeerID,
|
||||
id: NostrIdentity,
|
||||
messageTimestamp: Date
|
||||
)
|
||||
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID)
|
||||
func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID)
|
||||
func startPrivateChat(with peerID: PeerID)
|
||||
|
||||
// MARK: Nostr identity & blocking (shared with `ChatPrivateConversationContext`)
|
||||
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity
|
||||
func currentNostrIdentity() -> NostrIdentity?
|
||||
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool
|
||||
func displayNameForNostrPubkey(_ pubkeyHex: String) -> String
|
||||
|
||||
// MARK: Event dedup
|
||||
func hasProcessedNostrEvent(_ eventID: String) -> Bool
|
||||
func recordProcessedNostrEvent(_ eventID: String)
|
||||
func clearProcessedNostrEvents()
|
||||
|
||||
// MARK: Geo participants & presence
|
||||
var geoNicknames: [String: String] { get }
|
||||
var teleportedGeoCount: Int { get }
|
||||
func startGeoParticipantRefreshTimer()
|
||||
func stopGeoParticipantRefreshTimer()
|
||||
func setActiveParticipantGeohash(_ geohash: String?)
|
||||
func recordGeoParticipant(pubkeyHex: String)
|
||||
func recordGeoParticipant(pubkeyHex: String, geohash: String)
|
||||
func geoParticipantCount(for geohash: String) -> Int
|
||||
func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String)
|
||||
func markGeoTeleported(_ pubkeyHexLowercased: String)
|
||||
func clearGeoTeleported(_ pubkeyHexLowercased: String)
|
||||
func clearTeleportedGeo()
|
||||
func clearGeoNicknames()
|
||||
func visibleGeohashPeople() -> [GeoPerson]
|
||||
|
||||
// MARK: Location channels
|
||||
var isTeleported: Bool { get }
|
||||
/// True when regional channels are known and the geohash is not one of them.
|
||||
func isGeohashOutsideRegionalChannels(_ geohash: String) -> Bool
|
||||
|
||||
// MARK: Routing & acknowledgements (shared with `ChatPrivateConversationContext`)
|
||||
func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
||||
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
|
||||
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
|
||||
}
|
||||
|
||||
extension ChatViewModel: ChatNostrContext {
|
||||
// `activeChannel`, `selectedPrivateChatPeer`, `nostrKeyMapping`,
|
||||
// `messages`, `geoNicknames`, the Nostr identity/blocking members, and the
|
||||
// routing/ack members are shared requirements with `ChatDeliveryContext` /
|
||||
// `ChatPrivateConversationContext`; their witnesses already exist. The
|
||||
// members below flatten nested service accesses into intent-named calls.
|
||||
|
||||
func resetPublicMessagePipeline() {
|
||||
publicMessagePipeline.reset()
|
||||
}
|
||||
|
||||
func updatePublicMessagePipelineChannel(_ channel: ChannelID) {
|
||||
publicMessagePipeline.updateActiveChannel(channel)
|
||||
}
|
||||
|
||||
func drainPendingGeohashSystemMessages() -> [String] {
|
||||
timelineStore.drainPendingGeohashSystemMessages()
|
||||
}
|
||||
|
||||
func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool {
|
||||
timelineStore.appendIfAbsent(message, toGeohash: geohash)
|
||||
}
|
||||
|
||||
func hasProcessedNostrEvent(_ eventID: String) -> Bool {
|
||||
deduplicationService.hasProcessedNostrEvent(eventID)
|
||||
}
|
||||
|
||||
func recordProcessedNostrEvent(_ eventID: String) {
|
||||
deduplicationService.recordNostrEvent(eventID)
|
||||
}
|
||||
|
||||
func clearProcessedNostrEvents() {
|
||||
deduplicationService.clearNostrCaches()
|
||||
}
|
||||
|
||||
var teleportedGeoCount: Int {
|
||||
locationPresenceStore.teleportedGeo.count
|
||||
}
|
||||
|
||||
func startGeoParticipantRefreshTimer() {
|
||||
participantTracker.startRefreshTimer()
|
||||
}
|
||||
|
||||
func stopGeoParticipantRefreshTimer() {
|
||||
participantTracker.stopRefreshTimer()
|
||||
}
|
||||
|
||||
func setActiveParticipantGeohash(_ geohash: String?) {
|
||||
participantTracker.setActiveGeohash(geohash)
|
||||
}
|
||||
|
||||
func recordGeoParticipant(pubkeyHex: String) {
|
||||
participantTracker.recordParticipant(pubkeyHex: pubkeyHex)
|
||||
}
|
||||
|
||||
func recordGeoParticipant(pubkeyHex: String, geohash: String) {
|
||||
participantTracker.recordParticipant(pubkeyHex: pubkeyHex, geohash: geohash)
|
||||
}
|
||||
|
||||
func geoParticipantCount(for geohash: String) -> Int {
|
||||
participantTracker.participantCount(for: geohash)
|
||||
}
|
||||
|
||||
func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String) {
|
||||
locationPresenceStore.setNickname(nickname, for: pubkeyHex)
|
||||
}
|
||||
|
||||
func markGeoTeleported(_ pubkeyHexLowercased: String) {
|
||||
locationPresenceStore.markTeleported(pubkeyHexLowercased)
|
||||
}
|
||||
|
||||
func clearGeoTeleported(_ pubkeyHexLowercased: String) {
|
||||
locationPresenceStore.clearTeleported(pubkeyHexLowercased)
|
||||
}
|
||||
|
||||
func clearTeleportedGeo() {
|
||||
locationPresenceStore.clearTeleportedGeo()
|
||||
}
|
||||
|
||||
func clearGeoNicknames() {
|
||||
locationPresenceStore.clearGeoNicknames()
|
||||
}
|
||||
|
||||
var isTeleported: Bool {
|
||||
locationManager.teleported
|
||||
}
|
||||
|
||||
func isGeohashOutsideRegionalChannels(_ geohash: String) -> Bool {
|
||||
let channels = locationManager.availableChannels
|
||||
return !channels.isEmpty && !channels.contains { $0.geohash == geohash }
|
||||
}
|
||||
}
|
||||
|
||||
final class ChatNostrCoordinator {
|
||||
private weak var context: (any ChatNostrContext)?
|
||||
private var recentGeoSamplingEventIDs = Set<String>()
|
||||
private var recentGeoSamplingEventIDOrder: [String] = []
|
||||
|
||||
init(context: any ChatNostrContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func resubscribeCurrentGeohash() {
|
||||
guard case .location(let channel) = viewModel.activeChannel else { return }
|
||||
guard let subID = viewModel.geoSubscriptionID else {
|
||||
switchLocationChannel(to: viewModel.activeChannel)
|
||||
guard let context else { return }
|
||||
guard case .location(let channel) = context.activeChannel else { return }
|
||||
guard let subID = context.geoSubscriptionID else {
|
||||
switchLocationChannel(to: context.activeChannel)
|
||||
return
|
||||
}
|
||||
|
||||
viewModel.participantTracker.startRefreshTimer()
|
||||
context.startGeoParticipantRefreshTimer()
|
||||
NostrRelayManager.shared.unsubscribe(id: subID)
|
||||
let filter = NostrFilter.geohashEphemeral(
|
||||
channel.geohash,
|
||||
@@ -36,14 +222,14 @@ final class ChatNostrCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
if let dmSub = viewModel.geoDmSubscriptionID {
|
||||
if let dmSub = context.geoDmSubscriptionID {
|
||||
NostrRelayManager.shared.unsubscribe(id: dmSub)
|
||||
viewModel.geoDmSubscriptionID = nil
|
||||
context.geoDmSubscriptionID = nil
|
||||
}
|
||||
|
||||
if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
|
||||
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
|
||||
let dmSub = "geo-dm-\(channel.geohash)"
|
||||
viewModel.geoDmSubscriptionID = dmSub
|
||||
context.geoDmSubscriptionID = dmSub
|
||||
let dmFilter = NostrFilter.giftWrapsFor(
|
||||
pubkey: identity.publicKeyHex,
|
||||
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
|
||||
@@ -58,18 +244,19 @@ final class ChatNostrCoordinator {
|
||||
|
||||
@MainActor
|
||||
func subscribeNostrEvent(_ event: NostrEvent) {
|
||||
guard let context else { return }
|
||||
guard event.isValidSignature() else { return }
|
||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|
||||
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue),
|
||||
!viewModel.deduplicationService.hasProcessedNostrEvent(event.id)
|
||||
!context.hasProcessedNostrEvent(event.id)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
viewModel.deduplicationService.recordNostrEvent(event.id)
|
||||
context.recordProcessedNostrEvent(event.id)
|
||||
|
||||
if let gh = viewModel.currentGeohash,
|
||||
let myGeoIdentity = try? viewModel.idBridge.deriveIdentity(forGeohash: gh),
|
||||
if let gh = context.currentGeohash,
|
||||
let myGeoIdentity = try? context.deriveNostrIdentity(forGeohash: gh),
|
||||
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
|
||||
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
if Date().timeIntervalSince(eventTime) < 15 {
|
||||
@@ -79,12 +266,12 @@ final class ChatNostrCoordinator {
|
||||
|
||||
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
|
||||
let nick = nickTag[1].trimmed
|
||||
viewModel.locationPresenceStore.setNickname(nick, for: event.pubkey)
|
||||
context.setGeoNickname(nick, forPubkey: event.pubkey)
|
||||
}
|
||||
|
||||
viewModel.nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
|
||||
viewModel.nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
|
||||
viewModel.participantTracker.recordParticipant(pubkeyHex: event.pubkey)
|
||||
context.nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
|
||||
context.nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
|
||||
context.recordGeoParticipant(pubkeyHex: event.pubkey)
|
||||
|
||||
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
|
||||
return
|
||||
@@ -97,24 +284,24 @@ final class ChatNostrCoordinator {
|
||||
if hasTeleportTag {
|
||||
let key = event.pubkey.lowercased()
|
||||
let isSelf: Bool = {
|
||||
if let gh = viewModel.currentGeohash,
|
||||
let myIdentity = try? viewModel.idBridge.deriveIdentity(forGeohash: gh) {
|
||||
if let gh = context.currentGeohash,
|
||||
let myIdentity = try? context.deriveNostrIdentity(forGeohash: gh) {
|
||||
return myIdentity.publicKeyHex.lowercased() == key
|
||||
}
|
||||
return false
|
||||
}()
|
||||
if !isSelf {
|
||||
Task { @MainActor [weak viewModel] in
|
||||
viewModel?.locationPresenceStore.markTeleported(key)
|
||||
Task { @MainActor [weak context] in
|
||||
context?.markGeoTeleported(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let senderName = viewModel.displayNameForNostrPubkey(event.pubkey)
|
||||
let senderName = context.displayNameForNostrPubkey(event.pubkey)
|
||||
let content = event.content.trimmed
|
||||
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
let timestamp = min(rawTs, Date())
|
||||
let mentions = viewModel.parseMentions(from: content)
|
||||
let mentions = context.parseMentions(from: content)
|
||||
let message = BitchatMessage(
|
||||
id: event.id,
|
||||
sender: senderName,
|
||||
@@ -125,22 +312,23 @@ final class ChatNostrCoordinator {
|
||||
mentions: mentions.isEmpty ? nil : mentions
|
||||
)
|
||||
|
||||
Task { @MainActor [weak viewModel] in
|
||||
guard let viewModel else { return }
|
||||
let isBlocked = viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased())
|
||||
viewModel.handlePublicMessage(message)
|
||||
Task { @MainActor [weak context] in
|
||||
guard let context else { return }
|
||||
let isBlocked = context.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased())
|
||||
context.handlePublicMessage(message)
|
||||
if !isBlocked {
|
||||
viewModel.checkForMentions(message)
|
||||
viewModel.sendHapticFeedback(for: message)
|
||||
context.checkForMentions(message)
|
||||
context.sendHapticFeedback(for: message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||
guard let context else { return }
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
guard !viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id) else { return }
|
||||
viewModel.deduplicationService.recordNostrEvent(giftWrap.id)
|
||||
guard !context.hasProcessedNostrEvent(giftWrap.id) else { return }
|
||||
context.recordProcessedNostrEvent(giftWrap.id)
|
||||
|
||||
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
@@ -155,11 +343,11 @@ final class ChatNostrCoordinator {
|
||||
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
|
||||
let convKey = PeerID(nostr_: senderPubkey)
|
||||
viewModel.nostrKeyMapping[convKey] = senderPubkey
|
||||
context.nostrKeyMapping[convKey] = senderPubkey
|
||||
|
||||
switch noisePayload.type {
|
||||
case .privateMessage:
|
||||
viewModel.handlePrivateMessage(
|
||||
context.handlePrivateMessage(
|
||||
noisePayload,
|
||||
senderPubkey: senderPubkey,
|
||||
convKey: convKey,
|
||||
@@ -167,9 +355,9 @@ final class ChatNostrCoordinator {
|
||||
messageTimestamp: messageTimestamp
|
||||
)
|
||||
case .delivered:
|
||||
viewModel.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
case .readReceipt:
|
||||
viewModel.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
case .verifyChallenge, .verifyResponse:
|
||||
break
|
||||
}
|
||||
@@ -177,67 +365,66 @@ final class ChatNostrCoordinator {
|
||||
|
||||
@MainActor
|
||||
func switchLocationChannel(to channel: ChannelID) {
|
||||
viewModel.publicMessagePipeline.reset()
|
||||
viewModel.activeChannel = channel
|
||||
viewModel.publicMessagePipeline.updateActiveChannel(channel)
|
||||
guard let context else { return }
|
||||
context.resetPublicMessagePipeline()
|
||||
context.activeChannel = channel
|
||||
context.updatePublicMessagePipelineChannel(channel)
|
||||
|
||||
viewModel.deduplicationService.clearNostrCaches()
|
||||
context.clearProcessedNostrEvents()
|
||||
switch channel {
|
||||
case .mesh:
|
||||
viewModel.refreshVisibleMessages(from: .mesh)
|
||||
let emptyMesh = viewModel.messages.filter { $0.content.trimmed.isEmpty }.count
|
||||
context.refreshVisibleMessages(from: .mesh)
|
||||
let emptyMesh = context.messages.filter { $0.content.trimmed.isEmpty }.count
|
||||
if emptyMesh > 0 {
|
||||
SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session)
|
||||
}
|
||||
viewModel.participantTracker.stopRefreshTimer()
|
||||
viewModel.participantTracker.setActiveGeohash(nil)
|
||||
viewModel.locationPresenceStore.clearTeleportedGeo()
|
||||
context.stopGeoParticipantRefreshTimer()
|
||||
context.setActiveParticipantGeohash(nil)
|
||||
context.clearTeleportedGeo()
|
||||
|
||||
case .location:
|
||||
viewModel.refreshVisibleMessages(from: channel)
|
||||
context.refreshVisibleMessages(from: channel)
|
||||
}
|
||||
|
||||
if case .location = channel {
|
||||
for content in viewModel.timelineStore.drainPendingGeohashSystemMessages() {
|
||||
viewModel.addPublicSystemMessage(content)
|
||||
for content in context.drainPendingGeohashSystemMessages() {
|
||||
context.addPublicSystemMessage(content)
|
||||
}
|
||||
}
|
||||
|
||||
if let sub = viewModel.geoSubscriptionID {
|
||||
if let sub = context.geoSubscriptionID {
|
||||
NostrRelayManager.shared.unsubscribe(id: sub)
|
||||
viewModel.geoSubscriptionID = nil
|
||||
context.geoSubscriptionID = nil
|
||||
}
|
||||
if let dmSub = viewModel.geoDmSubscriptionID {
|
||||
if let dmSub = context.geoDmSubscriptionID {
|
||||
NostrRelayManager.shared.unsubscribe(id: dmSub)
|
||||
viewModel.geoDmSubscriptionID = nil
|
||||
context.geoDmSubscriptionID = nil
|
||||
}
|
||||
viewModel.currentGeohash = nil
|
||||
viewModel.participantTracker.setActiveGeohash(nil)
|
||||
viewModel.locationPresenceStore.clearGeoNicknames()
|
||||
context.currentGeohash = nil
|
||||
context.setActiveParticipantGeohash(nil)
|
||||
context.clearGeoNicknames()
|
||||
|
||||
guard case .location(let channel) = channel else { return }
|
||||
viewModel.currentGeohash = channel.geohash
|
||||
viewModel.participantTracker.setActiveGeohash(channel.geohash)
|
||||
context.currentGeohash = channel.geohash
|
||||
context.setActiveParticipantGeohash(channel.geohash)
|
||||
|
||||
if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
|
||||
viewModel.participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex)
|
||||
let hasRegional = !viewModel.locationManager.availableChannels.isEmpty
|
||||
let inRegional = viewModel.locationManager.availableChannels.contains { $0.geohash == channel.geohash }
|
||||
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
|
||||
context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
|
||||
let key = identity.publicKeyHex.lowercased()
|
||||
if viewModel.locationManager.teleported && hasRegional && !inRegional {
|
||||
viewModel.locationPresenceStore.markTeleported(key)
|
||||
if context.isTeleported && context.isGeohashOutsideRegionalChannels(channel.geohash) {
|
||||
context.markGeoTeleported(key)
|
||||
SecureLogger.info(
|
||||
"GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(viewModel.locationPresenceStore.teleportedGeo.count)",
|
||||
"GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(context.teleportedGeoCount)",
|
||||
category: .session
|
||||
)
|
||||
} else {
|
||||
viewModel.locationPresenceStore.clearTeleported(key)
|
||||
context.clearGeoTeleported(key)
|
||||
}
|
||||
}
|
||||
|
||||
let subID = "geo-\(channel.geohash)"
|
||||
viewModel.geoSubscriptionID = subID
|
||||
viewModel.participantTracker.startRefreshTimer()
|
||||
context.geoSubscriptionID = subID
|
||||
context.startGeoParticipantRefreshTimer()
|
||||
let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds)
|
||||
let filter = NostrFilter.geohashEphemeral(channel.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit)
|
||||
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5)
|
||||
@@ -252,6 +439,7 @@ final class ChatNostrCoordinator {
|
||||
|
||||
@MainActor
|
||||
func handleNostrEvent(_ event: NostrEvent) {
|
||||
guard let context else { return }
|
||||
guard event.isValidSignature() else { return }
|
||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|
||||
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue)
|
||||
@@ -259,13 +447,13 @@ final class ChatNostrCoordinator {
|
||||
return
|
||||
}
|
||||
|
||||
if viewModel.deduplicationService.hasProcessedNostrEvent(event.id) { return }
|
||||
viewModel.deduplicationService.recordNostrEvent(event.id)
|
||||
if context.hasProcessedNostrEvent(event.id) { return }
|
||||
context.recordProcessedNostrEvent(event.id)
|
||||
|
||||
let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",")
|
||||
SecureLogger.debug("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", category: .session)
|
||||
|
||||
if viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
|
||||
if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -274,8 +462,8 @@ final class ChatNostrCoordinator {
|
||||
}
|
||||
|
||||
let isSelf: Bool = {
|
||||
if let gh = viewModel.currentGeohash,
|
||||
let my = try? viewModel.idBridge.deriveIdentity(forGeohash: gh) {
|
||||
if let gh = context.currentGeohash,
|
||||
let my = try? context.deriveNostrIdentity(forGeohash: gh) {
|
||||
return my.publicKeyHex.lowercased() == event.pubkey.lowercased()
|
||||
}
|
||||
return false
|
||||
@@ -283,17 +471,17 @@ final class ChatNostrCoordinator {
|
||||
|
||||
if hasTeleportTag, !isSelf {
|
||||
let key = event.pubkey.lowercased()
|
||||
Task { @MainActor [weak viewModel] in
|
||||
guard let viewModel else { return }
|
||||
viewModel.locationPresenceStore.markTeleported(key)
|
||||
Task { @MainActor [weak context] in
|
||||
guard let context else { return }
|
||||
context.markGeoTeleported(key)
|
||||
SecureLogger.info(
|
||||
"GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(viewModel.locationPresenceStore.teleportedGeo.count)",
|
||||
"GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(context.teleportedGeoCount)",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.participantTracker.recordParticipant(pubkeyHex: event.pubkey)
|
||||
context.recordGeoParticipant(pubkeyHex: event.pubkey)
|
||||
|
||||
if isSelf {
|
||||
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
@@ -303,17 +491,17 @@ final class ChatNostrCoordinator {
|
||||
}
|
||||
|
||||
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
|
||||
viewModel.locationPresenceStore.setNickname(nickTag[1].trimmed, for: event.pubkey)
|
||||
context.setGeoNickname(nickTag[1].trimmed, forPubkey: event.pubkey)
|
||||
}
|
||||
|
||||
viewModel.nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
|
||||
viewModel.nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
|
||||
context.nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
|
||||
context.nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
|
||||
|
||||
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
|
||||
return
|
||||
}
|
||||
|
||||
let senderName = viewModel.displayNameForNostrPubkey(event.pubkey)
|
||||
let senderName = context.displayNameForNostrPubkey(event.pubkey)
|
||||
let content = event.content
|
||||
|
||||
if let teleTag = event.tags.first(where: { $0.first == "t" }),
|
||||
@@ -324,7 +512,7 @@ final class ChatNostrCoordinator {
|
||||
}
|
||||
|
||||
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
let mentions = viewModel.parseMentions(from: content)
|
||||
let mentions = context.parseMentions(from: content)
|
||||
let message = BitchatMessage(
|
||||
id: event.id,
|
||||
sender: senderName,
|
||||
@@ -335,20 +523,21 @@ final class ChatNostrCoordinator {
|
||||
mentions: mentions.isEmpty ? nil : mentions
|
||||
)
|
||||
|
||||
Task { @MainActor [weak viewModel] in
|
||||
guard let viewModel else { return }
|
||||
viewModel.handlePublicMessage(message)
|
||||
viewModel.checkForMentions(message)
|
||||
viewModel.sendHapticFeedback(for: message)
|
||||
Task { @MainActor [weak context] in
|
||||
guard let context else { return }
|
||||
context.handlePublicMessage(message)
|
||||
context.checkForMentions(message)
|
||||
context.sendHapticFeedback(for: message)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func subscribeToGeoChat(_ channel: GeohashChannel) {
|
||||
guard let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) else { return }
|
||||
guard let context else { return }
|
||||
guard let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) else { return }
|
||||
|
||||
let dmSub = "geo-dm-\(channel.geohash)"
|
||||
viewModel.geoDmSubscriptionID = dmSub
|
||||
context.geoDmSubscriptionID = dmSub
|
||||
if TorManager.shared.isReady {
|
||||
SecureLogger.debug("GeoDM: subscribing DMs pub=\(identity.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session)
|
||||
}
|
||||
@@ -365,11 +554,12 @@ final class ChatNostrCoordinator {
|
||||
|
||||
@MainActor
|
||||
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||
guard let context else { return }
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
if viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id) {
|
||||
if context.hasProcessedNostrEvent(giftWrap.id) {
|
||||
return
|
||||
}
|
||||
viewModel.deduplicationService.recordNostrEvent(giftWrap.id)
|
||||
context.recordProcessedNostrEvent(giftWrap.id)
|
||||
|
||||
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
@@ -392,12 +582,12 @@ final class ChatNostrCoordinator {
|
||||
}
|
||||
|
||||
let convKey = PeerID(nostr_: senderPubkey)
|
||||
viewModel.nostrKeyMapping[convKey] = senderPubkey
|
||||
context.nostrKeyMapping[convKey] = senderPubkey
|
||||
|
||||
switch payload.type {
|
||||
case .privateMessage:
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
|
||||
viewModel.handlePrivateMessage(
|
||||
context.handlePrivateMessage(
|
||||
payload,
|
||||
senderPubkey: senderPubkey,
|
||||
convKey: convKey,
|
||||
@@ -405,19 +595,20 @@ final class ChatNostrCoordinator {
|
||||
messageTimestamp: messageTimestamp
|
||||
)
|
||||
case .delivered:
|
||||
viewModel.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
case .readReceipt:
|
||||
viewModel.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
case .verifyChallenge, .verifyResponse:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func sendGeohash(context: ChatViewModel.GeoOutgoingContext) {
|
||||
let channel = context.channel
|
||||
let event = context.event
|
||||
let identity = context.identity
|
||||
func sendGeohash(context geoContext: ChatViewModel.GeoOutgoingContext) {
|
||||
guard let context else { return }
|
||||
let channel = geoContext.channel
|
||||
let event = geoContext.event
|
||||
let identity = geoContext.identity
|
||||
|
||||
let targetRelays = GeoRelayDirectory.shared.closestRelays(
|
||||
toGeohash: channel.geohash,
|
||||
@@ -430,42 +621,41 @@ final class ChatNostrCoordinator {
|
||||
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
||||
}
|
||||
|
||||
viewModel.participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex)
|
||||
viewModel.nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = identity.publicKeyHex
|
||||
context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
|
||||
context.nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = identity.publicKeyHex
|
||||
SecureLogger.debug(
|
||||
"GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(context.teleported)",
|
||||
"GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(geoContext.teleported)",
|
||||
category: .session
|
||||
)
|
||||
|
||||
let hasRegional = !viewModel.locationManager.availableChannels.isEmpty
|
||||
let inRegional = viewModel.locationManager.availableChannels.contains { $0.geohash == channel.geohash }
|
||||
if context.teleported && hasRegional && !inRegional {
|
||||
if geoContext.teleported && context.isGeohashOutsideRegionalChannels(channel.geohash) {
|
||||
let key = identity.publicKeyHex.lowercased()
|
||||
viewModel.locationPresenceStore.markTeleported(key)
|
||||
context.markGeoTeleported(key)
|
||||
SecureLogger.info(
|
||||
"GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(viewModel.locationPresenceStore.teleportedGeo.count)",
|
||||
"GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(context.teleportedGeoCount)",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
|
||||
viewModel.deduplicationService.recordNostrEvent(event.id)
|
||||
context.recordProcessedNostrEvent(event.id)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func beginGeohashSampling(for geohashes: [String]) {
|
||||
guard let context else { return }
|
||||
if !TorManager.shared.isForeground() {
|
||||
endGeohashSampling()
|
||||
return
|
||||
}
|
||||
|
||||
let desired = Set(geohashes)
|
||||
let current = Set(viewModel.geoSamplingSubs.values)
|
||||
let current = Set(context.geoSamplingSubs.values)
|
||||
let toAdd = desired.subtracting(current)
|
||||
let toRemove = current.subtracting(desired)
|
||||
|
||||
for (subID, gh) in viewModel.geoSamplingSubs where toRemove.contains(gh) {
|
||||
for (subID, gh) in context.geoSamplingSubs where toRemove.contains(gh) {
|
||||
NostrRelayManager.shared.unsubscribe(id: subID)
|
||||
viewModel.geoSamplingSubs.removeValue(forKey: subID)
|
||||
context.geoSamplingSubs.removeValue(forKey: subID)
|
||||
}
|
||||
|
||||
for gh in toAdd {
|
||||
@@ -475,8 +665,9 @@ final class ChatNostrCoordinator {
|
||||
|
||||
@MainActor
|
||||
func subscribe(_ gh: String) {
|
||||
guard let context else { return }
|
||||
let subID = "geo-sample-\(gh)"
|
||||
viewModel.geoSamplingSubs[subID] = gh
|
||||
context.geoSamplingSubs[subID] = gh
|
||||
let filter = NostrFilter.geohashEphemeral(
|
||||
gh,
|
||||
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds),
|
||||
@@ -492,19 +683,21 @@ final class ChatNostrCoordinator {
|
||||
|
||||
@MainActor
|
||||
func subscribeNostrEvent(_ event: NostrEvent, gh: String) {
|
||||
guard event.isValidSignature() else { return }
|
||||
guard let context else { return }
|
||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|
||||
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue)
|
||||
else {
|
||||
return
|
||||
}
|
||||
guard event.isValidSignature() else { return }
|
||||
guard shouldProcessGeoSamplingEvent(event.id) else { return }
|
||||
|
||||
let existingCount = viewModel.participantTracker.participantCount(for: gh)
|
||||
viewModel.participantTracker.recordParticipant(pubkeyHex: event.pubkey, geohash: gh)
|
||||
let existingCount = context.geoParticipantCount(for: gh)
|
||||
context.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh)
|
||||
|
||||
guard let content = event.content.trimmedOrNilIfEmpty else { return }
|
||||
if viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
|
||||
if let my = try? viewModel.idBridge.deriveIdentity(forGeohash: gh),
|
||||
if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
|
||||
if let my = try? context.deriveNostrIdentity(forGeohash: gh),
|
||||
my.publicKeyHex.lowercased() == event.pubkey.lowercased() {
|
||||
return
|
||||
}
|
||||
@@ -515,10 +708,10 @@ final class ChatNostrCoordinator {
|
||||
|
||||
#if os(iOS)
|
||||
guard UIApplication.shared.applicationState == .active else { return }
|
||||
if case .location(let channel) = viewModel.activeChannel, channel.geohash == gh { return }
|
||||
if case .location(let channel) = context.activeChannel, channel.geohash == gh { return }
|
||||
#elseif os(macOS)
|
||||
guard NSApplication.shared.isActive else { return }
|
||||
if case .location(let channel) = viewModel.activeChannel, channel.geohash == gh { return }
|
||||
if case .location(let channel) = context.activeChannel, channel.geohash == gh { return }
|
||||
#endif
|
||||
|
||||
cooldownPerGeohash(gh, content: content, event: event)
|
||||
@@ -526,8 +719,9 @@ final class ChatNostrCoordinator {
|
||||
|
||||
@MainActor
|
||||
func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) {
|
||||
guard let context else { return }
|
||||
let now = Date()
|
||||
let last = viewModel.lastGeoNotificationAt[gh] ?? .distantPast
|
||||
let last = context.lastGeoNotificationAt[gh] ?? .distantPast
|
||||
if now.timeIntervalSince(last) < TransportConfig.uiGeoNotifyCooldownSeconds { return }
|
||||
|
||||
let preview: String = {
|
||||
@@ -537,16 +731,16 @@ final class ChatNostrCoordinator {
|
||||
return String(content[..<idx]) + "…"
|
||||
}()
|
||||
|
||||
Task { @MainActor [weak viewModel] in
|
||||
guard let viewModel else { return }
|
||||
viewModel.lastGeoNotificationAt[gh] = now
|
||||
Task { @MainActor [weak context] in
|
||||
guard let context else { return }
|
||||
context.lastGeoNotificationAt[gh] = now
|
||||
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 rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
let ts = min(rawTs, Date())
|
||||
let mentions = viewModel.parseMentions(from: content)
|
||||
let mentions = context.parseMentions(from: content)
|
||||
let message = BitchatMessage(
|
||||
id: event.id,
|
||||
sender: senderName,
|
||||
@@ -556,8 +750,8 @@ final class ChatNostrCoordinator {
|
||||
senderPeerID: PeerID(nostr: event.pubkey),
|
||||
mentions: mentions.isEmpty ? nil : mentions
|
||||
)
|
||||
if viewModel.timelineStore.appendIfAbsent(message, toGeohash: gh) {
|
||||
viewModel.synchronizePublicConversationStore(forGeohash: gh)
|
||||
if context.appendGeohashMessageIfAbsent(message, toGeohash: gh) {
|
||||
context.synchronizePublicConversationStore(forGeohash: gh)
|
||||
NotificationService.shared.sendGeohashActivityNotification(geohash: gh, bodyPreview: preview)
|
||||
}
|
||||
}
|
||||
@@ -565,15 +759,41 @@ final class ChatNostrCoordinator {
|
||||
|
||||
@MainActor
|
||||
func endGeohashSampling() {
|
||||
for subID in viewModel.geoSamplingSubs.keys {
|
||||
guard let context else { return }
|
||||
for subID in context.geoSamplingSubs.keys {
|
||||
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
|
||||
func setupNostrMessageHandling() {
|
||||
guard let currentIdentity = try? viewModel.idBridge.getCurrentNostrIdentity() else {
|
||||
guard let context else { return }
|
||||
guard let currentIdentity = context.currentNostrIdentity() else {
|
||||
SecureLogger.warning("⚠️ No Nostr identity available for message handling", category: .session)
|
||||
return
|
||||
}
|
||||
@@ -588,7 +808,7 @@ final class ChatNostrCoordinator {
|
||||
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
|
||||
)
|
||||
|
||||
viewModel.nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in
|
||||
context.nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.handleNostrMessage(event)
|
||||
}
|
||||
@@ -597,8 +817,10 @@ final class ChatNostrCoordinator {
|
||||
|
||||
@MainActor
|
||||
func handleNostrMessage(_ giftWrap: NostrEvent) {
|
||||
if viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id) { return }
|
||||
viewModel.deduplicationService.recordNostrEvent(giftWrap.id)
|
||||
guard let context else { return }
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
if context.hasProcessedNostrEvent(giftWrap.id) { return }
|
||||
context.recordProcessedNostrEvent(giftWrap.id)
|
||||
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
await self?.processNostrMessage(giftWrap)
|
||||
@@ -607,8 +829,9 @@ final class ChatNostrCoordinator {
|
||||
|
||||
func processNostrMessage(_ giftWrap: NostrEvent) async {
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
guard let context else { return }
|
||||
let currentIdentity: NostrIdentity? = await MainActor.run {
|
||||
try? viewModel.idBridge.getCurrentNostrIdentity()
|
||||
context.currentNostrIdentity()
|
||||
}
|
||||
guard let currentIdentity else { return }
|
||||
|
||||
@@ -640,11 +863,11 @@ final class ChatNostrCoordinator {
|
||||
let payload = NoisePayload.decode(packet.payload) {
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
|
||||
await MainActor.run {
|
||||
viewModel.nostrKeyMapping[targetPeerID] = senderPubkey
|
||||
context.nostrKeyMapping[targetPeerID] = senderPubkey
|
||||
|
||||
switch payload.type {
|
||||
case .privateMessage:
|
||||
viewModel.handlePrivateMessage(
|
||||
context.handlePrivateMessage(
|
||||
payload,
|
||||
senderPubkey: senderPubkey,
|
||||
convKey: targetPeerID,
|
||||
@@ -652,9 +875,9 @@ final class ChatNostrCoordinator {
|
||||
messageTimestamp: messageTimestamp
|
||||
)
|
||||
case .delivered:
|
||||
viewModel.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
case .readReceipt:
|
||||
viewModel.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
case .verifyChallenge, .verifyResponse:
|
||||
break
|
||||
}
|
||||
@@ -711,33 +934,26 @@ final class ChatNostrCoordinator {
|
||||
senderPubkey: String,
|
||||
key: Data?
|
||||
) {
|
||||
guard let context else { return }
|
||||
if let _ = key {
|
||||
if let identity = try? viewModel.idBridge.getCurrentNostrIdentity() {
|
||||
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge)
|
||||
transport.senderPeerID = viewModel.meshService.myPeerID
|
||||
transport.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: identity)
|
||||
if let identity = context.currentNostrIdentity() {
|
||||
context.sendGeohashDeliveryAck(for: message.id, toRecipientHex: senderPubkey, from: identity)
|
||||
}
|
||||
} else if let identity = try? viewModel.idBridge.getCurrentNostrIdentity() {
|
||||
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge)
|
||||
transport.senderPeerID = viewModel.meshService.myPeerID
|
||||
transport.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: identity)
|
||||
} else if let identity = context.currentNostrIdentity() {
|
||||
context.sendGeohashDeliveryAck(for: message.id, toRecipientHex: senderPubkey, from: identity)
|
||||
SecureLogger.debug(
|
||||
"Sent DELIVERED ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
|
||||
if !wasReadBefore && viewModel.selectedPrivateChatPeer == message.senderPeerID {
|
||||
if !wasReadBefore && context.selectedPrivateChatPeer == message.senderPeerID {
|
||||
if let _ = key {
|
||||
if let identity = try? viewModel.idBridge.getCurrentNostrIdentity() {
|
||||
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge)
|
||||
transport.senderPeerID = viewModel.meshService.myPeerID
|
||||
transport.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: identity)
|
||||
if let identity = context.currentNostrIdentity() {
|
||||
context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity)
|
||||
}
|
||||
} else if let identity = try? viewModel.idBridge.getCurrentNostrIdentity() {
|
||||
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge)
|
||||
transport.senderPeerID = viewModel.meshService.myPeerID
|
||||
transport.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: identity)
|
||||
} else if let identity = context.currentNostrIdentity() {
|
||||
context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity)
|
||||
SecureLogger.debug(
|
||||
"Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…",
|
||||
category: .session
|
||||
@@ -798,6 +1014,7 @@ final class ChatNostrCoordinator {
|
||||
|
||||
@MainActor
|
||||
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
|
||||
guard let context else { return }
|
||||
guard let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey),
|
||||
relationship.peerNostrPublicKey != nil else {
|
||||
SecureLogger.warning("⚠️ Cannot send favorite notification - no Nostr key for peer", category: .session)
|
||||
@@ -805,15 +1022,16 @@ final class ChatNostrCoordinator {
|
||||
}
|
||||
|
||||
let peerID = PeerID(hexData: noisePublicKey)
|
||||
viewModel.messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||
context.routeFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func nostrPubkeyForDisplayName(_ name: String) -> String? {
|
||||
for person in viewModel.visibleGeohashPeople() where person.displayName == name {
|
||||
guard let context else { return nil }
|
||||
for person in context.visibleGeohashPeople() where person.displayName == name {
|
||||
return person.id
|
||||
}
|
||||
for (pub, nick) in viewModel.geoNicknames where nick == name {
|
||||
for (pub, nick) in context.geoNicknames where nick == name {
|
||||
return pub
|
||||
}
|
||||
return nil
|
||||
@@ -821,22 +1039,25 @@ final class ChatNostrCoordinator {
|
||||
|
||||
@MainActor
|
||||
func startGeohashDM(withPubkeyHex hex: String) {
|
||||
guard let context else { return }
|
||||
let convKey = PeerID(nostr_: hex)
|
||||
viewModel.nostrKeyMapping[convKey] = hex
|
||||
viewModel.startPrivateChat(with: convKey)
|
||||
context.nostrKeyMapping[convKey] = hex
|
||||
context.startPrivateChat(with: convKey)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func fullNostrHex(forSenderPeerID senderID: PeerID) -> String? {
|
||||
viewModel.nostrKeyMapping[senderID]
|
||||
guard let context else { return nil }
|
||||
return context.nostrKeyMapping[senderID]
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func geohashDisplayName(for convKey: PeerID) -> String {
|
||||
guard let full = viewModel.nostrKeyMapping[convKey] else {
|
||||
guard let context else { return convKey.bare }
|
||||
guard let full = context.nostrKeyMapping[convKey] else {
|
||||
return convKey.bare
|
||||
}
|
||||
return viewModel.displayNameForNostrPubkey(full)
|
||||
return context.displayNameForNostrPubkey(full)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,20 +2,174 @@ import BitFoundation
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
/// The narrow surface `ChatPrivateConversationCoordinator` needs from its owner.
|
||||
///
|
||||
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
|
||||
/// minimal context it actually uses instead of holding an `unowned` back-ref
|
||||
/// to the whole `ChatViewModel`. This keeps the coordinator independently
|
||||
/// testable (see `ChatPrivateConversationCoordinatorContextTests`) and makes
|
||||
/// its true dependencies explicit. The surface is intentionally large — it
|
||||
/// documents the coordinator's real coupling to private-chat state, peer
|
||||
/// identity, and the routing/ack transports.
|
||||
@MainActor
|
||||
protocol ChatPrivateConversationContext: AnyObject {
|
||||
// MARK: Conversation state
|
||||
var privateChats: [PeerID: [BitchatMessage]] { get set }
|
||||
var sentReadReceipts: Set<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
|
||||
final class ChatPrivateConversationCoordinator {
|
||||
private unowned let viewModel: ChatViewModel
|
||||
private unowned let context: any ChatPrivateConversationContext
|
||||
|
||||
init(viewModel: ChatViewModel) {
|
||||
self.viewModel = viewModel
|
||||
init(context: any ChatPrivateConversationContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
func sendPrivateMessage(_ content: String, to peerID: PeerID) {
|
||||
guard !content.isEmpty else { return }
|
||||
|
||||
if viewModel.unifiedPeerService.isBlocked(peerID) {
|
||||
let nickname = viewModel.meshService.peerNickname(peerID: peerID) ?? "user"
|
||||
viewModel.addSystemMessage(
|
||||
if context.isPeerBlocked(peerID) {
|
||||
let nickname = context.peerNickname(for: peerID) ?? "user"
|
||||
context.addSystemMessage(
|
||||
String(
|
||||
format: String(localized: "system.dm.blocked_recipient", comment: "System message when attempting to message a blocked user"),
|
||||
locale: .current,
|
||||
@@ -31,13 +185,13 @@ final class ChatPrivateConversationCoordinator {
|
||||
}
|
||||
|
||||
guard let noiseKey = Data(hexString: peerID.id) else { return }
|
||||
let isConnected = viewModel.meshService.isPeerConnected(peerID)
|
||||
let isReachable = viewModel.meshService.isPeerReachable(peerID)
|
||||
let isConnected = context.isPeerConnected(peerID)
|
||||
let isReachable = context.isPeerReachable(peerID)
|
||||
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
|
||||
let isMutualFavorite = favoriteStatus?.isMutual ?? false
|
||||
let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil
|
||||
|
||||
var recipientNickname = viewModel.meshService.peerNickname(peerID: peerID)
|
||||
var recipientNickname = context.peerNickname(for: peerID)
|
||||
if recipientNickname == nil && favoriteStatus != nil {
|
||||
recipientNickname = favoriteStatus?.peerNickname
|
||||
}
|
||||
@@ -46,42 +200,42 @@ final class ChatPrivateConversationCoordinator {
|
||||
let messageID = UUID().uuidString
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
sender: context.nickname,
|
||||
content: content,
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: true,
|
||||
recipientNickname: recipientNickname,
|
||||
senderPeerID: viewModel.meshService.myPeerID,
|
||||
senderPeerID: context.myPeerID,
|
||||
mentions: nil,
|
||||
deliveryStatus: .sending
|
||||
)
|
||||
|
||||
if viewModel.privateChats[peerID] == nil {
|
||||
viewModel.privateChats[peerID] = []
|
||||
if context.privateChats[peerID] == nil {
|
||||
context.privateChats[peerID] = []
|
||||
}
|
||||
viewModel.privateChats[peerID]?.append(message)
|
||||
viewModel.objectWillChange.send()
|
||||
context.privateChats[peerID]?.append(message)
|
||||
context.notifyUIChanged()
|
||||
|
||||
if isConnected || isReachable || (isMutualFavorite && hasNostrKey) {
|
||||
viewModel.messageRouter.sendPrivate(
|
||||
context.routePrivateMessage(
|
||||
content,
|
||||
to: peerID,
|
||||
recipientNickname: recipientNickname ?? "user",
|
||||
messageID: messageID
|
||||
)
|
||||
if let idx = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
viewModel.privateChats[peerID]?[idx].deliveryStatus = .sent
|
||||
if let idx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
context.privateChats[peerID]?[idx].deliveryStatus = .sent
|
||||
}
|
||||
} else {
|
||||
if let index = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
viewModel.privateChats[peerID]?[index].deliveryStatus = .failed(
|
||||
if let index = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
context.privateChats[peerID]?[index].deliveryStatus = .failed(
|
||||
reason: String(localized: "content.delivery.reason.unreachable", comment: "Failure reason when a peer is unreachable")
|
||||
)
|
||||
}
|
||||
let name = recipientNickname ?? "user"
|
||||
viewModel.addSystemMessage(
|
||||
context.addSystemMessage(
|
||||
String(
|
||||
format: String(localized: "system.dm.unreachable", comment: "System message when a recipient is unreachable"),
|
||||
locale: .current,
|
||||
@@ -92,8 +246,8 @@ final class ChatPrivateConversationCoordinator {
|
||||
}
|
||||
|
||||
func sendGeohashDM(_ content: String, to peerID: PeerID) {
|
||||
guard case .location(let channel) = viewModel.activeChannel else {
|
||||
viewModel.addSystemMessage(
|
||||
guard case .location(let channel) = context.activeChannel else {
|
||||
context.addSystemMessage(
|
||||
String(localized: "system.location.not_in_channel", comment: "System message when attempting to send without being in a location channel")
|
||||
)
|
||||
return
|
||||
@@ -102,49 +256,49 @@ final class ChatPrivateConversationCoordinator {
|
||||
let messageID = UUID().uuidString
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
sender: context.nickname,
|
||||
content: content,
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: viewModel.nickname,
|
||||
senderPeerID: viewModel.meshService.myPeerID,
|
||||
recipientNickname: context.nickname,
|
||||
senderPeerID: context.myPeerID,
|
||||
deliveryStatus: .sending
|
||||
)
|
||||
|
||||
if viewModel.privateChats[peerID] == nil {
|
||||
viewModel.privateChats[peerID] = []
|
||||
if context.privateChats[peerID] == nil {
|
||||
context.privateChats[peerID] = []
|
||||
}
|
||||
|
||||
viewModel.privateChats[peerID]?.append(message)
|
||||
viewModel.objectWillChange.send()
|
||||
context.privateChats[peerID]?.append(message)
|
||||
context.notifyUIChanged()
|
||||
|
||||
guard let recipientHex = viewModel.nostrKeyMapping[peerID] else {
|
||||
if let msgIdx = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
viewModel.privateChats[peerID]?[msgIdx].deliveryStatus = .failed(
|
||||
guard let recipientHex = context.nostrKeyMapping[peerID] else {
|
||||
if let msgIdx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
context.privateChats[peerID]?[msgIdx].deliveryStatus = .failed(
|
||||
reason: String(localized: "content.delivery.reason.unknown_recipient", comment: "Failure reason when the recipient is unknown")
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: recipientHex) {
|
||||
if let msgIdx = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
viewModel.privateChats[peerID]?[msgIdx].deliveryStatus = .failed(
|
||||
if context.isNostrBlocked(pubkeyHexLowercased: recipientHex) {
|
||||
if let msgIdx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
context.privateChats[peerID]?[msgIdx].deliveryStatus = .failed(
|
||||
reason: String(localized: "content.delivery.reason.blocked", comment: "Failure reason when the user is blocked")
|
||||
)
|
||||
}
|
||||
viewModel.addSystemMessage(
|
||||
context.addSystemMessage(
|
||||
String(localized: "system.dm.blocked_generic", comment: "System message when sending fails because user is blocked")
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash)
|
||||
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
|
||||
if recipientHex.lowercased() == identity.publicKeyHex.lowercased() {
|
||||
if let idx = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
viewModel.privateChats[peerID]?[idx].deliveryStatus = .failed(
|
||||
if let idx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
context.privateChats[peerID]?[idx].deliveryStatus = .failed(
|
||||
reason: String(localized: "content.delivery.reason.self", comment: "Failure reason when attempting to message yourself")
|
||||
)
|
||||
}
|
||||
@@ -155,20 +309,18 @@ final class ChatPrivateConversationCoordinator {
|
||||
"GeoDM: local send mid=\(messageID.prefix(8))… to=\(recipientHex.prefix(8))… conv=\(peerID)",
|
||||
category: .session
|
||||
)
|
||||
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge)
|
||||
transport.senderPeerID = viewModel.meshService.myPeerID
|
||||
transport.sendPrivateMessageGeohash(
|
||||
content: content,
|
||||
context.sendGeohashPrivateMessage(
|
||||
content,
|
||||
toRecipientHex: recipientHex,
|
||||
from: identity,
|
||||
messageID: messageID
|
||||
)
|
||||
if let msgIdx = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
viewModel.privateChats[peerID]?[msgIdx].deliveryStatus = .sent
|
||||
if let msgIdx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
context.privateChats[peerID]?[msgIdx].deliveryStatus = .sent
|
||||
}
|
||||
} catch {
|
||||
if let idx = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
viewModel.privateChats[peerID]?[idx].deliveryStatus = .failed(
|
||||
if let idx = context.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
context.privateChats[peerID]?[idx].deliveryStatus = .failed(
|
||||
reason: String(localized: "content.delivery.reason.send_error", comment: "Failure reason for a generic send error")
|
||||
)
|
||||
}
|
||||
@@ -189,16 +341,16 @@ final class ChatPrivateConversationCoordinator {
|
||||
|
||||
sendDeliveryAckIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id)
|
||||
|
||||
if viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: senderPubkey) {
|
||||
if context.isNostrBlocked(pubkeyHexLowercased: senderPubkey) {
|
||||
return
|
||||
}
|
||||
|
||||
if viewModel.privateChats[convKey]?.contains(where: { $0.id == messageId }) == true { return }
|
||||
for (_, arr) in viewModel.privateChats where arr.contains(where: { $0.id == messageId }) {
|
||||
if context.privateChats[convKey]?.contains(where: { $0.id == messageId }) == true { return }
|
||||
for (_, arr) in context.privateChats where arr.contains(where: { $0.id == messageId }) {
|
||||
return
|
||||
}
|
||||
|
||||
let senderName = viewModel.displayNameForNostrPubkey(senderPubkey)
|
||||
let senderName = context.displayNameForNostrPubkey(senderPubkey)
|
||||
let message = BitchatMessage(
|
||||
id: messageId,
|
||||
sender: senderName,
|
||||
@@ -206,22 +358,22 @@ final class ChatPrivateConversationCoordinator {
|
||||
timestamp: messageTimestamp,
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: viewModel.nickname,
|
||||
recipientNickname: context.nickname,
|
||||
senderPeerID: convKey,
|
||||
deliveryStatus: .delivered(to: viewModel.nickname, at: Date())
|
||||
deliveryStatus: .delivered(to: context.nickname, at: Date())
|
||||
)
|
||||
|
||||
if viewModel.privateChats[convKey] == nil {
|
||||
viewModel.privateChats[convKey] = []
|
||||
if context.privateChats[convKey] == nil {
|
||||
context.privateChats[convKey] = []
|
||||
}
|
||||
viewModel.privateChats[convKey]?.append(message)
|
||||
context.privateChats[convKey]?.append(message)
|
||||
|
||||
let isViewing = viewModel.selectedPrivateChatPeer == convKey
|
||||
let wasReadBefore = viewModel.sentReadReceipts.contains(messageId)
|
||||
let isViewing = context.selectedPrivateChatPeer == convKey
|
||||
let wasReadBefore = context.sentReadReceipts.contains(messageId)
|
||||
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
|
||||
let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage
|
||||
if shouldMarkUnread {
|
||||
viewModel.unreadPrivateMessages.insert(convKey)
|
||||
context.unreadPrivateMessages.insert(convKey)
|
||||
}
|
||||
|
||||
if isViewing {
|
||||
@@ -236,18 +388,18 @@ final class ChatPrivateConversationCoordinator {
|
||||
)
|
||||
}
|
||||
|
||||
viewModel.objectWillChange.send()
|
||||
context.notifyUIChanged()
|
||||
}
|
||||
|
||||
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
|
||||
guard let messageID = String(data: payload.data, encoding: .utf8) else { return }
|
||||
|
||||
if let idx = viewModel.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) {
|
||||
viewModel.privateChats[convKey]?[idx].deliveryStatus = .delivered(
|
||||
to: viewModel.displayNameForNostrPubkey(senderPubkey),
|
||||
if let idx = context.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) {
|
||||
context.privateChats[convKey]?[idx].deliveryStatus = .delivered(
|
||||
to: context.displayNameForNostrPubkey(senderPubkey),
|
||||
at: Date()
|
||||
)
|
||||
viewModel.objectWillChange.send()
|
||||
context.notifyUIChanged()
|
||||
SecureLogger.info(
|
||||
"GeoDM: recv DELIVERED for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…",
|
||||
category: .session
|
||||
@@ -260,12 +412,12 @@ final class ChatPrivateConversationCoordinator {
|
||||
func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
|
||||
guard let messageID = String(data: payload.data, encoding: .utf8) else { return }
|
||||
|
||||
if let idx = viewModel.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) {
|
||||
viewModel.privateChats[convKey]?[idx].deliveryStatus = .read(
|
||||
by: viewModel.displayNameForNostrPubkey(senderPubkey),
|
||||
if let idx = context.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) {
|
||||
context.privateChats[convKey]?[idx].deliveryStatus = .read(
|
||||
by: context.displayNameForNostrPubkey(senderPubkey),
|
||||
at: Date()
|
||||
)
|
||||
viewModel.objectWillChange.send()
|
||||
context.notifyUIChanged()
|
||||
SecureLogger.info("GeoDM: recv READ for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", category: .session)
|
||||
} else {
|
||||
SecureLogger.warning("GeoDM: read ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session)
|
||||
@@ -273,19 +425,15 @@ final class ChatPrivateConversationCoordinator {
|
||||
}
|
||||
|
||||
func sendDeliveryAckIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {
|
||||
guard !viewModel.sentGeoDeliveryAcks.contains(messageId) else { return }
|
||||
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge)
|
||||
transport.senderPeerID = viewModel.meshService.myPeerID
|
||||
transport.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubKey, from: id)
|
||||
viewModel.sentGeoDeliveryAcks.insert(messageId)
|
||||
guard !context.sentGeoDeliveryAcks.contains(messageId) else { return }
|
||||
context.sendGeohashDeliveryAck(for: messageId, toRecipientHex: senderPubKey, from: id)
|
||||
context.sentGeoDeliveryAcks.insert(messageId)
|
||||
}
|
||||
|
||||
func sendReadReceiptIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {
|
||||
guard !viewModel.sentReadReceipts.contains(messageId) else { return }
|
||||
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge)
|
||||
transport.senderPeerID = viewModel.meshService.myPeerID
|
||||
transport.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubKey, from: id)
|
||||
viewModel.sentReadReceipts.insert(messageId)
|
||||
guard !context.sentReadReceipts.contains(messageId) else { return }
|
||||
context.sendGeohashReadReceipt(messageId, toRecipientHex: senderPubKey, from: id)
|
||||
context.sentReadReceipts.insert(messageId)
|
||||
}
|
||||
|
||||
func handlePrivateMessage(
|
||||
@@ -315,15 +463,15 @@ final class ChatPrivateConversationCoordinator {
|
||||
return
|
||||
}
|
||||
|
||||
let wasReadBefore = viewModel.sentReadReceipts.contains(messageId)
|
||||
let wasReadBefore = context.sentReadReceipts.contains(messageId)
|
||||
|
||||
var isViewingThisChat = false
|
||||
if viewModel.selectedPrivateChatPeer == targetPeerID {
|
||||
if context.selectedPrivateChatPeer == targetPeerID {
|
||||
isViewingThisChat = true
|
||||
} else if let selectedPeer = viewModel.selectedPrivateChatPeer,
|
||||
let selectedPeerData = viewModel.unifiedPeerService.getPeer(by: selectedPeer),
|
||||
} else if let selectedPeer = context.selectedPrivateChatPeer,
|
||||
let selectedPeerNoiseKey = context.noisePublicKey(for: selectedPeer),
|
||||
let key = actualSenderNoiseKey,
|
||||
selectedPeerData.noisePublicKey == key {
|
||||
selectedPeerNoiseKey == key {
|
||||
isViewingThisChat = true
|
||||
}
|
||||
|
||||
@@ -337,15 +485,15 @@ final class ChatPrivateConversationCoordinator {
|
||||
timestamp: messageTimestamp,
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: viewModel.nickname,
|
||||
recipientNickname: context.nickname,
|
||||
senderPeerID: targetPeerID,
|
||||
deliveryStatus: .delivered(to: viewModel.nickname, at: Date())
|
||||
deliveryStatus: .delivered(to: context.nickname, at: Date())
|
||||
)
|
||||
|
||||
addMessageToPrivateChatsIfNeeded(message, targetPeerID: targetPeerID)
|
||||
mirrorToEphemeralIfNeeded(message, targetPeerID: targetPeerID, key: actualSenderNoiseKey)
|
||||
|
||||
viewModel.sendDeliveryAckViaNostrEmbedded(
|
||||
context.sendDeliveryAckViaNostrEmbedded(
|
||||
message,
|
||||
wasReadBefore: wasReadBefore,
|
||||
senderPubkey: senderPubkey,
|
||||
@@ -372,12 +520,12 @@ final class ChatPrivateConversationCoordinator {
|
||||
)
|
||||
}
|
||||
|
||||
viewModel.objectWillChange.send()
|
||||
context.notifyUIChanged()
|
||||
}
|
||||
|
||||
func handlePrivateMessage(_ message: BitchatMessage) {
|
||||
SecureLogger.debug("📥 handlePrivateMessage called for message from \(message.sender)", category: .session)
|
||||
let senderPeerID = message.senderPeerID ?? viewModel.getPeerIDForNickname(message.sender)
|
||||
let senderPeerID = message.senderPeerID ?? context.getPeerIDForNickname(message.sender)
|
||||
|
||||
guard let peerID = senderPeerID else {
|
||||
SecureLogger.warning("⚠️ Could not get peer ID for sender \(message.sender)", category: .session)
|
||||
@@ -391,22 +539,22 @@ final class ChatPrivateConversationCoordinator {
|
||||
|
||||
migratePrivateChatsIfNeeded(for: peerID, senderNickname: message.sender)
|
||||
|
||||
if peerID.id.count == 16, let peer = viewModel.unifiedPeerService.getPeer(by: peerID) {
|
||||
let stableKeyHex = PeerID(hexData: peer.noisePublicKey)
|
||||
if peerID.id.count == 16, let peerNoiseKey = context.noisePublicKey(for: peerID) {
|
||||
let stableKeyHex = PeerID(hexData: peerNoiseKey)
|
||||
if stableKeyHex != peerID,
|
||||
let nostrMessages = viewModel.privateChats[stableKeyHex],
|
||||
let nostrMessages = context.privateChats[stableKeyHex],
|
||||
!nostrMessages.isEmpty {
|
||||
if viewModel.privateChats[peerID] == nil {
|
||||
viewModel.privateChats[peerID] = []
|
||||
if context.privateChats[peerID] == nil {
|
||||
context.privateChats[peerID] = []
|
||||
}
|
||||
|
||||
let existingMessageIds = Set(viewModel.privateChats[peerID]?.map { $0.id } ?? [])
|
||||
let existingMessageIds = Set(context.privateChats[peerID]?.map { $0.id } ?? [])
|
||||
for nostrMessage in nostrMessages where !existingMessageIds.contains(nostrMessage.id) {
|
||||
viewModel.privateChats[peerID]?.append(nostrMessage)
|
||||
context.privateChats[peerID]?.append(nostrMessage)
|
||||
}
|
||||
|
||||
viewModel.privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
|
||||
viewModel.privateChats.removeValue(forKey: stableKeyHex)
|
||||
context.privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
|
||||
context.privateChats.removeValue(forKey: stableKeyHex)
|
||||
|
||||
SecureLogger.info(
|
||||
"📥 Consolidated \(nostrMessages.count) Nostr messages from stable key to ephemeral peer \(peerID)",
|
||||
@@ -420,20 +568,20 @@ final class ChatPrivateConversationCoordinator {
|
||||
}
|
||||
|
||||
addMessageToPrivateChatsIfNeeded(message, targetPeerID: peerID)
|
||||
let noiseKey = peerID.noiseKey ?? viewModel.unifiedPeerService.getPeer(by: peerID)?.noisePublicKey
|
||||
let noiseKey = peerID.noiseKey ?? context.noisePublicKey(for: peerID)
|
||||
mirrorToEphemeralIfNeeded(message, targetPeerID: peerID, key: noiseKey)
|
||||
|
||||
let isViewing = viewModel.selectedPrivateChatPeer == peerID
|
||||
let isViewing = context.selectedPrivateChatPeer == peerID
|
||||
if isViewing {
|
||||
let receipt = ReadReceipt(
|
||||
originalMessageID: message.id,
|
||||
readerID: viewModel.meshService.myPeerID,
|
||||
readerNickname: viewModel.nickname
|
||||
readerID: context.myPeerID,
|
||||
readerNickname: context.nickname
|
||||
)
|
||||
viewModel.meshService.sendReadReceipt(receipt, to: peerID)
|
||||
viewModel.sentReadReceipts.insert(message.id)
|
||||
context.sendMeshReadReceipt(receipt, to: peerID)
|
||||
context.sentReadReceipts.insert(message.id)
|
||||
} else {
|
||||
viewModel.unreadPrivateMessages.insert(peerID)
|
||||
context.unreadPrivateMessages.insert(peerID)
|
||||
NotificationService.shared.sendPrivateMessageNotification(
|
||||
from: message.sender,
|
||||
message: message.content,
|
||||
@@ -441,48 +589,48 @@ final class ChatPrivateConversationCoordinator {
|
||||
)
|
||||
}
|
||||
|
||||
viewModel.objectWillChange.send()
|
||||
context.notifyUIChanged()
|
||||
}
|
||||
|
||||
func isDuplicateMessage(_ messageId: String, targetPeerID: PeerID) -> Bool {
|
||||
if viewModel.privateChats[targetPeerID]?.contains(where: { $0.id == messageId }) == true {
|
||||
if context.privateChats[targetPeerID]?.contains(where: { $0.id == messageId }) == true {
|
||||
return true
|
||||
}
|
||||
for (_, messages) in viewModel.privateChats where messages.contains(where: { $0.id == messageId }) {
|
||||
for (_, messages) in context.privateChats where messages.contains(where: { $0.id == messageId }) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func addMessageToPrivateChatsIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID) {
|
||||
if viewModel.privateChats[targetPeerID] == nil {
|
||||
viewModel.privateChats[targetPeerID] = []
|
||||
if context.privateChats[targetPeerID] == nil {
|
||||
context.privateChats[targetPeerID] = []
|
||||
}
|
||||
if let idx = viewModel.privateChats[targetPeerID]?.firstIndex(where: { $0.id == message.id }) {
|
||||
viewModel.privateChats[targetPeerID]?[idx] = message
|
||||
if let idx = context.privateChats[targetPeerID]?.firstIndex(where: { $0.id == message.id }) {
|
||||
context.privateChats[targetPeerID]?[idx] = message
|
||||
} else {
|
||||
viewModel.privateChats[targetPeerID]?.append(message)
|
||||
context.privateChats[targetPeerID]?.append(message)
|
||||
}
|
||||
viewModel.privateChatManager.sanitizeChat(for: targetPeerID)
|
||||
context.sanitizeChat(for: targetPeerID)
|
||||
}
|
||||
|
||||
func mirrorToEphemeralIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID, key: Data?) {
|
||||
guard let key,
|
||||
let ephemeralPeerID = viewModel.unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID,
|
||||
let ephemeralPeerID = context.ephemeralPeerID(forNoiseKey: key),
|
||||
ephemeralPeerID != targetPeerID
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
if viewModel.privateChats[ephemeralPeerID] == nil {
|
||||
viewModel.privateChats[ephemeralPeerID] = []
|
||||
if context.privateChats[ephemeralPeerID] == nil {
|
||||
context.privateChats[ephemeralPeerID] = []
|
||||
}
|
||||
if let idx = viewModel.privateChats[ephemeralPeerID]?.firstIndex(where: { $0.id == message.id }) {
|
||||
viewModel.privateChats[ephemeralPeerID]?[idx] = message
|
||||
if let idx = context.privateChats[ephemeralPeerID]?.firstIndex(where: { $0.id == message.id }) {
|
||||
context.privateChats[ephemeralPeerID]?[idx] = message
|
||||
} else {
|
||||
viewModel.privateChats[ephemeralPeerID]?.append(message)
|
||||
context.privateChats[ephemeralPeerID]?.append(message)
|
||||
}
|
||||
viewModel.privateChatManager.sanitizeChat(for: ephemeralPeerID)
|
||||
context.sanitizeChat(for: ephemeralPeerID)
|
||||
}
|
||||
|
||||
func handleViewingThisChat(
|
||||
@@ -491,27 +639,25 @@ final class ChatPrivateConversationCoordinator {
|
||||
key: Data?,
|
||||
senderPubkey: String
|
||||
) {
|
||||
viewModel.unreadPrivateMessages.remove(targetPeerID)
|
||||
context.unreadPrivateMessages.remove(targetPeerID)
|
||||
if let key,
|
||||
let ephemeralPeerID = viewModel.unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID {
|
||||
viewModel.unreadPrivateMessages.remove(ephemeralPeerID)
|
||||
let ephemeralPeerID = context.ephemeralPeerID(forNoiseKey: key) {
|
||||
context.unreadPrivateMessages.remove(ephemeralPeerID)
|
||||
}
|
||||
guard !viewModel.sentReadReceipts.contains(message.id) else { return }
|
||||
guard !context.sentReadReceipts.contains(message.id) else { return }
|
||||
|
||||
if let key {
|
||||
let receipt = ReadReceipt(
|
||||
originalMessageID: message.id,
|
||||
readerID: viewModel.meshService.myPeerID,
|
||||
readerNickname: viewModel.nickname
|
||||
readerID: context.myPeerID,
|
||||
readerNickname: context.nickname
|
||||
)
|
||||
SecureLogger.debug("Viewing chat; sending READ ack for \(message.id.prefix(8))… via router", category: .session)
|
||||
viewModel.messageRouter.sendReadReceipt(receipt, to: PeerID(hexData: key))
|
||||
viewModel.sentReadReceipts.insert(message.id)
|
||||
} else if let identity = try? viewModel.idBridge.getCurrentNostrIdentity() {
|
||||
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge)
|
||||
transport.senderPeerID = viewModel.meshService.myPeerID
|
||||
transport.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: identity)
|
||||
viewModel.sentReadReceipts.insert(message.id)
|
||||
context.routeReadReceipt(receipt, to: PeerID(hexData: key))
|
||||
context.sentReadReceipts.insert(message.id)
|
||||
} else if let identity = context.currentNostrIdentity() {
|
||||
context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity)
|
||||
context.sentReadReceipts.insert(message.id)
|
||||
SecureLogger.debug(
|
||||
"Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…",
|
||||
category: .session
|
||||
@@ -529,11 +675,11 @@ final class ChatPrivateConversationCoordinator {
|
||||
) {
|
||||
guard shouldMarkAsUnread else { return }
|
||||
|
||||
viewModel.unreadPrivateMessages.insert(targetPeerID)
|
||||
context.unreadPrivateMessages.insert(targetPeerID)
|
||||
if let key,
|
||||
let ephemeralPeerID = viewModel.unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID,
|
||||
let ephemeralPeerID = context.ephemeralPeerID(forNoiseKey: key),
|
||||
ephemeralPeerID != targetPeerID {
|
||||
viewModel.unreadPrivateMessages.insert(ephemeralPeerID)
|
||||
context.unreadPrivateMessages.insert(ephemeralPeerID)
|
||||
}
|
||||
if isRecentMessage {
|
||||
NotificationService.shared.sendPrivateMessageNotification(
|
||||
@@ -554,7 +700,7 @@ final class ChatPrivateConversationCoordinator {
|
||||
SecureLogger.info("📝 Received Nostr npub in favorite notification: \(nostrPubkey ?? "none")", category: .session)
|
||||
}
|
||||
|
||||
let noiseKey = peerID.noiseKey ?? viewModel.unifiedPeerService.getPeer(by: peerID)?.noisePublicKey
|
||||
let noiseKey = peerID.noiseKey ?? context.noisePublicKey(for: peerID)
|
||||
guard let finalNoiseKey = noiseKey else {
|
||||
SecureLogger.warning("⚠️ Cannot get Noise key for peer \(peerID)", category: .session)
|
||||
return
|
||||
@@ -577,7 +723,7 @@ final class ChatPrivateConversationCoordinator {
|
||||
|
||||
if prior != isFavorite {
|
||||
let action = isFavorite ? "favorited" : "unfavorited"
|
||||
viewModel.addMeshOnlySystemMessage("\(senderNickname) \(action) you")
|
||||
context.addMeshOnlySystemMessage("\(senderNickname) \(action) you")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -606,15 +752,15 @@ final class ChatPrivateConversationCoordinator {
|
||||
}
|
||||
|
||||
func migratePrivateChatsIfNeeded(for peerID: PeerID, senderNickname: String) {
|
||||
let currentFingerprint = viewModel.getFingerprint(for: peerID)
|
||||
let currentFingerprint = context.getFingerprint(for: peerID)
|
||||
|
||||
if viewModel.privateChats[peerID] == nil || viewModel.privateChats[peerID]?.isEmpty == true {
|
||||
if context.privateChats[peerID] == nil || context.privateChats[peerID]?.isEmpty == true {
|
||||
var migratedMessages: [BitchatMessage] = []
|
||||
var oldPeerIDsToRemove: [PeerID] = []
|
||||
let cutoffTime = Date().addingTimeInterval(-TransportConfig.uiMigrationCutoffSeconds)
|
||||
|
||||
for (oldPeerID, messages) in viewModel.privateChats where oldPeerID != peerID {
|
||||
let oldFingerprint = viewModel.peerIDToPublicKeyFingerprint[oldPeerID]
|
||||
for (oldPeerID, messages) in context.privateChats where oldPeerID != peerID {
|
||||
let oldFingerprint = context.storedFingerprint(for: oldPeerID)
|
||||
let recentMessages = messages.filter { $0.timestamp > cutoffTime }
|
||||
guard !recentMessages.isEmpty else { continue }
|
||||
|
||||
@@ -637,8 +783,8 @@ final class ChatPrivateConversationCoordinator {
|
||||
)
|
||||
} else if currentFingerprint == nil || oldFingerprint == nil {
|
||||
let isRelevantChat = recentMessages.contains { msg in
|
||||
(msg.sender == senderNickname && msg.sender != viewModel.nickname)
|
||||
|| (msg.sender == viewModel.nickname && msg.recipientNickname == senderNickname)
|
||||
(msg.sender == senderNickname && msg.sender != context.nickname)
|
||||
|| (msg.sender == context.nickname && msg.recipientNickname == senderNickname)
|
||||
}
|
||||
|
||||
if isRelevantChat {
|
||||
@@ -656,27 +802,27 @@ final class ChatPrivateConversationCoordinator {
|
||||
}
|
||||
|
||||
if !oldPeerIDsToRemove.isEmpty {
|
||||
let needsSelectedUpdate = oldPeerIDsToRemove.contains { viewModel.selectedPrivateChatPeer == $0 }
|
||||
let needsSelectedUpdate = oldPeerIDsToRemove.contains { context.selectedPrivateChatPeer == $0 }
|
||||
|
||||
for oldID in oldPeerIDsToRemove {
|
||||
viewModel.privateChats.removeValue(forKey: oldID)
|
||||
viewModel.unreadPrivateMessages.remove(oldID)
|
||||
viewModel.peerIdentityStore.setFingerprint(nil, for: oldID)
|
||||
context.privateChats.removeValue(forKey: oldID)
|
||||
context.unreadPrivateMessages.remove(oldID)
|
||||
context.clearStoredFingerprint(for: oldID)
|
||||
}
|
||||
|
||||
if needsSelectedUpdate {
|
||||
viewModel.selectedPrivateChatPeer = peerID
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
}
|
||||
}
|
||||
|
||||
if !migratedMessages.isEmpty {
|
||||
if viewModel.privateChats[peerID] == nil {
|
||||
viewModel.privateChats[peerID] = []
|
||||
if context.privateChats[peerID] == nil {
|
||||
context.privateChats[peerID] = []
|
||||
}
|
||||
viewModel.privateChats[peerID]?.append(contentsOf: migratedMessages)
|
||||
viewModel.privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
|
||||
viewModel.privateChatManager.sanitizeChat(for: peerID)
|
||||
viewModel.objectWillChange.send()
|
||||
context.privateChats[peerID]?.append(contentsOf: migratedMessages)
|
||||
context.privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
|
||||
context.sanitizeChat(for: peerID)
|
||||
context.notifyUIChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -686,26 +832,26 @@ final class ChatPrivateConversationCoordinator {
|
||||
|
||||
if let hexKey = Data(hexString: peerID.id) {
|
||||
noiseKey = hexKey
|
||||
} else if let peer = viewModel.unifiedPeerService.getPeer(by: peerID) {
|
||||
noiseKey = peer.noisePublicKey
|
||||
} else if let peerNoiseKey = context.noisePublicKey(for: peerID) {
|
||||
noiseKey = peerNoiseKey
|
||||
}
|
||||
|
||||
if viewModel.meshService.isPeerConnected(peerID) {
|
||||
viewModel.messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||
if context.isPeerConnected(peerID) {
|
||||
context.routeFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||
SecureLogger.debug("📤 Sent favorite notification via BLE to \(peerID)", category: .session)
|
||||
} else if let key = noiseKey {
|
||||
viewModel.messageRouter.sendFavoriteNotification(to: PeerID(hexData: key), isFavorite: isFavorite)
|
||||
context.routeFavoriteNotification(to: PeerID(hexData: key), isFavorite: isFavorite)
|
||||
} else {
|
||||
SecureLogger.warning("⚠️ Cannot send favorite notification - peer not connected and no Nostr pubkey", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
func isMessageBlocked(_ message: BitchatMessage) -> Bool {
|
||||
if let peerID = message.senderPeerID ?? viewModel.getPeerIDForNickname(message.sender) {
|
||||
if viewModel.isPeerBlocked(peerID) { return true }
|
||||
if let peerID = message.senderPeerID ?? context.getPeerIDForNickname(message.sender) {
|
||||
if context.isPeerBlocked(peerID) { return true }
|
||||
if peerID.isGeoChat || peerID.isGeoDM,
|
||||
let full = viewModel.nostrKeyMapping[peerID]?.lowercased(),
|
||||
viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: full) {
|
||||
let full = context.nostrKeyMapping[peerID]?.lowercased(),
|
||||
context.isNostrBlocked(pubkeyHexLowercased: full) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,16 +7,190 @@ import SwiftUI
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
/// The narrow surface `ChatPublicConversationCoordinator` needs from its owner.
|
||||
///
|
||||
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
|
||||
/// minimal context it actually uses instead of holding an `unowned` back-ref
|
||||
/// to the whole `ChatViewModel`. This keeps the coordinator independently
|
||||
/// testable (see `ChatPublicConversationCoordinatorContextTests`) and makes
|
||||
/// its true dependencies explicit. The surface is intentionally large — it
|
||||
/// documents the coordinator's real coupling to the public timeline, the
|
||||
/// conversation stores, geohash participants, and the inbound public message
|
||||
/// pipeline.
|
||||
@MainActor
|
||||
protocol ChatPublicConversationContext: AnyObject {
|
||||
// MARK: Channel & visible timeline state
|
||||
var messages: [BitchatMessage] { get set }
|
||||
var activeChannel: ChannelID { get }
|
||||
var currentGeohash: String? { get }
|
||||
var nickname: String { get }
|
||||
var myPeerID: PeerID { get }
|
||||
var isBatchingPublic: Bool { get set }
|
||||
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
|
||||
func notifyUIChanged()
|
||||
func trimMessagesIfNeeded()
|
||||
|
||||
// MARK: Public timeline store
|
||||
func timelineMessages(for channel: ChannelID) -> [BitchatMessage]
|
||||
func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID)
|
||||
func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool
|
||||
func removeTimelineMessage(withID id: String) -> BitchatMessage?
|
||||
func removeGeohashTimelineMessages(in geohash: String, where predicate: (BitchatMessage) -> Bool)
|
||||
func clearTimeline(for channel: ChannelID)
|
||||
func timelineGeohashKeys() -> [String]
|
||||
/// Queues a system message for the next geohash channel visit.
|
||||
func queueGeohashSystemMessage(_ content: String)
|
||||
|
||||
// MARK: Conversation stores
|
||||
func setConversationActiveChannel(_ channel: ChannelID)
|
||||
func replaceConversationMessages(_ messages: [BitchatMessage], for channelID: ChannelID)
|
||||
func replaceConversationMessages(_ messages: [BitchatMessage], for conversationID: ConversationID)
|
||||
func synchronizePrivateConversationStore()
|
||||
func synchronizeConversationSelectionStore()
|
||||
|
||||
// MARK: Private chats (block cleanup & message removal)
|
||||
var privateChats: [PeerID: [BitchatMessage]] { get set }
|
||||
var unreadPrivateMessages: Set<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
|
||||
final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
||||
private unowned let viewModel: ChatViewModel
|
||||
private unowned let context: any ChatPublicConversationContext
|
||||
|
||||
init(viewModel: ChatViewModel) {
|
||||
self.viewModel = viewModel
|
||||
init(context: any ChatPublicConversationContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
func visibleGeohashPeople() -> [GeoPerson] {
|
||||
viewModel.participantTracker.getVisiblePeople()
|
||||
context.visibleGeoPeople()
|
||||
}
|
||||
|
||||
func getVisibleGeoParticipants() -> [CommandGeoParticipant] {
|
||||
@@ -24,7 +198,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
||||
}
|
||||
|
||||
func geohashParticipantCount(for geohash: String) -> Int {
|
||||
viewModel.participantTracker.participantCount(for: geohash)
|
||||
context.geoParticipantCount(for: geohash)
|
||||
}
|
||||
|
||||
func displayNameForPubkey(_ pubkeyHex: String) -> String {
|
||||
@@ -32,49 +206,49 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
||||
}
|
||||
|
||||
func isBlocked(_ pubkeyHexLowercased: String) -> Bool {
|
||||
viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased)
|
||||
context.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased)
|
||||
}
|
||||
|
||||
func isGeohashUserBlocked(pubkeyHexLowercased: String) -> Bool {
|
||||
viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased)
|
||||
context.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased)
|
||||
}
|
||||
|
||||
func blockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
|
||||
let hex = pubkeyHexLowercased.lowercased()
|
||||
viewModel.identityManager.setNostrBlocked(hex, isBlocked: true)
|
||||
viewModel.participantTracker.removeParticipant(pubkeyHex: hex)
|
||||
context.setNostrBlocked(hex, isBlocked: true)
|
||||
context.removeGeoParticipant(pubkeyHex: hex)
|
||||
|
||||
if let gh = viewModel.currentGeohash {
|
||||
let predicate: (BitchatMessage) -> Bool = { [unowned viewModel] message in
|
||||
if let gh = context.currentGeohash {
|
||||
let predicate: (BitchatMessage) -> Bool = { [unowned context] message in
|
||||
guard let senderPeerID = message.senderPeerID,
|
||||
senderPeerID.isGeoDM || senderPeerID.isGeoChat else {
|
||||
return false
|
||||
}
|
||||
if let full = viewModel.nostrKeyMapping[senderPeerID]?.lowercased() {
|
||||
if let full = context.nostrKeyMapping[senderPeerID]?.lowercased() {
|
||||
return full == hex
|
||||
}
|
||||
return false
|
||||
}
|
||||
viewModel.timelineStore.removeMessages(in: gh, where: predicate)
|
||||
context.removeGeohashTimelineMessages(in: gh, where: predicate)
|
||||
synchronizePublicConversationStore(forGeohash: gh)
|
||||
if case .location = viewModel.activeChannel {
|
||||
viewModel.messages.removeAll(where: predicate)
|
||||
if case .location = context.activeChannel {
|
||||
context.messages.removeAll(where: predicate)
|
||||
}
|
||||
}
|
||||
|
||||
let conversationPeerID = PeerID(nostr_: hex)
|
||||
if viewModel.privateChats[conversationPeerID] != nil {
|
||||
var privateChats = viewModel.privateChats
|
||||
if context.privateChats[conversationPeerID] != nil {
|
||||
var privateChats = context.privateChats
|
||||
privateChats.removeValue(forKey: conversationPeerID)
|
||||
viewModel.privateChats = privateChats
|
||||
context.privateChats = privateChats
|
||||
|
||||
var unread = viewModel.unreadPrivateMessages
|
||||
var unread = context.unreadPrivateMessages
|
||||
unread.remove(conversationPeerID)
|
||||
viewModel.unreadPrivateMessages = unread
|
||||
context.unreadPrivateMessages = unread
|
||||
}
|
||||
|
||||
for (key, value) in viewModel.nostrKeyMapping where value.lowercased() == hex {
|
||||
viewModel.nostrKeyMapping.removeValue(forKey: key)
|
||||
for (key, value) in context.nostrKeyMapping where value.lowercased() == hex {
|
||||
context.nostrKeyMapping.removeValue(forKey: key)
|
||||
}
|
||||
|
||||
addSystemMessage(
|
||||
@@ -90,7 +264,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
||||
}
|
||||
|
||||
func unblockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
|
||||
viewModel.identityManager.setNostrBlocked(pubkeyHexLowercased, isBlocked: false)
|
||||
context.setNostrBlocked(pubkeyHexLowercased, isBlocked: false)
|
||||
addSystemMessage(
|
||||
String(
|
||||
format: String(
|
||||
@@ -105,24 +279,24 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
||||
|
||||
func displayNameForNostrPubkey(_ pubkeyHex: String) -> String {
|
||||
let suffix = String(pubkeyHex.suffix(4))
|
||||
if let geohash = viewModel.currentGeohash,
|
||||
let myGeoIdentity = try? viewModel.idBridge.deriveIdentity(forGeohash: geohash),
|
||||
if let geohash = context.currentGeohash,
|
||||
let myGeoIdentity = try? context.deriveNostrIdentity(forGeohash: geohash),
|
||||
myGeoIdentity.publicKeyHex.lowercased() == pubkeyHex.lowercased() {
|
||||
return viewModel.nickname + "#" + suffix
|
||||
return context.nickname + "#" + suffix
|
||||
}
|
||||
if let nick = viewModel.geoNicknames[pubkeyHex.lowercased()], !nick.isEmpty {
|
||||
if let nick = context.geoNicknames[pubkeyHex.lowercased()], !nick.isEmpty {
|
||||
return nick + "#" + suffix
|
||||
}
|
||||
return "anon#\(suffix)"
|
||||
}
|
||||
|
||||
func currentPublicSender() -> (name: String, peerID: PeerID) {
|
||||
var displaySender = viewModel.nickname
|
||||
var senderPeerID = viewModel.meshService.myPeerID
|
||||
if case .location(let channel) = viewModel.activeChannel,
|
||||
let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
|
||||
var displaySender = context.nickname
|
||||
var senderPeerID = context.myPeerID
|
||||
if case .location(let channel) = context.activeChannel,
|
||||
let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
|
||||
let suffix = String(identity.publicKeyHex.suffix(4))
|
||||
displaySender = viewModel.nickname + "#" + suffix
|
||||
displaySender = context.nickname + "#" + suffix
|
||||
senderPeerID = PeerID(nostr: identity.publicKeyHex)
|
||||
}
|
||||
return (displaySender, senderPeerID)
|
||||
@@ -131,16 +305,16 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
||||
func removeMessage(withID messageID: String, cleanupFile: Bool = false) {
|
||||
var removedMessage: BitchatMessage?
|
||||
|
||||
if let index = viewModel.messages.firstIndex(where: { $0.id == messageID }) {
|
||||
removedMessage = viewModel.messages.remove(at: index)
|
||||
if let index = context.messages.firstIndex(where: { $0.id == messageID }) {
|
||||
removedMessage = context.messages.remove(at: index)
|
||||
}
|
||||
|
||||
if let storeRemoved = viewModel.timelineStore.removeMessage(withID: messageID) {
|
||||
if let storeRemoved = context.removeTimelineMessage(withID: messageID) {
|
||||
removedMessage = removedMessage ?? storeRemoved
|
||||
synchronizeAllPublicConversationStores()
|
||||
}
|
||||
|
||||
var chats = viewModel.privateChats
|
||||
var chats = context.privateChats
|
||||
for (peerID, items) in chats {
|
||||
let filtered = items.filter { $0.id != messageID }
|
||||
if filtered.count != items.count {
|
||||
@@ -154,55 +328,55 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
||||
}
|
||||
}
|
||||
}
|
||||
viewModel.privateChats = chats
|
||||
context.privateChats = chats
|
||||
|
||||
if cleanupFile, let removedMessage {
|
||||
viewModel.cleanupLocalFile(forMessage: removedMessage)
|
||||
context.cleanupLocalFile(forMessage: removedMessage)
|
||||
}
|
||||
|
||||
viewModel.objectWillChange.send()
|
||||
context.notifyUIChanged()
|
||||
}
|
||||
|
||||
func initializeConversationStore() {
|
||||
viewModel.conversationStore.setActiveChannel(viewModel.activeChannel)
|
||||
synchronizePublicConversationStore(for: viewModel.activeChannel)
|
||||
viewModel.synchronizePrivateConversationStore()
|
||||
viewModel.synchronizeConversationSelectionStore()
|
||||
context.setConversationActiveChannel(context.activeChannel)
|
||||
synchronizePublicConversationStore(for: context.activeChannel)
|
||||
context.synchronizePrivateConversationStore()
|
||||
context.synchronizeConversationSelectionStore()
|
||||
}
|
||||
|
||||
func synchronizePublicConversationStore(for channel: ChannelID) {
|
||||
let publicMessages = viewModel.timelineStore.messages(for: channel)
|
||||
viewModel.conversationStore.replaceMessages(publicMessages, for: channel)
|
||||
if channel == viewModel.activeChannel {
|
||||
viewModel.conversationStore.setActiveChannel(viewModel.activeChannel)
|
||||
let publicMessages = context.timelineMessages(for: channel)
|
||||
context.replaceConversationMessages(publicMessages, for: channel)
|
||||
if channel == context.activeChannel {
|
||||
context.setConversationActiveChannel(context.activeChannel)
|
||||
}
|
||||
}
|
||||
|
||||
func synchronizePublicConversationStore(forGeohash geohash: String) {
|
||||
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))
|
||||
let publicMessages = viewModel.timelineStore.messages(for: channel)
|
||||
viewModel.conversationStore.replaceMessages(publicMessages, for: .geohash(geohash.lowercased()))
|
||||
let publicMessages = context.timelineMessages(for: channel)
|
||||
context.replaceConversationMessages(publicMessages, for: .geohash(geohash.lowercased()))
|
||||
}
|
||||
|
||||
func synchronizeAllPublicConversationStores() {
|
||||
synchronizePublicConversationStore(for: .mesh)
|
||||
for geohash in viewModel.timelineStore.geohashKeys() {
|
||||
for geohash in context.timelineGeohashKeys() {
|
||||
synchronizePublicConversationStore(forGeohash: geohash)
|
||||
}
|
||||
}
|
||||
|
||||
func refreshVisibleMessages(from channel: ChannelID? = nil) {
|
||||
let target = channel ?? viewModel.activeChannel
|
||||
viewModel.messages = viewModel.timelineStore.messages(for: target)
|
||||
viewModel.conversationStore.replaceMessages(viewModel.messages, for: target)
|
||||
if target == viewModel.activeChannel {
|
||||
viewModel.conversationStore.setActiveChannel(viewModel.activeChannel)
|
||||
let target = channel ?? context.activeChannel
|
||||
context.messages = context.timelineMessages(for: target)
|
||||
context.replaceConversationMessages(context.messages, for: target)
|
||||
if target == context.activeChannel {
|
||||
context.setConversationActiveChannel(context.activeChannel)
|
||||
}
|
||||
}
|
||||
|
||||
func clearCurrentPublicTimeline() {
|
||||
viewModel.messages.removeAll()
|
||||
viewModel.timelineStore.clear(channel: viewModel.activeChannel)
|
||||
context.messages.removeAll()
|
||||
context.clearTimeline(for: context.activeChannel)
|
||||
|
||||
Task.detached(priority: .utility) {
|
||||
do {
|
||||
@@ -242,7 +416,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
||||
timestamp: timestamp,
|
||||
isRelay: false
|
||||
)
|
||||
viewModel.messages.append(systemMessage)
|
||||
context.messages.append(systemMessage)
|
||||
}
|
||||
|
||||
func addMeshOnlySystemMessage(_ content: String) {
|
||||
@@ -252,11 +426,11 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
||||
timestamp: Date(),
|
||||
isRelay: false
|
||||
)
|
||||
viewModel.timelineStore.append(systemMessage, to: .mesh)
|
||||
context.appendTimelineMessage(systemMessage, to: .mesh)
|
||||
synchronizePublicConversationStore(for: .mesh)
|
||||
refreshVisibleMessages()
|
||||
viewModel.trimMessagesIfNeeded()
|
||||
viewModel.objectWillChange.send()
|
||||
context.trimMessagesIfNeeded()
|
||||
context.notifyUIChanged()
|
||||
}
|
||||
|
||||
func addPublicSystemMessage(_ content: String) {
|
||||
@@ -266,34 +440,34 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
||||
timestamp: Date(),
|
||||
isRelay: false
|
||||
)
|
||||
viewModel.timelineStore.append(systemMessage, to: viewModel.activeChannel)
|
||||
refreshVisibleMessages(from: viewModel.activeChannel)
|
||||
let contentKey = viewModel.deduplicationService.normalizedContentKey(systemMessage.content)
|
||||
viewModel.deduplicationService.recordContentKey(contentKey, timestamp: systemMessage.timestamp)
|
||||
viewModel.trimMessagesIfNeeded()
|
||||
viewModel.objectWillChange.send()
|
||||
context.appendTimelineMessage(systemMessage, to: context.activeChannel)
|
||||
refreshVisibleMessages(from: context.activeChannel)
|
||||
let contentKey = context.normalizedContentKey(systemMessage.content)
|
||||
context.recordContentKey(contentKey, timestamp: systemMessage.timestamp)
|
||||
context.trimMessagesIfNeeded()
|
||||
context.notifyUIChanged()
|
||||
}
|
||||
|
||||
func addGeohashOnlySystemMessage(_ content: String) {
|
||||
if case .location = viewModel.activeChannel {
|
||||
if case .location = context.activeChannel {
|
||||
addPublicSystemMessage(content)
|
||||
} else {
|
||||
viewModel.timelineStore.queueGeohashSystemMessage(content)
|
||||
context.queueGeohashSystemMessage(content)
|
||||
}
|
||||
}
|
||||
|
||||
func sendPublicRaw(_ content: String) {
|
||||
if case .location(let channel) = viewModel.activeChannel {
|
||||
Task { @MainActor [weak viewModel] in
|
||||
guard let viewModel else { return }
|
||||
if case .location(let channel) = context.activeChannel {
|
||||
Task { @MainActor [weak context] in
|
||||
guard let context else { return }
|
||||
do {
|
||||
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash)
|
||||
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
|
||||
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
||||
content: content,
|
||||
geohash: channel.geohash,
|
||||
senderIdentity: identity,
|
||||
nickname: viewModel.nickname,
|
||||
teleported: viewModel.locationManager.teleported
|
||||
nickname: context.nickname,
|
||||
teleported: context.isTeleported
|
||||
)
|
||||
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5)
|
||||
if targetRelays.isEmpty {
|
||||
@@ -308,7 +482,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
||||
return
|
||||
}
|
||||
|
||||
viewModel.meshService.sendMessage(
|
||||
context.sendMeshMessage(
|
||||
content,
|
||||
mentions: [],
|
||||
messageID: UUID().uuidString,
|
||||
@@ -317,15 +491,15 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
||||
}
|
||||
|
||||
func handlePublicMessage(_ message: BitchatMessage) {
|
||||
let finalMessage = viewModel.processActionMessage(message)
|
||||
if viewModel.isMessageBlocked(finalMessage) { return }
|
||||
let finalMessage = context.processActionMessage(message)
|
||||
if context.isMessageBlocked(finalMessage) { return }
|
||||
|
||||
let isGeo = finalMessage.senderPeerID?.isGeoChat == true
|
||||
let shouldRateLimit = finalMessage.sender != "system" || finalMessage.senderPeerID != nil
|
||||
if shouldRateLimit {
|
||||
let senderKey = normalizedSenderKey(for: finalMessage)
|
||||
let contentKey = viewModel.deduplicationService.normalizedContentKey(finalMessage.content)
|
||||
if !viewModel.publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey) {
|
||||
let contentKey = context.normalizedContentKey(finalMessage.content)
|
||||
if !context.allowPublicMessage(senderKey: senderKey, contentKey: contentKey) {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -333,19 +507,19 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
||||
if finalMessage.sender != "system" && finalMessage.content.count > 16000 { return }
|
||||
|
||||
if !isGeo && finalMessage.sender != "system" {
|
||||
viewModel.timelineStore.append(finalMessage, to: .mesh)
|
||||
context.appendTimelineMessage(finalMessage, to: .mesh)
|
||||
synchronizePublicConversationStore(for: .mesh)
|
||||
}
|
||||
|
||||
if isGeo && finalMessage.sender != "system",
|
||||
let geohash = viewModel.currentGeohash,
|
||||
viewModel.timelineStore.appendIfAbsent(finalMessage, toGeohash: geohash) {
|
||||
let geohash = context.currentGeohash,
|
||||
context.appendGeohashMessageIfAbsent(finalMessage, toGeohash: geohash) {
|
||||
synchronizePublicConversationStore(forGeohash: geohash)
|
||||
}
|
||||
|
||||
let isSystem = finalMessage.sender == "system"
|
||||
let channelMatches: Bool = {
|
||||
switch viewModel.activeChannel {
|
||||
switch context.activeChannel {
|
||||
case .mesh: return !isGeo || isSystem
|
||||
case .location: return isGeo || isSystem
|
||||
}
|
||||
@@ -354,22 +528,22 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
||||
guard channelMatches else { return }
|
||||
|
||||
if !finalMessage.content.trimmed.isEmpty,
|
||||
!viewModel.messages.contains(where: { $0.id == finalMessage.id }) {
|
||||
viewModel.publicMessagePipeline.enqueue(finalMessage)
|
||||
!context.messages.contains(where: { $0.id == finalMessage.id }) {
|
||||
context.enqueuePublicMessage(finalMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func checkForMentions(_ message: BitchatMessage) {
|
||||
var myTokens: Set<String> = [viewModel.nickname]
|
||||
let meshPeers = viewModel.meshService.getPeerNicknames()
|
||||
let collisions = meshPeers.values.filter { $0.hasPrefix(viewModel.nickname + "#") }
|
||||
var myTokens: Set<String> = [context.nickname]
|
||||
let meshPeers = context.meshPeerNicknames()
|
||||
let collisions = meshPeers.values.filter { $0.hasPrefix(context.nickname + "#") }
|
||||
if !collisions.isEmpty {
|
||||
let suffix = "#" + String(viewModel.meshService.myPeerID.id.prefix(4))
|
||||
myTokens = [viewModel.nickname + suffix]
|
||||
let suffix = "#" + String(context.myPeerID.id.prefix(4))
|
||||
myTokens = [context.nickname + suffix]
|
||||
}
|
||||
let isMentioned = message.mentions?.contains(where: myTokens.contains) ?? false
|
||||
|
||||
if isMentioned && message.sender != viewModel.nickname {
|
||||
if isMentioned && message.sender != context.nickname {
|
||||
SecureLogger.info("🔔 Mention from \(message.sender)", category: .session)
|
||||
NotificationService.shared.sendMentionNotification(from: message.sender, message: message.content)
|
||||
}
|
||||
@@ -379,11 +553,11 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
||||
#if os(iOS)
|
||||
guard UIApplication.shared.applicationState == .active else { return }
|
||||
|
||||
var tokens: [String] = [viewModel.nickname]
|
||||
switch viewModel.activeChannel {
|
||||
var tokens: [String] = [context.nickname]
|
||||
switch context.activeChannel {
|
||||
case .location(let channel):
|
||||
if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
|
||||
tokens.append(viewModel.nickname + "#" + String(identity.publicKeyHex.suffix(4)))
|
||||
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
|
||||
tokens.append(context.nickname + "#" + String(identity.publicKeyHex.suffix(4)))
|
||||
}
|
||||
case .mesh:
|
||||
break
|
||||
@@ -394,7 +568,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
||||
let isHugForMe = message.content.contains("🫂") && hugsMe
|
||||
let isSlapForMe = message.content.contains("🐟") && slapsMe
|
||||
|
||||
if isHugForMe && message.sender != viewModel.nickname {
|
||||
if isHugForMe && message.sender != context.nickname {
|
||||
let impactFeedback = UIImpactFeedbackGenerator(style: .medium)
|
||||
impactFeedback.prepare()
|
||||
|
||||
@@ -405,7 +579,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
||||
impactFeedback.impactOccurred()
|
||||
}
|
||||
}
|
||||
} else if isSlapForMe && message.sender != viewModel.nickname {
|
||||
} else if isSlapForMe && message.sender != context.nickname {
|
||||
let impactFeedback = UIImpactFeedbackGenerator(style: .heavy)
|
||||
impactFeedback.prepare()
|
||||
impactFeedback.impactOccurred()
|
||||
@@ -414,35 +588,35 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
||||
}
|
||||
|
||||
func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage] {
|
||||
viewModel.messages
|
||||
context.messages
|
||||
}
|
||||
|
||||
func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage]) {
|
||||
viewModel.messages = messages
|
||||
context.messages = messages
|
||||
}
|
||||
|
||||
func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String {
|
||||
viewModel.deduplicationService.normalizedContentKey(content)
|
||||
context.normalizedContentKey(content)
|
||||
}
|
||||
|
||||
func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date? {
|
||||
viewModel.deduplicationService.contentTimestamp(forKey: key)
|
||||
context.contentTimestamp(forKey: key)
|
||||
}
|
||||
|
||||
func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) {
|
||||
viewModel.deduplicationService.recordContentKey(key, timestamp: timestamp)
|
||||
context.recordContentKey(key, timestamp: timestamp)
|
||||
}
|
||||
|
||||
func pipelineTrimMessages(_ pipeline: PublicMessagePipeline) {
|
||||
viewModel.trimMessagesIfNeeded()
|
||||
context.trimMessagesIfNeeded()
|
||||
}
|
||||
|
||||
func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage) {
|
||||
_ = viewModel.formatMessageAsText(message, colorScheme: viewModel.currentColorScheme)
|
||||
context.prewarmMessageFormatting(message)
|
||||
}
|
||||
|
||||
func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) {
|
||||
viewModel.isBatchingPublic = isBatching
|
||||
context.isBatchingPublic = isBatching
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,10 +624,10 @@ private extension ChatPublicConversationCoordinator {
|
||||
func normalizedSenderKey(for message: BitchatMessage) -> String {
|
||||
if let senderPeerID = message.senderPeerID {
|
||||
if senderPeerID.isGeoChat || senderPeerID.isGeoDM {
|
||||
let full = (viewModel.nostrKeyMapping[senderPeerID] ?? senderPeerID.bare).lowercased()
|
||||
let full = (context.nostrKeyMapping[senderPeerID] ?? senderPeerID.bare).lowercased()
|
||||
return "nostr:" + full
|
||||
} else if senderPeerID.id.count == 16,
|
||||
let full = viewModel.cachedStablePeerID(for: senderPeerID)?.id.lowercased() {
|
||||
let full = context.cachedStablePeerID(for: senderPeerID)?.id.lowercased() {
|
||||
return "noise:" + full
|
||||
} else {
|
||||
return "mesh:" + senderPeerID.id.lowercased()
|
||||
|
||||
@@ -66,7 +66,7 @@ final class ChatVerificationCoordinator {
|
||||
let noiseService = viewModel.meshService.getNoiseService()
|
||||
|
||||
noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in
|
||||
DispatchQueue.main.async {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security)
|
||||
@@ -103,7 +103,7 @@ final class ChatVerificationCoordinator {
|
||||
}
|
||||
|
||||
noiseService.onHandshakeRequired = { [weak self] peerID in
|
||||
DispatchQueue.main.async {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.viewModel.peerIdentityStore.setEncryptionStatus(.noiseHandshaking, for: peerID)
|
||||
self.viewModel.invalidateEncryptionCache(for: peerID)
|
||||
|
||||
@@ -152,11 +152,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
private lazy var peerListCoordinator = ChatPeerListCoordinator(viewModel: self)
|
||||
private lazy var messageFormatter = ChatMessageFormatter(viewModel: self)
|
||||
lazy var peerIdentityCoordinator = ChatPeerIdentityCoordinator(viewModel: self)
|
||||
lazy var deliveryCoordinator = ChatDeliveryCoordinator(viewModel: self)
|
||||
lazy var deliveryCoordinator = ChatDeliveryCoordinator(context: self)
|
||||
lazy var composerCoordinator = ChatComposerCoordinator(viewModel: self)
|
||||
lazy var publicConversationCoordinator = ChatPublicConversationCoordinator(viewModel: self)
|
||||
lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(viewModel: self)
|
||||
lazy var nostrCoordinator = ChatNostrCoordinator(viewModel: self)
|
||||
lazy var publicConversationCoordinator = ChatPublicConversationCoordinator(context: self)
|
||||
lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(context: self)
|
||||
lazy var nostrCoordinator = ChatNostrCoordinator(context: self)
|
||||
lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(viewModel: self)
|
||||
lazy var verificationCoordinator = ChatVerificationCoordinator(viewModel: self)
|
||||
|
||||
@@ -168,7 +168,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
get { privateChatManager.privateChats }
|
||||
set {
|
||||
privateChatManager.privateChats = newValue
|
||||
synchronizePrivateConversationStore()
|
||||
schedulePrivateConversationStoreSynchronization()
|
||||
}
|
||||
}
|
||||
var selectedPrivateChatPeer: PeerID? {
|
||||
@@ -187,7 +187,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
get { privateChatManager.unreadMessages }
|
||||
set {
|
||||
privateChatManager.unreadMessages = newValue
|
||||
synchronizePrivateConversationStore()
|
||||
schedulePrivateConversationStoreSynchronization()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,6 +372,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
// MARK: - Message Delivery Tracking
|
||||
|
||||
var cancellables = Set<AnyCancellable>()
|
||||
private var pendingPrivateConversationStoreSyncTask: Task<Void, Never>?
|
||||
|
||||
var transferIdToMessageIDs: [String: [String]] {
|
||||
mediaTransferCoordinator.transferIdToMessageIDs
|
||||
@@ -967,6 +968,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
publicConversationCoordinator.synchronizeAllPublicConversationStores()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func schedulePrivateConversationStoreSynchronization() {
|
||||
guard pendingPrivateConversationStoreSyncTask == nil else { return }
|
||||
pendingPrivateConversationStoreSyncTask = Task { @MainActor [weak self] in
|
||||
await Task.yield()
|
||||
guard let self else { return }
|
||||
self.pendingPrivateConversationStoreSyncTask = nil
|
||||
self.synchronizePrivateConversationStore()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func synchronizePrivateConversationStore() {
|
||||
conversationStore.synchronizePrivateChats(
|
||||
|
||||
@@ -93,7 +93,7 @@ private extension ChatViewModelBootstrapper {
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak viewModel] _ in
|
||||
Task { @MainActor [weak viewModel] in
|
||||
viewModel?.synchronizePrivateConversationStore()
|
||||
viewModel?.schedulePrivateConversationStoreSynchronization()
|
||||
}
|
||||
}
|
||||
.store(in: &viewModel.cancellables)
|
||||
@@ -102,7 +102,7 @@ private extension ChatViewModelBootstrapper {
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak viewModel] _ in
|
||||
Task { @MainActor [weak viewModel] in
|
||||
viewModel?.synchronizePrivateConversationStore()
|
||||
viewModel?.schedulePrivateConversationStoreSynchronization()
|
||||
}
|
||||
}
|
||||
.store(in: &viewModel.cancellables)
|
||||
|
||||
@@ -10,7 +10,9 @@ import Foundation
|
||||
|
||||
struct PublicTimelineStore {
|
||||
private var meshTimeline: [BitchatMessage] = []
|
||||
private var meshMessageIDs: Set<String> = []
|
||||
private var geohashTimelines: [String: [BitchatMessage]] = [:]
|
||||
private var geohashMessageIDs: [String: Set<String>] = [:]
|
||||
private var pendingGeohashSystemMessages: [String] = []
|
||||
|
||||
private let meshCap: Int
|
||||
@@ -24,8 +26,9 @@ struct PublicTimelineStore {
|
||||
mutating func append(_ message: BitchatMessage, to channel: ChannelID) {
|
||||
switch channel {
|
||||
case .mesh:
|
||||
guard !meshTimeline.contains(where: { $0.id == message.id }) else { return }
|
||||
guard !meshMessageIDs.contains(message.id) else { return }
|
||||
meshTimeline.append(message)
|
||||
meshMessageIDs.insert(message.id)
|
||||
trimMeshTimelineIfNeeded()
|
||||
case .location(let channel):
|
||||
append(message, toGeohash: channel.geohash)
|
||||
@@ -33,21 +36,12 @@ struct PublicTimelineStore {
|
||||
}
|
||||
|
||||
mutating func append(_ message: BitchatMessage, toGeohash geohash: String) {
|
||||
var timeline = geohashTimelines[geohash] ?? []
|
||||
guard !timeline.contains(where: { $0.id == message.id }) else { return }
|
||||
timeline.append(message)
|
||||
trimGeohashTimelineIfNeeded(&timeline)
|
||||
geohashTimelines[geohash] = timeline
|
||||
_ = appendGeohashMessageIfAbsent(message, geohash: geohash)
|
||||
}
|
||||
|
||||
/// Append message if absent, returning true when stored.
|
||||
mutating func appendIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool {
|
||||
var timeline = geohashTimelines[geohash] ?? []
|
||||
guard !timeline.contains(where: { $0.id == message.id }) else { return false }
|
||||
timeline.append(message)
|
||||
trimGeohashTimelineIfNeeded(&timeline)
|
||||
geohashTimelines[geohash] = timeline
|
||||
return true
|
||||
appendGeohashMessageIfAbsent(message, geohash: geohash)
|
||||
}
|
||||
|
||||
mutating func messages(for channel: ChannelID) -> [BitchatMessage] {
|
||||
@@ -56,7 +50,7 @@ struct PublicTimelineStore {
|
||||
return meshTimeline
|
||||
case .location(let channel):
|
||||
let cleaned = geohashTimelines[channel.geohash]?.cleanedAndDeduped() ?? []
|
||||
geohashTimelines[channel.geohash] = cleaned
|
||||
replaceGeohashTimeline(cleaned, for: channel.geohash, keepEmpty: true)
|
||||
return cleaned
|
||||
}
|
||||
}
|
||||
@@ -65,22 +59,26 @@ struct PublicTimelineStore {
|
||||
switch channel {
|
||||
case .mesh:
|
||||
meshTimeline.removeAll()
|
||||
meshMessageIDs.removeAll()
|
||||
case .location(let channel):
|
||||
geohashTimelines[channel.geohash] = []
|
||||
geohashMessageIDs[channel.geohash] = []
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
mutating func removeMessage(withID id: String) -> BitchatMessage? {
|
||||
if let index = meshTimeline.firstIndex(where: { $0.id == id }) {
|
||||
return meshTimeline.remove(at: index)
|
||||
let removed = meshTimeline.remove(at: index)
|
||||
meshMessageIDs.remove(id)
|
||||
return removed
|
||||
}
|
||||
|
||||
for key in Array(geohashTimelines.keys) {
|
||||
var timeline = geohashTimelines[key] ?? []
|
||||
if let index = timeline.firstIndex(where: { $0.id == id }) {
|
||||
let removed = timeline.remove(at: index)
|
||||
geohashTimelines[key] = timeline.isEmpty ? nil : timeline
|
||||
replaceGeohashTimeline(timeline, for: key, keepEmpty: false)
|
||||
return removed
|
||||
}
|
||||
}
|
||||
@@ -91,13 +89,13 @@ struct PublicTimelineStore {
|
||||
mutating func removeMessages(in geohash: String, where predicate: (BitchatMessage) -> Bool) {
|
||||
var timeline = geohashTimelines[geohash] ?? []
|
||||
timeline.removeAll(where: predicate)
|
||||
geohashTimelines[geohash] = timeline.isEmpty ? nil : timeline
|
||||
replaceGeohashTimeline(timeline, for: geohash, keepEmpty: false)
|
||||
}
|
||||
|
||||
mutating func mutateGeohash(_ geohash: String, _ transform: (inout [BitchatMessage]) -> Void) {
|
||||
var timeline = geohashTimelines[geohash] ?? []
|
||||
transform(&timeline)
|
||||
geohashTimelines[geohash] = timeline.isEmpty ? nil : timeline
|
||||
replaceGeohashTimeline(timeline, for: geohash, keepEmpty: false)
|
||||
}
|
||||
|
||||
mutating func queueGeohashSystemMessage(_ content: String) {
|
||||
@@ -116,10 +114,35 @@ struct PublicTimelineStore {
|
||||
private mutating func trimMeshTimelineIfNeeded() {
|
||||
guard meshTimeline.count > meshCap else { return }
|
||||
meshTimeline = Array(meshTimeline.suffix(meshCap))
|
||||
meshMessageIDs = Set(meshTimeline.map(\.id))
|
||||
}
|
||||
|
||||
private func trimGeohashTimelineIfNeeded(_ timeline: inout [BitchatMessage]) {
|
||||
private mutating func appendGeohashMessageIfAbsent(_ message: BitchatMessage, geohash: String) -> Bool {
|
||||
var timeline = geohashTimelines[geohash] ?? []
|
||||
var messageIDs = geohashMessageIDs[geohash] ?? Set(timeline.map(\.id))
|
||||
guard messageIDs.insert(message.id).inserted else { return false }
|
||||
|
||||
timeline.append(message)
|
||||
trimGeohashTimelineIfNeeded(&timeline, messageIDs: &messageIDs)
|
||||
geohashTimelines[geohash] = timeline
|
||||
geohashMessageIDs[geohash] = messageIDs
|
||||
return true
|
||||
}
|
||||
|
||||
private func trimGeohashTimelineIfNeeded(_ timeline: inout [BitchatMessage], messageIDs: inout Set<String>) {
|
||||
guard timeline.count > geohashCap else { return }
|
||||
timeline = Array(timeline.suffix(geohashCap))
|
||||
messageIDs = Set(timeline.map(\.id))
|
||||
}
|
||||
|
||||
private mutating func replaceGeohashTimeline(_ timeline: [BitchatMessage], for geohash: String, keepEmpty: Bool) {
|
||||
if timeline.isEmpty && !keepEmpty {
|
||||
geohashTimelines[geohash] = nil
|
||||
geohashMessageIDs[geohash] = nil
|
||||
return
|
||||
}
|
||||
|
||||
geohashTimelines[geohash] = timeline
|
||||
geohashMessageIDs[geohash] = Set(timeline.map(\.id))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,10 +305,10 @@ private extension MessageListView {
|
||||
|
||||
var targetPeerID: String? {
|
||||
if let peer = privatePeer,
|
||||
let last = privateInboxModel.messages(for: peer).suffix(300).last?.id {
|
||||
let last = privateInboxModel.messages(for: peer).last?.id {
|
||||
return "dm:\(peer)|\(last)"
|
||||
}
|
||||
if let last = publicChatModel.messages.suffix(300).last?.id {
|
||||
if let last = publicChatModel.messages.last?.id {
|
||||
return "\(locationChannelsModel.selectedChannel.contextKey)|\(last)"
|
||||
}
|
||||
return nil
|
||||
@@ -329,7 +329,7 @@ private extension MessageListView {
|
||||
func scrollIfNeeded(date: Date) {
|
||||
lastScrollTime = date
|
||||
let contextKey = locationChannelsModel.selectedChannel.contextKey
|
||||
if let target = messages.suffix(windowCountPublic).last.map({ "\(contextKey)|\($0.id)" }) {
|
||||
if let target = messages.last.map({ "\(contextKey)|\($0.id)" }) {
|
||||
proxy.scrollTo(target, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
@@ -368,8 +368,7 @@ private extension MessageListView {
|
||||
func scrollIfNeeded(date: Date) {
|
||||
lastScrollTime = date
|
||||
let contextKey = "dm:\(peerID)"
|
||||
let count = windowCountPrivate[peerID] ?? 300
|
||||
if let target = messages.suffix(count).last.map({ "\(contextKey)|\($0.id)" }){
|
||||
if let target = messages.last.map({ "\(contextKey)|\($0.id)" }) {
|
||||
proxy.scrollTo(target, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
@@ -399,7 +398,7 @@ private extension MessageListView {
|
||||
isAtBottom = true
|
||||
windowCountPublic = TransportConfig.uiWindowInitialCountPublic
|
||||
let contextKey = "geo:\(ch.geohash)"
|
||||
if let target = publicChatModel.messages.suffix(windowCountPublic).last?.id.map({ "\(contextKey)|\($0)" }) {
|
||||
if let target = publicChatModel.messages.last?.id.map({ "\(contextKey)|\($0)" }) {
|
||||
proxy.scrollTo(target, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
func deliveryStatus_upgrade_deliveredToRead() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
@@ -222,6 +246,104 @@ struct ChatViewModelDeliveryStatusTests {
|
||||
}())
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func deliveryStatus_updatesPublicAndMirroredPrivateMessages() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let messageID = "mirrored-msg-1"
|
||||
let firstPeerID = PeerID(str: "0102030405060708")
|
||||
let secondPeerID = PeerID(str: "1112131415161718")
|
||||
|
||||
viewModel.messages = [
|
||||
BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
content: "Public copy",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: false,
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
]
|
||||
viewModel.privateChats[firstPeerID] = [
|
||||
BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
content: "Private copy A",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Peer A",
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
]
|
||||
viewModel.privateChats[secondPeerID] = [
|
||||
BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
content: "Private copy B",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Peer B",
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
]
|
||||
|
||||
let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus(
|
||||
messageID,
|
||||
status: .delivered(to: "Peer", at: Date())
|
||||
)
|
||||
|
||||
#expect(didUpdate)
|
||||
#expect(isDelivered(viewModel.messages.first?.deliveryStatus))
|
||||
#expect(isDelivered(viewModel.privateChats[firstPeerID]?.first?.deliveryStatus))
|
||||
#expect(isDelivered(viewModel.privateChats[secondPeerID]?.first?.deliveryStatus))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func deliveryStatus_indexRefreshesAfterPrivateChatReorder() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0102030405060708")
|
||||
let messageID = "reordered-msg-1"
|
||||
let olderMessage = BitchatMessage(
|
||||
id: "older-msg",
|
||||
sender: viewModel.nickname,
|
||||
content: "Older message",
|
||||
timestamp: Date(timeIntervalSince1970: 1),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Peer",
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
let targetMessage = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
content: "Target message",
|
||||
timestamp: Date(timeIntervalSince1970: 2),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Peer",
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
|
||||
viewModel.privateChats[peerID] = [targetMessage, olderMessage]
|
||||
#expect(isSent(viewModel.deliveryCoordinator.deliveryStatus(for: messageID)))
|
||||
|
||||
viewModel.privateChats[peerID] = [olderMessage, targetMessage]
|
||||
let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus(
|
||||
messageID,
|
||||
status: .read(by: "Peer", at: Date())
|
||||
)
|
||||
|
||||
#expect(didUpdate)
|
||||
#expect(isRead(viewModel.privateChats[peerID]?.last?.deliveryStatus))
|
||||
}
|
||||
|
||||
// MARK: - Status Rank Tests (for deduplication)
|
||||
|
||||
@Test @MainActor
|
||||
@@ -252,3 +374,198 @@ struct ChatViewModelDeliveryStatusTests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mock Delivery Context
|
||||
|
||||
/// Lightweight stand-in for `ChatDeliveryContext` proving that
|
||||
/// `ChatDeliveryCoordinator` is testable without constructing a `ChatViewModel`.
|
||||
@MainActor
|
||||
private final class MockChatDeliveryContext: ChatDeliveryContext {
|
||||
var messages: [BitchatMessage] = []
|
||||
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
||||
var sentReadReceipts: Set<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)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleNostrMessage_invalidSignatureDoesNotPoisonDedup() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try #require(try viewModel.idBridge.getCurrentNostrIdentity())
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
content: "verify:noop",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
var invalidGiftWrap = giftWrap
|
||||
invalidGiftWrap.sig = String(repeating: "0", count: 128)
|
||||
|
||||
viewModel.handleNostrMessage(invalidGiftWrap)
|
||||
#expect(!viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id))
|
||||
|
||||
viewModel.handleNostrMessage(giftWrap)
|
||||
#expect(viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func switchLocationChannel_clearsNostrDedupCache() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
@@ -20,4 +20,32 @@ struct GCSFilterTests {
|
||||
#expect(bucket != 0)
|
||||
#expect(bucket < 2)
|
||||
}
|
||||
|
||||
@Test func decodeRejectsOutOfRangeParameters() {
|
||||
let junk = Data(repeating: 0xFF, count: 64)
|
||||
#expect(GCSFilter.decodeToSortedSet(p: 0, m: 1000, data: junk).isEmpty)
|
||||
#expect(GCSFilter.decodeToSortedSet(p: -1, m: 1000, data: junk).isEmpty)
|
||||
#expect(GCSFilter.decodeToSortedSet(p: GCSFilter.maxP + 1, m: 1000, data: junk).isEmpty)
|
||||
#expect(GCSFilter.decodeToSortedSet(p: 255, m: UInt32.max, data: junk).isEmpty)
|
||||
#expect(GCSFilter.decodeToSortedSet(p: 8, m: 0, data: junk).isEmpty)
|
||||
#expect(GCSFilter.decodeToSortedSet(p: 8, m: 1, data: junk).isEmpty)
|
||||
}
|
||||
|
||||
@Test func decodeOfTruncatedDataReturnsOnlyCompleteValues() {
|
||||
let ids = (0..<32).map { i in Data(repeating: UInt8(i), count: 16) }
|
||||
let params = GCSFilter.buildFilter(ids: ids, maxBytes: 128, targetFpr: 0.01)
|
||||
let full = GCSFilter.decodeToSortedSet(p: params.p, m: params.m, data: params.data)
|
||||
let truncated = GCSFilter.decodeToSortedSet(p: params.p, m: params.m, data: params.data.prefix(params.data.count / 2))
|
||||
#expect(truncated.count <= full.count)
|
||||
// Truncation must not invent values that were not in the full set.
|
||||
#expect(truncated.allSatisfy { full.contains($0) })
|
||||
}
|
||||
|
||||
@Test func requestSyncPacketDecodeRejectsOversizedP() {
|
||||
let valid = RequestSyncPacket(p: 8, m: 4096, data: Data([0x01, 0x02]))
|
||||
#expect(RequestSyncPacket.decode(from: valid.encode()) != nil)
|
||||
|
||||
let oversized = RequestSyncPacket(p: 200, m: 4096, data: Data([0x01, 0x02]))
|
||||
#expect(RequestSyncPacket.decode(from: oversized.encode()) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,6 +278,75 @@ struct ChatViewModelPresenceHandlingTests {
|
||||
#expect(count >= 1)
|
||||
}
|
||||
|
||||
@Test func subscribeNostrEvent_samplingDeduplicatesSameEventAcrossGeohashes() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let firstGeohash = "u4pru"
|
||||
let secondGeohash = "u4pruy"
|
||||
let identity = try NostrIdentity.generate()
|
||||
let event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .geohashPresence,
|
||||
tags: [["g", secondGeohash]],
|
||||
content: ""
|
||||
)
|
||||
let signed = try event.sign(with: identity.schnorrSigningKey())
|
||||
|
||||
viewModel.subscribeNostrEvent(signed, gh: firstGeohash)
|
||||
viewModel.subscribeNostrEvent(signed, gh: secondGeohash)
|
||||
|
||||
#expect(viewModel.geohashParticipantCount(for: firstGeohash) == 1)
|
||||
#expect(viewModel.geohashParticipantCount(for: secondGeohash) == 0)
|
||||
}
|
||||
|
||||
@Test func subscribeNostrEvent_samplingDedupDoesNotBlockActiveChannelProcessing() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let sampleGeohash = "u4pru"
|
||||
let activeGeohash = "u4pruy"
|
||||
let identity = try NostrIdentity.generate()
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: activeGeohash)))
|
||||
|
||||
let event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .geohashPresence,
|
||||
tags: [["g", sampleGeohash]],
|
||||
content: ""
|
||||
)
|
||||
let signed = try event.sign(with: identity.schnorrSigningKey())
|
||||
|
||||
viewModel.subscribeNostrEvent(signed, gh: sampleGeohash)
|
||||
#expect(!viewModel.deduplicationService.hasProcessedNostrEvent(signed.id))
|
||||
|
||||
viewModel.subscribeNostrEvent(signed)
|
||||
|
||||
#expect(viewModel.geohashParticipantCount(for: sampleGeohash) == 1)
|
||||
#expect(viewModel.deduplicationService.hasProcessedNostrEvent(signed.id))
|
||||
#expect(viewModel.geohashParticipantCount(for: activeGeohash) >= 1)
|
||||
}
|
||||
|
||||
@Test func subscribeNostrEvent_samplingInvalidSignatureDoesNotPoisonDedup() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let sampleGeohash = "u4pru"
|
||||
let identity = try NostrIdentity.generate()
|
||||
let event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .geohashPresence,
|
||||
tags: [["g", sampleGeohash]],
|
||||
content: ""
|
||||
)
|
||||
let signed = try event.sign(with: identity.schnorrSigningKey())
|
||||
var invalid = signed
|
||||
invalid.sig = String(repeating: "0", count: 128)
|
||||
|
||||
viewModel.subscribeNostrEvent(invalid, gh: sampleGeohash)
|
||||
viewModel.subscribeNostrEvent(signed, gh: sampleGeohash)
|
||||
|
||||
#expect(viewModel.geohashParticipantCount(for: sampleGeohash) == 1)
|
||||
}
|
||||
|
||||
// MARK: - Test Helper
|
||||
|
||||
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
|
||||
|
||||
@@ -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 BitFoundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@@ -21,6 +22,38 @@ struct PublicTimelineStoreTests {
|
||||
#expect(messages.map(\.content) == ["two", "three"])
|
||||
}
|
||||
|
||||
@Test("Timeline indexes allow trimmed message IDs to return")
|
||||
func timelineIndexesAllowTrimmedMessageIDsToReturn() {
|
||||
var store = PublicTimelineStore(meshCap: 2, geohashCap: 2)
|
||||
let first = timelineMessage(id: "one", content: "one", timestamp: 1)
|
||||
let second = timelineMessage(id: "two", content: "two", timestamp: 2)
|
||||
let third = timelineMessage(id: "three", content: "three", timestamp: 3)
|
||||
|
||||
store.append(first, to: .mesh)
|
||||
store.append(second, to: .mesh)
|
||||
store.append(third, to: .mesh)
|
||||
store.append(first, to: .mesh)
|
||||
|
||||
#expect(store.messages(for: .mesh).map(\.content) == ["three", "one"])
|
||||
|
||||
let geohash = "u4pruydq"
|
||||
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))
|
||||
let geoFirst = timelineMessage(id: "geo-one", content: "geo one", timestamp: 1)
|
||||
let geoSecond = timelineMessage(id: "geo-two", content: "geo two", timestamp: 2)
|
||||
let geoThird = timelineMessage(id: "geo-three", content: "geo three", timestamp: 3)
|
||||
|
||||
let didAppendGeoFirst = store.appendIfAbsent(geoFirst, toGeohash: geohash)
|
||||
let didAppendGeoSecond = store.appendIfAbsent(geoSecond, toGeohash: geohash)
|
||||
let didAppendGeoThird = store.appendIfAbsent(geoThird, toGeohash: geohash)
|
||||
let didReappendGeoFirst = store.appendIfAbsent(geoFirst, toGeohash: geohash)
|
||||
|
||||
#expect(didAppendGeoFirst)
|
||||
#expect(didAppendGeoSecond)
|
||||
#expect(didAppendGeoThird)
|
||||
#expect(didReappendGeoFirst)
|
||||
#expect(store.messages(for: channel).map(\.content) == ["geo one", "geo three"])
|
||||
}
|
||||
|
||||
@Test("Geohash appendIfAbsent remove and clear work together")
|
||||
func geohashStoreSupportsAppendRemoveAndClear() {
|
||||
var store = PublicTimelineStore(meshCap: 2, geohashCap: 3)
|
||||
@@ -69,4 +102,14 @@ struct PublicTimelineStoreTests {
|
||||
#expect(store.drainPendingGeohashSystemMessages() == ["first", "second"])
|
||||
#expect(store.drainPendingGeohashSystemMessages().isEmpty)
|
||||
}
|
||||
|
||||
private func timelineMessage(id: String, content: String, timestamp: TimeInterval) -> BitchatMessage {
|
||||
BitchatMessage(
|
||||
id: id,
|
||||
sender: "alice",
|
||||
content: content,
|
||||
timestamp: Date(timeIntervalSince1970: timestamp),
|
||||
isRelay: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendPrivate_prefersConnectedTransportOverEarlierReachableOne() async {
|
||||
let peerID = PeerID(str: "0000000000000005")
|
||||
let reachableOnly = MockTransport()
|
||||
reachableOnly.reachablePeers.insert(peerID)
|
||||
let connected = MockTransport()
|
||||
connected.connectedPeers.insert(peerID)
|
||||
connected.reachablePeers.insert(peerID)
|
||||
|
||||
let router = MessageRouter(transports: [reachableOnly, connected])
|
||||
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m5")
|
||||
|
||||
#expect(reachableOnly.sentPrivateMessages.isEmpty)
|
||||
#expect(connected.sentPrivateMessages.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendPrivate_reachableOnlySendRetainsUntilDeliveryAck() async {
|
||||
let peerID = PeerID(str: "0000000000000006")
|
||||
let transport = MockTransport()
|
||||
transport.reachablePeers.insert(peerID)
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m6")
|
||||
#expect(transport.sentPrivateMessages.count == 1)
|
||||
|
||||
// No ack yet: a flush retries over the weak signal.
|
||||
router.flushOutbox(for: peerID)
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
|
||||
// Ack clears the retained copy; later flushes stop resending.
|
||||
router.markDelivered("m6")
|
||||
router.flushOutbox(for: peerID)
|
||||
#expect(transport.sentPrivateMessages.count == 2)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendPrivate_connectedSendIsNotRetained() async {
|
||||
let peerID = PeerID(str: "0000000000000007")
|
||||
let transport = MockTransport()
|
||||
transport.connectedPeers.insert(peerID)
|
||||
transport.reachablePeers.insert(peerID)
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m7")
|
||||
#expect(transport.sentPrivateMessages.count == 1)
|
||||
|
||||
router.flushOutbox(for: peerID)
|
||||
#expect(transport.sentPrivateMessages.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func flushOutbox_dropsUnackedMessageAfterAttemptCap() async {
|
||||
let peerID = PeerID(str: "0000000000000008")
|
||||
let transport = MockTransport()
|
||||
transport.reachablePeers.insert(peerID)
|
||||
|
||||
let router = MessageRouter(transports: [transport])
|
||||
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m8")
|
||||
|
||||
for _ in 0..<10 {
|
||||
router.flushOutbox(for: peerID)
|
||||
}
|
||||
|
||||
// Initial send plus capped resends; never unbounded.
|
||||
#expect(transport.sentPrivateMessages.count == 8)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendReadReceipt_usesReachableTransport() async {
|
||||
let peerID = PeerID(str: "0000000000000003")
|
||||
|
||||
@@ -90,6 +90,90 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
XCTAssertFalse(context.manager.isConnected)
|
||||
}
|
||||
|
||||
func test_connect_retriesTorWaitAndConnectsWhenTorBecomesReady() async {
|
||||
let context = makeContext(permission: .authorized, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||
|
||||
context.manager.connect()
|
||||
XCTAssertEqual(context.torWaiter.awaitCallCount, 1)
|
||||
|
||||
context.torWaiter.resolve(false)
|
||||
|
||||
// A failed wait re-queues the same targets and waits again instead of dropping them.
|
||||
XCTAssertEqual(context.torWaiter.awaitCallCount, 2)
|
||||
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
||||
|
||||
context.torWaiter.resolve(true)
|
||||
|
||||
let connected = await waitUntil {
|
||||
context.sessionFactory.requestedURLs.count == self.expectedDefaultRelayCount &&
|
||||
context.manager.relays.allSatisfy(\.isConnected)
|
||||
}
|
||||
XCTAssertTrue(connected)
|
||||
}
|
||||
|
||||
func test_subscribe_unblocksDeferredEOSEWhenTorWaitAttemptsExhausted() async {
|
||||
let relayURL = "wss://tor-eose-unblock.example"
|
||||
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||
var eoseCount = 0
|
||||
|
||||
context.manager.subscribe(
|
||||
filter: makeFilter(),
|
||||
id: "tor-sub-unblock",
|
||||
relayUrls: [relayURL],
|
||||
handler: { _ in },
|
||||
onEOSE: { eoseCount += 1 }
|
||||
)
|
||||
|
||||
for _ in 0..<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 {
|
||||
let relayURL = "wss://tor-ready.example"
|
||||
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||
@@ -439,6 +523,114 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
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 {
|
||||
let relayURL = "wss://missing-handler.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
@@ -880,6 +1072,12 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
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(
|
||||
timeout: TimeInterval = 1.0,
|
||||
condition: @escaping @MainActor () -> Bool
|
||||
|
||||
@@ -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
@@ -2,7 +2,7 @@ Tor-by-default integration (scaffold)
|
||||
|
||||
Overview
|
||||
- All network traffic is routed via a local Tor SOCKS5 proxy by default, with fail-closed behavior when Tor isn’t ready. There are no user-visible settings.
|
||||
- This repo now includes a minimal TorManager and TorURLSession to make dropping in an embedded Tor framework straightforward.
|
||||
- This repo vendors an Arti-backed Swift package under `localPackages/Arti`, including a Rust static-library xcframework linked by SwiftPM.
|
||||
|
||||
Key pieces
|
||||
- TorManager
|
||||
@@ -12,36 +12,17 @@ Key pieces
|
||||
- Provides a shared URLSession configured with a SOCKS5 proxy when Tor is enforced/ready.
|
||||
- NostrRelayManager and GeoRelayDirectory now use this session and await Tor readiness before starting network activity.
|
||||
|
||||
Drop‑in steps
|
||||
1) Build or obtain a small Tor framework
|
||||
- Recommended: Tor C (client-only) with static linking and dead-strip.
|
||||
- Configure Tor with a minimal feature set:
|
||||
./configure \
|
||||
--enable-static \
|
||||
--disable-asciidoc --disable-unittests --disable-manpage \
|
||||
--disable-zstd --disable-lzma --enable-zlib \
|
||||
--disable-systemd --disable-ptrace --disable-seccomp
|
||||
CFLAGS="-Os -fdata-sections -ffunction-sections" \
|
||||
LDFLAGS="-Wl,-dead_strip"
|
||||
- Build a tiny OpenSSL/LibreSSL (no engines, strip symbols) or reuse system crypto where permitted on macOS.
|
||||
Artifact maintenance
|
||||
- Binary provenance, rebuild steps, and current hashes are documented in `docs/ARTI-BINARY-PROVENANCE.md`.
|
||||
- The xcframework must include iOS device, iOS simulator, and macOS arm64 slices.
|
||||
- Any refresh should review the Rust source, `Cargo.lock`, generated header, build script, and new hashes together.
|
||||
|
||||
2) Add the framework to Xcode targets
|
||||
- Drop your xcframework into `Frameworks/`. The project is prewired in `project.yml` to link/embed `Frameworks/tor-nolzma.xcframework` (rename yours to match, or update the path).
|
||||
- Ensure the binary includes the slices you need (iOS device/simulator and/or macOS). If your xcframework lacks simulator slices, you can still build/run on device or macOS arm64; simulator will fail to link.
|
||||
- On iOS, it will be embedded and signed automatically.
|
||||
|
||||
3) Wire Tor bootstrap in TorManager.startTor()
|
||||
- Two paths are already implemented:
|
||||
- If a module named `Tor` is present (iCepa API), it starts `TORThread` directly.
|
||||
- Otherwise, it attempts a dynamic load (`dlopen`) of a bundled framework binary named `tor-nolzma.framework/tor-nolzma` (or `Tor.framework/Tor`), resolves `tor_run_main`, and launches Tor on a background thread.
|
||||
- `TorManager` writes a torrc and then probes `127.0.0.1:39050` until ready.
|
||||
|
||||
4) Verify networking
|
||||
Verification
|
||||
- On app launch, TorManager.startIfNeeded() is called implicitly by awaitReady().
|
||||
- NostrRelayManager.connect() awaits readiness, then creates WebSocket tasks via TorURLSession.shared.
|
||||
- GeoRelayDirectory.fetchRemote() awaits readiness, then fetches via TorURLSession.shared.
|
||||
|
||||
5) Optional macOS optimization
|
||||
Optional macOS optimization
|
||||
- Detect a system Tor binary (e.g., /opt/homebrew/bin/tor) and run it as a subprocess to avoid bundling. Keep the embedded fallback for portability.
|
||||
|
||||
torrc template
|
||||
|
||||
@@ -4,14 +4,16 @@ BitChat Privacy Assessment
|
||||
Scope
|
||||
- Mesh transport (BLE) behavior and metadata minimization
|
||||
- Nostr-based private message fallback (gift-wrapped, end-to-end encrypted)
|
||||
- Nostr-backed public geohash channels, presence heartbeats, and location notes
|
||||
- Optional CoreLocation use for geohash channel discovery
|
||||
- Read receipts and delivery acknowledgments
|
||||
- Logging/telemetry posture and controls
|
||||
|
||||
Summary
|
||||
- No accounts, no servers for mesh; Nostr used only for mutual favorites, with end-to-end Noise encryption encapsulated in gift wraps.
|
||||
- No accounts and no project-operated servers. Mesh traffic is peer-to-peer; Nostr is used for mutual-favorite private fallback and public geohash features.
|
||||
- BLE announces contain only nickname and Noise pubkey. No device name, no plaintext identity beyond what the user broadcasts.
|
||||
- Discovery and flooding incorporate jitter and TTL caps to reduce linkability and propagation radius of encrypted payloads.
|
||||
- UI and storage remain ephemeral; message content is not persisted to disk. Minimal state (e.g., read-receipt IDs) is stored for UX and is bounded/cleaned.
|
||||
- UI and storage remain mostly ephemeral; message content is not persisted to disk by default. Minimal local state (e.g., read-receipt IDs, favorites, selected/bookmarked geohashes) is stored for UX and is bounded or user-wipeable.
|
||||
- Logging defaults to conservative levels; debug verbosity is suppressed for release builds. A single env var can raise/lower threshold when needed.
|
||||
|
||||
BLE Privacy Considerations
|
||||
@@ -35,6 +37,14 @@ Nostr Private Messaging Fallback
|
||||
- Read/delivery acks: Also encapsulated in gift wraps, preserving content secrecy and minimizing metadata.
|
||||
- Relay policy variance: Some relays apply “web-of-trust” policies and may reject events; BitChat tolerates partial delivery and still prefers mesh when available.
|
||||
|
||||
Location Channels and Geohash Public Chats
|
||||
- Location permission: Optional when-in-use CoreLocation access computes local geohash channel options. Exact coordinates are held in memory only and are not included in BitChat or Nostr payloads.
|
||||
- Local state: Selected channel, teleported geohashes, bookmarks, and bookmark display names are stored in `UserDefaults`; the panic action clears location presence state along with identity/session state.
|
||||
- Geohash precision: User-selected channels can range from region-level to building-level. Public geohash messages and location notes expose the selected geohash tag to relays and participants.
|
||||
- Presence minimization: Automatic presence heartbeats are restricted to low-precision region/province/city geohashes and use randomized timing.
|
||||
- Per-geohash identities: Public geohash Nostr identities are derived from a device seed stored in the keychain, reducing cross-channel linkability compared with a single stable public key.
|
||||
- Relay metadata: Relays can observe event kind, geohash tag, public key, timestamp, and network metadata. Content in public geohash channels is intentionally public to that channel.
|
||||
|
||||
Read Receipts and Delivery Acks
|
||||
- Routing policy: Prefer mesh if Noise session established; otherwise use Nostr when mapping exists.
|
||||
- Throttling: Nostr READ acks are queued and rate-limited (~3/s) to prevent relay rate limits during backlogs.
|
||||
@@ -44,6 +54,9 @@ Data Retention and State
|
||||
- Messages: Ephemeral in-memory only; history is bounded per chat and trimmed.
|
||||
- Read-receipt IDs: Stored in `UserDefaults` for UX continuity; periodically pruned to IDs present in memory.
|
||||
- Favorites: Noise and optional Nostr keys with petnames; can be wiped via panic action.
|
||||
- Location channels: Exact coordinates are not persisted by BitChat. Selected/bookmarked geohashes, teleport flags, and bookmark display names persist locally until removed, panic-wiped, or the app is deleted.
|
||||
- Geohash identities: Device seed is stored in the keychain and used to derive per-geohash Nostr identities deterministically.
|
||||
- Relay persistence: Public geohash events, location notes, and encrypted gift wraps may be retained by relays according to each relay's policy.
|
||||
- Panic: Triple-tap clears keys, sessions, cached state, and disconnects transports.
|
||||
|
||||
Logging and Telemetry
|
||||
@@ -56,9 +69,11 @@ Residual Risks and Mitigations
|
||||
- RF fingerprinting: BLE presence is observable at the RF layer; mitigated by minimal announce content and platform MAC randomization.
|
||||
- Timing correlation: Announce/relay jitter reduces but does not eliminate timing analysis. Avoids synchronized bursts.
|
||||
- Relay metadata: Nostr relays can see that an account posts gift wraps; content remains end-to-end encrypted. Favor mesh path when in range.
|
||||
- Geohash inference: Public location-channel tags reveal approximate area. Mitigated by explicit channel selection, low-precision automatic presence, and per-geohash identities.
|
||||
- Bookmark persistence: Locally stored geohash bookmarks may reveal places of interest on a seized/unlocked device. Mitigated by panic wipe and local-only storage.
|
||||
|
||||
Recommendations (Next)
|
||||
- Add optional coalesced READ behavior for large backlogs.
|
||||
- Expose a “low-visibility mode” to reduce scanning aggressiveness in sensitive contexts.
|
||||
- Allow user-configurable Nostr relay set with a “private relays only” toggle.
|
||||
|
||||
- Add a user-facing precision warning before posting in block/building-level geohash channels.
|
||||
|
||||
+16
-16
@@ -4,22 +4,6 @@
|
||||
<dict>
|
||||
<key>AvailableLibraries</key>
|
||||
<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>
|
||||
<key>BinaryPath</key>
|
||||
<string>libarti_bitchat.a</string>
|
||||
@@ -54,6 +38,22 @@
|
||||
<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>
|
||||
|
||||
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -37,7 +37,8 @@ let package = Package(
|
||||
.linkedLibrary("sqlite3"),
|
||||
]
|
||||
),
|
||||
// Binary framework containing the Rust static library
|
||||
// Binary framework containing the Rust static library.
|
||||
// Provenance and rebuild steps: repo-root docs/ARTI-BINARY-PROVENANCE.md
|
||||
.binaryTarget(
|
||||
name: "arti",
|
||||
path: "Frameworks/arti.xcframework"
|
||||
|
||||
@@ -110,7 +110,9 @@ public final class TorManager: ObservableObject {
|
||||
public func isForeground() -> Bool { isAppForeground }
|
||||
|
||||
nonisolated
|
||||
public func awaitReady(timeout: TimeInterval = 25.0) async -> Bool {
|
||||
// Default matches the bootstrap monitor deadline (75s); a shorter wait here
|
||||
// reports "not ready" while Arti is still legitimately bootstrapping.
|
||||
public func awaitReady(timeout: TimeInterval = 75.0) async -> Bool {
|
||||
await MainActor.run {
|
||||
if self.isAppForeground { self.startIfNeeded() }
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name = "arti-bitchat"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.86"
|
||||
rust-version = "1.90"
|
||||
|
||||
[lib]
|
||||
crate-type = ["staticlib"]
|
||||
|
||||
@@ -133,6 +133,11 @@ create_xcframework() {
|
||||
log_info "Stripping $target library..."
|
||||
strip -x "$lib_path" 2>/dev/null || true
|
||||
|
||||
# Normalize archive metadata so repeated rebuilds are byte-stable.
|
||||
local normalized_path="$lib_path.normalized"
|
||||
xcrun libtool -static -D -no_warning_for_no_symbols "$lib_path" -o "$normalized_path"
|
||||
mv "$normalized_path" "$lib_path"
|
||||
|
||||
cmd="$cmd -library $lib_path"
|
||||
|
||||
# Add headers if they exist
|
||||
@@ -151,6 +156,71 @@ create_xcframework() {
|
||||
eval "$cmd"
|
||||
|
||||
if [[ -d "$xcframework_path" ]]; then
|
||||
cat > "$xcframework_path/Info.plist" << 'EOF'
|
||||
<?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)
|
||||
log_info "Created $xcframework_path ($size)"
|
||||
else
|
||||
|
||||
Reference in New Issue
Block a user