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

Architecture hardening: Tor reliability, supply-chain CI gate, BLE handler extraction, coordinator contexts
This commit is contained in:
jack
2026-06-10 20:24:39 +02:00
committed by GitHub
56 changed files with 6663 additions and 1057 deletions
+85
View File
@@ -0,0 +1,85 @@
name: Arti Binary Provenance
# The Arti xcframework is a vendored binary; these checks turn the policy in
# docs/ARTI-BINARY-PROVENANCE.md into an enforced gate:
# 1. The checked-in binary must match the hash manifest in the provenance doc.
# 2. A PR that changes the binary must also change at least one provenance
# input (Rust source, lockfile, build script, or the doc itself).
on:
push:
branches:
- main
paths:
- "localPackages/Arti/**"
- "docs/ARTI-BINARY-PROVENANCE.md"
pull_request:
paths:
- "localPackages/Arti/**"
- "docs/ARTI-BINARY-PROVENANCE.md"
jobs:
verify-hashes:
name: Verify xcframework hashes against provenance doc
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Compare artifact hashes with manifest
run: |
set -euo pipefail
doc="docs/ARTI-BINARY-PROVENANCE.md"
# Extract the manifest: lines of "<sha256> <path>" from the doc.
grep -E '^[0-9a-f]{64} localPackages/Arti/Frameworks/arti\.xcframework/' "$doc" \
| sort -k2 > expected.txt
if [ ! -s expected.txt ]; then
echo "::error::No hash manifest found in $doc"
exit 1
fi
# Hash the same file set the doc documents.
find localPackages/Arti/Frameworks/arti.xcframework -maxdepth 3 -type f -print0 \
| sort -z | xargs -0 sha256sum | sed 's/ \.\// /' | sort -k2 > actual.txt
if ! diff -u expected.txt actual.txt; then
echo "::error::Checked-in arti.xcframework does not match the manifest in $doc. If the binary change is intentional, rebuild per the doc and update the manifest in the same PR."
exit 1
fi
echo "All $(wc -l < actual.txt) artifact hashes match the provenance manifest."
require-provenance-evidence:
name: Binary changes must ship with provenance inputs
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Check changed files
run: |
set -euo pipefail
base="origin/${{ github.base_ref }}"
git fetch --no-tags --depth=1 origin "${{ github.base_ref }}"
changed=$(git diff --name-only "$base"...HEAD)
echo "Changed files:"
echo "$changed"
if ! echo "$changed" | grep -q '^localPackages/Arti/Frameworks/arti\.xcframework/'; then
echo "No binary artifact changes; nothing to verify."
exit 0
fi
if echo "$changed" | grep -Eq '^(localPackages/Arti/(Cargo\.(toml|lock)|build-ios\.sh|arti-bitchat/)|docs/ARTI-BINARY-PROVENANCE\.md)'; then
echo "Binary change is accompanied by provenance inputs."
exit 0
fi
echo "::error::arti.xcframework changed without matching source, lockfile, build-script, or provenance-doc changes. See docs/ARTI-BINARY-PROVENANCE.md (\"Do not accept an xcframework-only update\")."
exit 1
+22
View File
@@ -40,3 +40,25 @@ jobs:
- name: Run Tests
run: swift test --parallel --quiet --package-path ${{ matrix.path }}
# SPM tests above only compile the macOS slice; this job covers the
# iOS-conditional code paths (UIKit, CoreBluetooth restoration, etc.).
ios-build:
name: Build iOS app (simulator)
runs-on: macos-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Build iOS (simulator, no signing)
# arm64 only: the vendored arti.xcframework has no x86_64 simulator slice.
run: |
set -o pipefail
xcodebuild -project bitchat.xcodeproj \
-scheme "bitchat (iOS)" \
-sdk iphonesimulator \
-destination 'generic/platform=iOS Simulator' \
ARCHS=arm64 \
CODE_SIGNING_ALLOWED=NO \
build
+42 -16
View File
@@ -1,6 +1,6 @@
# bitchat Privacy Policy
*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.
---
+26 -6
View File
@@ -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
}
}
+3 -1
View File
@@ -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
}
}
+1 -1
View File
@@ -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" : {
+1 -1
View File
@@ -96,7 +96,7 @@ struct RequestSyncPacket {
}
}
guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil }
guard let pp = p, let mm = m, let dd = payload, pp >= 1, pp <= GCSFilter.maxP, mm > 0 else { return nil }
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types, sinceTimestamp: sinceTimestamp, fragmentIdFilter: fragmentIdFilter)
}
}
+129 -14
View File
@@ -142,11 +142,22 @@ final class NostrRelayManager: ObservableObject {
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
private var 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
)
}
}
}
+55 -8
View File
@@ -26,36 +26,69 @@ struct BLESubscribedCentralSnapshot {
}
}
/// Owns all BLE link state (peripheral connections we hold as central, and
/// central subscriptions we serve as peripheral). The store has no internal
/// locking: every access must happen on the single owning queue (the BLE
/// queue). Other queues must go through BLEService's `readLinkState`, which
/// hops to that queue. Call `assumeOwnership(of:)` to have debug builds trap
/// any access from the wrong queue.
final class BLELinkStateStore {
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)
}
}
+223 -389
View File
@@ -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
+59 -15
View File
@@ -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)
}
+8
View File
@@ -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
+8
View File
@@ -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
+249 -31
View File
@@ -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.")
}
}
+386 -165
View File
@@ -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)
+18 -6
View File
@@ -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)
+41 -18
View File
@@ -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))
}
}
+5 -6
View File
@@ -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()
+28
View File
@@ -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)
}
}
+69
View File
@@ -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
+88
View File
@@ -0,0 +1,88 @@
# Arti Binary Provenance
This repo vendors a prebuilt Arti static-library xcframework at:
`localPackages/Arti/Frameworks/arti.xcframework`
SwiftPM links it through `localPackages/Arti/Package.swift` as the binary target named `arti`. Treat changes to this artifact like dependency updates: review the Rust sources, lockfile, build script, produced headers, and artifact hashes together.
## Source Inputs
- Rust workspace: `localPackages/Arti/Cargo.toml`
- Crate: `localPackages/Arti/arti-bitchat`
- Dependency lockfile: `localPackages/Arti/Cargo.lock`
- Build script: `localPackages/Arti/build-ios.sh`
- Exported C header: `localPackages/Arti/Frameworks/include/arti.h`
The crate declares `rust-version = "1.90"` and uses `arti-client` / `tor-rtcompat` `0.38` with minimal Tokio/Rustls features. The current lockfile requires Rust 1.90 or newer. The build script currently targets:
- `aarch64-apple-ios`
- `aarch64-apple-ios-sim`
- `aarch64-apple-darwin`
It builds release static libraries with size-oriented flags (`opt-level=z`, fat LTO, one codegen unit, `panic=abort`, stripped symbols), normalizes static-archive metadata with `xcrun libtool -static -D`, then packages them with `xcodebuild -create-xcframework`.
## Regenerating The Artifact
From the repo root:
```sh
cd localPackages/Arti
rustup toolchain install 1.96.0
rustup default 1.96.0
rustup target add aarch64-apple-ios aarch64-apple-ios-sim aarch64-apple-darwin
cargo install cbindgen
./build-ios.sh
```
After rebuilding, verify that:
- `Cargo.lock` changes are intentional and reviewed.
- `Frameworks/include/arti.h` still matches the exported FFI functions used by `TorManager`.
- `Frameworks/arti.xcframework` contains iOS device, iOS simulator, and macOS arm64 slices.
- The main app still passes iOS tests and the macOS build.
## Audited Rebuild
The June 2026 artifact below was rebuilt from source on this host with:
```text
rustc 1.96.0 (ac68faa20 2026-05-25)
cargo 1.96.0 (30a34c682 2026-05-25)
rustup 1.29.0 (28d1352db 2026-03-05)
cbindgen 0.29.3
Xcode 26.5
Build version 17F42
```
Rust 1.86.0 was also checked during the audit and no longer builds this lockfile because `typed-index-collections@3.4.0` requires Rust 1.90.0 or newer.
The build script now normalizes static-archive metadata and writes a stable xcframework `Info.plist`. Two consecutive no-source-change rebuilds on this host produced the same hashes below.
## Current Artifact Hashes
Run this from the repo root to verify the checked-in artifact:
```sh
find localPackages/Arti/Frameworks/arti.xcframework -maxdepth 3 -type f -print0 | sort -z | xargs -0 shasum -a 256
```
Current hashes:
```text
2083d44eafc765db1ffa2691a5c5fabe60b4edbb82b574169ca0c6b98e245e3a localPackages/Arti/Frameworks/arti.xcframework/Info.plist
551655904834748c9dc36034fdbc9465e7533aef1e4a6514b4fcc75875b93058 localPackages/Arti/Frameworks/arti.xcframework/ios-arm64-simulator/Headers/arti.h
85febff37b751df667a3cab8222de2e1450cefe44b5b62c419adcbce48b9663f localPackages/Arti/Frameworks/arti.xcframework/ios-arm64-simulator/libarti_bitchat.a
551655904834748c9dc36034fdbc9465e7533aef1e4a6514b4fcc75875b93058 localPackages/Arti/Frameworks/arti.xcframework/ios-arm64/Headers/arti.h
fd25ee379d709a794733fc3c052746d1e6f7b25fec23e5f5234008a3434ce879 localPackages/Arti/Frameworks/arti.xcframework/ios-arm64/libarti_bitchat.a
551655904834748c9dc36034fdbc9465e7533aef1e4a6514b4fcc75875b93058 localPackages/Arti/Frameworks/arti.xcframework/macos-arm64/Headers/arti.h
8c426a41dc3eb76cc3e3e22e3356b9d11dbebdf0a0f248c5ac892e1839352c75 localPackages/Arti/Frameworks/arti.xcframework/macos-arm64/libarti_bitchat.a
```
## Review Checklist
- Record `rustc --version`, `cargo --version`, `cbindgen --version`, and `xcodebuild -version` in the PR when refreshing the binary.
- Include the hash output above after any binary change.
- If a rebuild changes only xcframework/library bytes, record the new hashes and app validation evidence in the PR.
- Keep `target/`, `.build/`, and `.swiftpm/` out of source control.
- Do not accept an xcframework-only update without matching source, lockfile, or build-script evidence explaining where it came from.
+7 -26
View File
@@ -2,7 +2,7 @@ Tor-by-default integration (scaffold)
Overview
- All network traffic is routed via a local Tor SOCKS5 proxy by default, with fail-closed behavior when Tor isnt ready. There are no user-visible settings.
- This repo now includes a minimal TorManager and TorURLSession to make dropping in an embedded Tor framework straightforward.
- This repo vendors an Arti-backed Swift package under `localPackages/Arti`, including a Rust static-library xcframework linked by SwiftPM.
Key pieces
- 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.
Dropin 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
+18 -3
View File
@@ -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
View File
@@ -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>
+2 -1
View File
@@ -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"
+3 -1
View File
@@ -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() }
}
+1 -1
View File
@@ -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"]
+70
View File
@@ -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