Extract BLE packet handlers and migrate coordinators to narrow contexts

BLEService's per-packet-type orchestration moves into owned, tested
components (BLEAnnounceHandler, BLEPublicMessageHandler,
BLENoisePacketHandler, BLEFileTransferHandler, BLEFragmentHandler),
each taking an environment struct of closures so every queue hop stays
in BLEService and the handlers are synchronously testable. Behavior is
preserved verbatim, including Noise session recovery on decrypt failure
and single-block UI event ordering. handleLeave/handleRequestSync stay
in place as already-thin delegations. BLEService drops to 3393 lines.

Four coordinators (delivery, private conversation, Nostr, public
conversation) drop their unowned/weak ChatViewModel back-references for
narrow @MainActor context protocols, with ChatViewModel conformances as
single shared witnesses for overlapping members. Their true coupling is
now an explicit, reviewable surface, and each gains a mock-context test
suite covering flows previously testable only through the full view
model. Delivery/read acks now also clear the router's retained-send
outbox via the delivery context.

New LargeTopologyTests exercise production-shaped meshes with the
in-memory harness: an 8-peer relay chain with per-hop TTL decay, a
14-peer cyclic mesh with exactly-once delivery, partition/heal, and
topology churn.

App-layer runtime/model files updated alongside.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-06-10 16:22:52 +01:00
co-authored by Claude Fable 5
parent 6bda919dd4
commit 3a995e20b6
32 changed files with 5685 additions and 953 deletions
+29 -9
View File
@@ -207,9 +207,14 @@ final class ConversationStore: ObservableObject {
private var directHandlesByConversation: [ConversationID: PeerHandle] = [:]
func setActiveChannel(_ channelID: ChannelID) {
activeChannel = channelID
if activeChannel != channelID {
activeChannel = channelID
}
if selectedPrivatePeerID == nil {
selectedConversationID = ConversationID(channelID: channelID)
let conversationID = ConversationID(channelID: channelID)
if selectedConversationID != conversationID {
selectedConversationID = conversationID
}
}
}
@@ -218,21 +223,33 @@ final class ConversationStore: ObservableObject {
activeChannel: ChannelID,
identityResolver: IdentityResolver
) {
self.activeChannel = activeChannel
selectedPrivatePeerID = peerID
if self.activeChannel != activeChannel {
self.activeChannel = activeChannel
}
if selectedPrivatePeerID != peerID {
selectedPrivatePeerID = peerID
}
if let peerID {
selectedConversationID = directConversationID(
let conversationID = directConversationID(
for: peerID,
identityResolver: identityResolver
)
if selectedConversationID != conversationID {
selectedConversationID = conversationID
}
} else {
selectedConversationID = ConversationID(channelID: activeChannel)
let conversationID = ConversationID(channelID: activeChannel)
if selectedConversationID != conversationID {
selectedConversationID = conversationID
}
}
}
func replaceMessages(_ messages: [BitchatMessage], for conversationID: ConversationID) {
messagesByConversation[conversationID] = normalized(messages)
let normalizedMessages = normalized(messages)
guard messagesByConversation[conversationID] != normalizedMessages else { return }
messagesByConversation[conversationID] = normalizedMessages
}
func replaceMessages(_ messages: [BitchatMessage], for channelID: ChannelID) {
@@ -296,7 +313,7 @@ final class ConversationStore: ObservableObject {
let conversationID = ConversationID.direct(handle)
liveConversations.insert(conversationID)
directHandlesByConversation[conversationID] = handle
messagesByConversation[conversationID] = normalized(messages)
replaceMessages(messages, for: conversationID)
}
let staleDirectConversations = messagesByConversation.keys.filter { conversationID in
@@ -319,10 +336,13 @@ final class ConversationStore: ObservableObject {
}
}
unreadConversations = unreadPeerIDs.reduce(into: publicUnread) { result, peerID in
let nextUnreadConversations = unreadPeerIDs.reduce(into: publicUnread) { result, peerID in
let handle = identityResolver.canonicalHandle(for: peerID)
result.insert(.direct(handle))
}
if unreadConversations != nextUnreadConversations {
unreadConversations = nextUnreadConversations
}
}
func markRead(_ conversationID: ConversationID) {
@@ -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
}
}
@@ -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
)
}
}
}
@@ -0,0 +1,132 @@
import BitFoundation
import BitLogger
import Foundation
/// Narrow environment for `BLENoisePacketHandler`.
///
/// All queue hops (collections barrier writes, main-actor UI notification)
/// and every `noiseService.*` crypto call live inside the closures supplied by
/// `BLEService`, keeping the handler queue-agnostic and synchronously testable.
struct BLENoisePacketHandlerEnvironment {
/// Local peer identity at the time the packet is handled.
let localPeerID: () -> PeerID
/// Local peer ID bytes used as the sender of handshake responses.
let localPeerIDData: () -> Data
/// TTL value used for direct (non-relayed) packets.
let messageTTL: UInt8
/// Current time source.
let now: () -> Date
/// Processes an inbound handshake message, returning an optional response payload (crypto).
let processHandshakeMessage: (_ peerID: PeerID, _ message: Data) throws -> Data?
/// Whether any Noise session (established or pending) exists for the peer (crypto).
let hasNoiseSession: (PeerID) -> Bool
/// Initiates a fresh Noise handshake with the peer (crypto + send).
let initiateHandshake: (PeerID) -> Void
/// Broadcasts a packet on the mesh (caller is already on the message queue).
let broadcastPacket: (BitchatPacket) -> Void
/// Updates the registry last-seen timestamp for the peer (async barrier write).
let updatePeerLastSeen: (PeerID) -> Void
/// Decrypts an encrypted payload from the peer (crypto).
let decrypt: (_ payload: Data, _ peerID: PeerID) throws -> Data
/// Clears the peer's Noise session after an unrecoverable decrypt failure (crypto).
let clearSession: (PeerID) -> Void
/// Delivers `.noisePayloadReceived` to the UI as one main-actor hop.
let deliverNoisePayload: (
_ peerID: PeerID,
_ type: NoisePayloadType,
_ payload: Data,
_ timestamp: Date
) -> Void
}
/// Orchestrates the Noise session domain for inbound packets: handshake
/// processing (with response), encrypted payload decryption and dispatch,
/// and session recovery on decrypt failure.
final class BLENoisePacketHandler {
private let environment: BLENoisePacketHandlerEnvironment
init(environment: BLENoisePacketHandlerEnvironment) {
self.environment = environment
}
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
let env = environment
// Use NoiseEncryptionService for handshake processing
if PeerID(hexData: packet.recipientID) == env.localPeerID() {
// Handshake is for us
do {
if let response = try env.processHandshakeMessage(peerID, packet.payload) {
// Send response
let responsePacket = BitchatPacket(
type: MessageType.noiseHandshake.rawValue,
senderID: env.localPeerIDData(),
recipientID: Data(hexString: peerID.id),
timestamp: UInt64(env.now().timeIntervalSince1970 * 1000),
payload: response,
signature: nil,
ttl: env.messageTTL
)
// We're on messageQueue from delegate callback
env.broadcastPacket(responsePacket)
}
// Session establishment will trigger onPeerAuthenticated callback
// which will send any pending messages at the right time
} catch {
SecureLogger.error("Failed to process handshake: \(error)")
// Try initiating a new handshake
if !env.hasNoiseSession(peerID) {
env.initiateHandshake(peerID)
}
}
}
}
func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
let env = environment
guard let recipientID = PeerID(hexData: packet.recipientID) else {
SecureLogger.warning("⚠️ Encrypted message has no recipient ID", category: .session)
return
}
if recipientID != env.localPeerID() {
SecureLogger.debug("🔐 Encrypted message not for me (for \(recipientID.id.prefix(8))…, I am \(env.localPeerID().id.prefix(8))…)", category: .session)
return
}
// Update lastSeen for the peer we received from (important for private messages)
env.updatePeerLastSeen(peerID)
do {
let decrypted = try env.decrypt(packet.payload, peerID)
guard decrypted.count > 0 else { return }
// First byte indicates the payload type
let payloadType = decrypted[0]
let payloadData = decrypted.dropFirst()
guard let noisePayloadType = NoisePayloadType(rawValue: payloadType) else {
SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)")
return
}
SecureLogger.debug("🔐 Decrypted noise payload type \(noisePayloadType.description) from \(peerID.id.prefix(8))", category: .session)
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
env.deliverNoisePayload(peerID, noisePayloadType, Data(payloadData), ts)
} catch NoiseEncryptionError.sessionNotEstablished {
// We received an encrypted message before establishing a session with this peer.
// Trigger a handshake so future messages can be decrypted.
SecureLogger.debug("🔑 Encrypted message from \(peerID.id.prefix(8))… without session; initiating handshake")
if !env.hasNoiseSession(peerID) {
env.initiateHandshake(peerID)
}
} catch {
// Decryption failed - clear the corrupted session and re-initiate handshake
// This handles cases where session state got out of sync (nonce mismatch, etc.)
SecureLogger.error("❌ Failed to decrypt message from \(peerID.id.prefix(8))…: \(error) - clearing session and re-initiating handshake")
env.clearSession(peerID)
env.initiateHandshake(peerID)
}
}
}
@@ -0,0 +1,104 @@
import BitFoundation
import BitLogger
import Foundation
/// Narrow environment for `BLEPublicMessageHandler`.
///
/// All queue hops (collections registry reads, BLE-queue link-state reads,
/// main-actor UI notification) live inside the closures supplied by
/// `BLEService`, keeping the handler queue-agnostic and synchronously testable.
struct BLEPublicMessageHandlerEnvironment {
/// Local peer identity at the time the message is handled.
let localPeerID: () -> PeerID
/// Local nickname used for sender resolution and collision checks.
let localNickname: () -> String
/// Current time source.
let now: () -> Date
/// Snapshot of known peers keyed by ID (registry read).
let peersSnapshot: () -> [PeerID: BLEPeerInfo]
/// Resolves a display name from a verified packet signature for peers missing from the registry.
let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String?
/// Tracks the broadcast message packet for gossip sync.
let trackPacketSeen: (BitchatPacket) -> Void
/// Direct link state for the peer (BLE-queue read).
let linkState: (PeerID) -> (hasPeripheral: Bool, hasCentral: Bool)
/// Resolves and consumes the original message ID for our own re-broadcast.
let takeSelfBroadcastMessageID: (BitchatPacket) -> String?
/// Delivers `.publicMessageReceived` to the UI as one main-actor hop.
let deliverPublicMessage: (
_ peerID: PeerID,
_ nickname: String,
_ content: String,
_ timestamp: Date,
_ messageID: String?
) -> Void
}
/// Orchestrates inbound public (broadcast) messages: freshness/self-echo
/// policy, sender display-name resolution, gossip tracking, payload decoding,
/// and UI delivery.
final class BLEPublicMessageHandler {
private let environment: BLEPublicMessageHandlerEnvironment
init(environment: BLEPublicMessageHandlerEnvironment) {
self.environment = environment
}
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
let env = environment
let now = env.now()
let messageDecision = BLEPublicMessagePolicy.evaluate(
packet: packet,
from: peerID,
localPeerID: env.localPeerID(),
now: now
)
let messagePolicy: BLEPublicMessageAcceptance
switch messageDecision {
case .accept(let acceptance):
messagePolicy = acceptance
case .reject(.selfEcho):
return
case .reject(.staleBroadcast(let ageSeconds)):
SecureLogger.debug("⏰ Ignoring stale broadcast message from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session)
return
}
// Snapshot peers to avoid concurrent mutation while iterating during nickname collision checks.
let peersSnapshot = env.peersSnapshot()
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
peerID: peerID,
localPeerID: env.localPeerID(),
localNickname: env.localNickname(),
peers: peersSnapshot,
allowConnectedUnverified: false
) ?? env.signedSenderDisplayName(packet, peerID) else {
SecureLogger.warning("🚫 Dropping public message from unverified or unknown peer \(peerID.id.prefix(8))", category: .security)
return
}
if messagePolicy.shouldTrackForSync {
env.trackPacketSeen(packet)
}
guard let content = String(data: packet.payload, encoding: .utf8) else {
SecureLogger.error("❌ Failed to decode message payload as UTF-8", category: .session)
return
}
// Determine if we have a direct link to the sender
let directLink = env.linkState(peerID)
let hasDirectLink = directLink.hasPeripheral || directLink.hasCentral
let pathTag = hasDirectLink ? "direct" : "mesh"
SecureLogger.debug("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)) chars=\(content.count) bytes=\(packet.payload.count)", category: .session)
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
var resolvedSelfMessageID: String? = nil
if peerID == env.localPeerID() {
resolvedSelfMessageID = env.takeSelfBroadcastMessageID(packet)
}
env.deliverPublicMessage(peerID, senderNickname, content, ts, resolvedSelfMessageID)
}
}
+262 -428
View File
@@ -110,6 +110,16 @@ final class BLEService: NSObject {
private var pendingDirectedRelays = BLEDirectedRelaySpool()
// Debounce for 'reconnected' logs
private var reconnectLogDebouncer = BLEPeerEventDebouncer()
// Announce-packet orchestration (queue hops stay in the environment closures)
private lazy var announceHandler = BLEAnnounceHandler(environment: makeAnnounceHandlerEnvironment())
// Public-message orchestration (queue hops stay in the environment closures)
private lazy var publicMessageHandler = BLEPublicMessageHandler(environment: makePublicMessageHandlerEnvironment())
// Noise handshake/encrypted orchestration (queue hops and crypto stay in the environment closures)
private lazy var noisePacketHandler = BLENoisePacketHandler(environment: makeNoisePacketHandlerEnvironment())
// Fragment-assembly orchestration (queue hops stay in the environment closures)
private lazy var fragmentHandler = BLEFragmentHandler(environment: makeFragmentHandlerEnvironment())
// File-transfer orchestration (queue hops stay in the environment closures)
private lazy var fileTransferHandler = BLEFileTransferHandler(environment: makeFileTransferHandlerEnvironment())
// MARK: - Gossip Sync
private var gossipSyncManager: GossipSyncManager?
@@ -195,6 +205,9 @@ final class BLEService: NSObject {
// Tag BLE queue for re-entrancy detection
bleQueue.setSpecific(key: bleQueueKey, value: ())
// Link state is owned exclusively by bleQueue; debug builds trap
// any access from another queue (cross-queue reads use readLinkState).
linkStateStore.assumeOwnership(of: bleQueue)
if initializeBluetoothManagers {
// Initialize BLE on background queue to prevent main thread blocking.
@@ -1005,79 +1018,50 @@ final class BLEService: NSObject {
}
private func handleFileTransfer(_ packet: BitchatPacket, from peerID: PeerID) {
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: myPeerID) { return }
fileTransferHandler.handle(packet, from: peerID)
}
let peersSnapshot = collectionsQueue.sync { peerRegistry.snapshotByID }
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
peerID: peerID,
localPeerID: myPeerID,
localNickname: myNickname,
peers: peersSnapshot,
allowConnectedUnverified: true
) ?? signedSenderDisplayName(for: packet, from: peerID) else {
SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))", category: .security)
return
}
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: myPeerID) else {
return
}
if deliveryPlan.shouldTrackForSync {
gossipSyncManager?.onPublicPacketSeen(packet)
}
let filePacket: BitchatFilePacket
let mime: MimeType
switch BLEIncomingFileValidator.validate(payload: packet.payload) {
case .success(let acceptance):
filePacket = acceptance.filePacket
mime = acceptance.mime
case .failure(.malformedPayload):
SecureLogger.error("❌ Failed to decode file transfer payload", category: .session)
return
case .failure(.payloadTooLarge(let bytes)):
SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security)
return
case .failure(.unsupportedMime(let mimeType, let bytes)):
SecureLogger.warning("🚫 MIME REJECT: '\(mimeType ?? "<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
/// Builds the file-transfer handler environment. All queue hops stay here
/// so `BLEFileTransferHandler` remains queue-agnostic and synchronously
/// testable.
private func makeFileTransferHandlerEnvironment() -> BLEFileTransferHandlerEnvironment {
BLEFileTransferHandlerEnvironment(
localPeerID: { [weak self] in
self?.myPeerID ?? PeerID(str: "")
},
localNickname: { [weak self] in
self?.myNickname ?? ""
},
peersSnapshot: { [weak self] in
guard let self = self else { return [:] }
return self.collectionsQueue.sync { self.peerRegistry.snapshotByID }
},
signedSenderDisplayName: { [weak self] packet, peerID in
self?.signedSenderDisplayName(for: packet, from: peerID)
},
trackPacketSeen: { [weak self] packet in
self?.gossipSyncManager?.onPublicPacketSeen(packet)
},
enforceStorageQuota: { [weak self] reservingBytes in
self?.incomingFileStore.enforceQuota(reservingBytes: reservingBytes)
},
saveIncomingFile: { [weak self] data, preferredName, subdirectory, fallbackExtension, defaultPrefix in
self?.incomingFileStore.save(
data: data,
preferredName: preferredName,
subdirectory: subdirectory,
fallbackExtension: fallbackExtension,
defaultPrefix: defaultPrefix
)
},
updatePeerLastSeen: { [weak self] peerID in
self?.updatePeerLastSeen(peerID)
},
deliverMessage: { [weak self] message in
// Single main-actor hop delivering `.messageReceived`.
self?.emitTransportEvent(.messageReceived(message))
}
)
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
emitTransportEvent(.messageReceived(message))
}
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
@@ -2775,74 +2759,39 @@ extension BLEService {
private func handleFragment(_ packet: BitchatPacket, from peerID: PeerID) {
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
_handleFragment(packet, from: peerID)
fragmentHandler.handle(packet, from: peerID)
} else {
messageQueue.async(flags: .barrier) { [weak self] in
self?._handleFragment(packet, from: peerID)
self?.fragmentHandler.handle(packet, from: peerID)
}
}
}
private func _handleFragment(_ packet: BitchatPacket, from peerID: PeerID) {
// Don't process our own fragments
if peerID == myPeerID {
return
}
guard let header = BLEFragmentHeader(packet: packet) else { return }
if header.isBroadcastFragment {
gossipSyncManager?.onPublicPacketSeen(packet)
}
let assemblyResult = collectionsQueue.sync(flags: .barrier) {
fragmentAssemblyBuffer.append(header, maxInFlightAssemblies: maxInFlightAssemblies)
}
logFragmentAssemblyResult(assemblyResult)
guard case let .complete(completedHeader, reassembled, _) = assemblyResult else { return }
// Decode the original packet bytes we reassembled, so flags/compression are preserved
if var originalPacket = BinaryProtocol.decode(reassembled) {
// Reassembled packet validation
let innerSender = PeerID(hexData: originalPacket.senderID)
if !isAcceptedIngressPayload(originalPacket, from: innerSender) {
// Cleanup below
} else {
SecureLogger.debug("✅ Reassembled packet id=\(completedHeader.idLogString) type=\(originalPacket.type) bytes=\(reassembled.count)", category: .session)
originalPacket.ttl = 0
handleReceivedPacket(originalPacket, from: peerID)
/// Builds the fragment handler environment. All queue hops stay here so
/// `BLEFragmentHandler` remains queue-agnostic and synchronously testable.
private func makeFragmentHandlerEnvironment() -> BLEFragmentHandlerEnvironment {
BLEFragmentHandlerEnvironment(
localPeerID: { [weak self] in
self?.myPeerID ?? PeerID(str: "")
},
trackPacketSeen: { [weak self] packet in
self?.gossipSyncManager?.onPublicPacketSeen(packet)
},
appendFragment: { [weak self] header in
guard let self = self else {
return .stored(header: header, started: false)
}
return self.collectionsQueue.sync(flags: .barrier) {
self.fragmentAssemblyBuffer.append(header, maxInFlightAssemblies: self.maxInFlightAssemblies)
}
},
isAcceptedIngressPayload: { [weak self] packet, innerSender in
self?.isAcceptedIngressPayload(packet, from: innerSender) ?? false
},
processReassembledPacket: { [weak self] packet, peerID in
self?.handleReceivedPacket(packet, from: peerID)
}
} else {
SecureLogger.error("❌ Failed to decode reassembled packet (type=\(completedHeader.originalType), total=\(completedHeader.total))", category: .session)
}
}
private func logFragmentAssemblyResult(_ result: BLEFragmentAssemblyBuffer.AppendResult) {
func logStartedIfNeeded(header: BLEFragmentHeader, started: Bool) {
if started {
SecureLogger.debug("📦 Started fragment assembly id=\(header.idLogString) total=\(header.total)", category: .session)
}
}
switch result {
case let .stored(header, started):
logStartedIfNeeded(header: header, started: started)
SecureLogger.debug("📦 Fragment \(header.index + 1)/\(header.total) (len=\(header.fragmentData.count)) for id=\(header.idLogString)", category: .session)
case let .complete(header, _, started):
logStartedIfNeeded(header: header, started: started)
SecureLogger.debug("📦 Fragment \(header.index + 1)/\(header.total) (len=\(header.fragmentData.count)) for id=\(header.idLogString)", category: .session)
case let .oversized(header, projectedSize, limit, started):
logStartedIfNeeded(header: header, started: started)
SecureLogger.warning(
"🚫 Fragment assembly exceeds size limit (\(projectedSize) bytes > \(limit)), evicting. Type=\(header.originalType) Index=\(header.index)/\(header.total)",
category: .security
)
}
)
}
// MARK: Packet Reception
@@ -2963,165 +2912,96 @@ extension BLEService {
}
private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) {
let now = Date()
let preflight = BLEAnnouncePreflightPolicy.evaluate(
packet: packet,
from: peerID,
localPeerID: myPeerID,
now: now
)
announceHandler.handle(packet, from: peerID)
}
let announcement: AnnouncementPacket
switch preflight {
case .accept(let acceptance):
announcement = acceptance.announcement
case .reject(.malformed):
SecureLogger.error("❌ Failed to decode announce packet from \(peerID.id.prefix(8))", category: .session)
return
case .reject(.senderMismatch(let derivedFromKey)):
SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))", category: .security)
return
case .reject(.selfAnnounce):
return
case .reject(.stale(let ageSeconds)):
SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session)
return
}
// Suppress announce logs to reduce noise
// Precompute signature verification outside barrier to reduce contention
let existingPeerForVerify = collectionsQueue.sync { peerRegistry.info(for: peerID) }
let hasSignature = packet.signature != nil
let signatureValid: Bool
if hasSignature {
signatureValid = noiseService.verifyPacketSignature(packet, publicKey: announcement.signingPublicKey)
if !signatureValid {
SecureLogger.warning("⚠️ Signature verification for announce failed \(peerID.id.prefix(8))", category: .security)
}
} else {
signatureValid = false
}
let trustDecision = BLEAnnounceTrustPolicy.evaluate(
hasSignature: hasSignature,
signatureValid: signatureValid,
existingNoisePublicKey: existingPeerForVerify?.noisePublicKey,
announcedNoisePublicKey: announcement.noisePublicKey
)
if case .reject(.keyMismatch) = trustDecision {
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security)
}
let verifiedAnnounce = trustDecision.isVerified
var isNewPeer = false
var isReconnectedPeer = false
let directLinkState = linkState(for: peerID)
let isDirectAnnounce = packet.ttl == messageTTL
collectionsQueue.sync(flags: .barrier) {
let hasPeripheralConnection = directLinkState.hasPeripheral
let hasCentralSubscription = directLinkState.hasCentral
// Require verified announce; ignore otherwise (no backward compatibility)
if !verifiedAnnounce {
SecureLogger.warning("❌ Ignoring unverified announce from \(peerID.id.prefix(8))", category: .security)
// Reset flags to prevent post-barrier code from acting on unverified announces
isNewPeer = false
isReconnectedPeer = false
return
}
let update = peerRegistry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: announcement.nickname,
noisePublicKey: announcement.noisePublicKey,
signingPublicKey: announcement.signingPublicKey,
isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
now: now
)
isNewPeer = update.isNewPeer
isReconnectedPeer = update.wasDisconnected
// Log connection status only for direct connectivity changes; debounce to reduce spam
if isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription {
let now = Date()
if update.isNewPeer {
SecureLogger.debug("🆕 New peer: \(announcement.nickname)", category: .session)
} else if update.wasDisconnected {
if reconnectLogDebouncer.shouldEmit(
peerID: peerID,
now: now,
minimumInterval: TransportConfig.bleReconnectLogDebounceSeconds
) {
SecureLogger.debug("🔄 Peer \(announcement.nickname) reconnected", category: .session)
/// Builds the announce handler environment. All queue hops stay here so
/// `BLEAnnounceHandler` remains queue-agnostic and synchronously testable.
private func makeAnnounceHandlerEnvironment() -> BLEAnnounceHandlerEnvironment {
BLEAnnounceHandlerEnvironment(
localPeerID: { [weak self] in
self?.myPeerID ?? PeerID(str: "")
},
messageTTL: messageTTL,
now: { Date() },
existingNoisePublicKey: { [weak self] peerID in
guard let self = self else { return nil }
return self.collectionsQueue.sync { self.peerRegistry.info(for: peerID)?.noisePublicKey }
},
verifySignature: { [weak self] packet, signingPublicKey in
self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false
},
linkState: { [weak self] peerID in
self?.linkState(for: peerID) ?? (hasPeripheral: false, hasCentral: false)
},
withRegistryBarrier: { [weak self] body in
self?.collectionsQueue.sync(flags: .barrier) { body() }
},
upsertVerifiedAnnounce: { [weak self] peerID, announcement, isConnected, now in
// Called from inside withRegistryBarrier; access registry directly.
self?.peerRegistry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: announcement.nickname,
noisePublicKey: announcement.noisePublicKey,
signingPublicKey: announcement.signingPublicKey,
isConnected: isConnected,
now: now
) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
},
shouldEmitReconnectLog: { [weak self] peerID, now in
// Called from inside withRegistryBarrier; access debouncer directly.
self?.reconnectLogDebouncer.shouldEmit(
peerID: peerID,
now: now,
minimumInterval: TransportConfig.bleReconnectLogDebounceSeconds
) ?? false
},
updateTopology: { [weak self] peerID, neighbors in
self?.meshTopology.updateNeighbors(for: peerID.routingData, neighbors: neighbors)
},
persistIdentity: { [weak self] announcement in
self?.identityManager.upsertCryptographicIdentity(
fingerprint: announcement.noisePublicKey.sha256Fingerprint(),
noisePublicKey: announcement.noisePublicKey,
signingPublicKey: announcement.signingPublicKey,
claimedNickname: announcement.nickname
)
},
dedupContains: { [weak self] id in
self?.messageDeduplicator.contains(id) ?? true
},
dedupMarkProcessed: { [weak self] id in
self?.messageDeduplicator.markProcessed(id)
},
deliverAnnounceUIEvents: { [weak self] peerID, notifyPeerConnected, scheduleInitialSync in
// Single main-actor hop so event order is guaranteed:
// .peerConnected initial sync scheduling .peerListUpdated.
self?.notifyUI { [weak self] in
guard let self = self else { return }
if notifyPeerConnected {
self.deliverTransportEvent(.peerConnected(peerID))
}
} else if let previousNickname = update.previousNickname, previousNickname != announcement.nickname {
SecureLogger.debug("🔄 Peer \(peerID.id.prefix(8))… changed nickname: \(previousNickname) -> \(announcement.nickname)", category: .session)
if scheduleInitialSync {
self.gossipSyncManager?.scheduleInitialSyncToPeer(peerID, delaySeconds: 1.0)
}
// Get current peer list (after addition)
let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs }
self.requestPeerDataPublish()
self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
}
}
}
// Update topology with verified neighbor claims (only for authenticated announces)
if verifiedAnnounce, let neighbors = announcement.directNeighbors {
meshTopology.updateNeighbors(for: peerID.routingData, neighbors: neighbors)
}
// Persist cryptographic identity and signing key for robust offline verification
identityManager.upsertCryptographicIdentity(
fingerprint: announcement.noisePublicKey.sha256Fingerprint(),
noisePublicKey: announcement.noisePublicKey,
signingPublicKey: announcement.signingPublicKey,
claimedNickname: announcement.nickname
)
let announceBackID = "announce-back-\(peerID)"
let shouldSendBack = !messageDeduplicator.contains(announceBackID)
if shouldSendBack {
messageDeduplicator.markProcessed(announceBackID)
}
let responsePlan = BLEAnnounceResponsePolicy.plan(
isDirectAnnounce: isDirectAnnounce,
isNewPeer: isNewPeer,
isReconnectedPeer: isReconnectedPeer,
shouldSendAnnounceBack: shouldSendBack
)
// Notify UI on main thread
notifyUI { [weak self] in
guard let self = self else { return }
// Get current peer list (after addition)
let currentPeerIDs = self.collectionsQueue.sync { self.peerRegistry.peerIDs }
// Only notify of connection for new or reconnected peers when it is a direct announce
if responsePlan.shouldNotifyPeerConnected {
self.deliverTransportEvent(.peerConnected(peerID))
// Schedule initial unicast sync to this peer
if responsePlan.shouldScheduleInitialSync {
self.gossipSyncManager?.scheduleInitialSyncToPeer(peerID, delaySeconds: 1.0)
}
}
self.requestPeerDataPublish()
self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
}
// Track for sync (include our own and others' announces)
gossipSyncManager?.onPublicPacketSeen(packet)
if responsePlan.shouldSendAnnounceBack {
// Reciprocate announce for bidirectional discovery
// Force send to ensure the peer receives our announce
sendAnnounce(forceSend: true)
}
// Afterglow: on first-seen peers, schedule a short re-announce to push presence one more hop
if responsePlan.shouldScheduleAfterglow {
let delay = Double.random(in: 0.3...0.6)
messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in
},
trackPacketSeen: { [weak self] packet in
self?.gossipSyncManager?.onPublicPacketSeen(packet)
},
sendAnnounceBack: { [weak self] in
self?.sendAnnounce(forceSend: true)
},
scheduleAfterglow: { [weak self] delay in
self?.messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.sendAnnounce(forceSend: true)
}
}
}
)
}
// Handle REQUEST_SYNC: decode payload and respond with missing packets via sync manager
@@ -3136,156 +3016,110 @@ extension BLEService {
// Mention parsing moved to ChatViewModel
private func handleMessage(_ packet: BitchatPacket, from peerID: PeerID) {
let now = Date()
let messageDecision = BLEPublicMessagePolicy.evaluate(
packet: packet,
from: peerID,
localPeerID: myPeerID,
now: now
)
let messagePolicy: BLEPublicMessageAcceptance
switch messageDecision {
case .accept(let acceptance):
messagePolicy = acceptance
case .reject(.selfEcho):
return
case .reject(.staleBroadcast(let ageSeconds)):
SecureLogger.debug("⏰ Ignoring stale broadcast message from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session)
return
}
// Snapshot peers to avoid concurrent mutation while iterating during nickname collision checks.
let peersSnapshot = collectionsQueue.sync { peerRegistry.snapshotByID }
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
peerID: peerID,
localPeerID: myPeerID,
localNickname: myNickname,
peers: peersSnapshot,
allowConnectedUnverified: false
) ?? signedSenderDisplayName(for: packet, from: peerID) else {
SecureLogger.warning("🚫 Dropping public message from unverified or unknown peer \(peerID.id.prefix(8))", category: .security)
return
}
if messagePolicy.shouldTrackForSync {
gossipSyncManager?.onPublicPacketSeen(packet)
}
guard let content = String(data: packet.payload, encoding: .utf8) else {
SecureLogger.error("❌ Failed to decode message payload as UTF-8", category: .session)
return
}
// Determine if we have a direct link to the sender
let directLink = linkState(for: peerID)
let hasDirectLink = directLink.hasPeripheral || directLink.hasCentral
let pathTag = hasDirectLink ? "direct" : "mesh"
SecureLogger.debug("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)) chars=\(content.count) bytes=\(packet.payload.count)", category: .session)
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
var resolvedSelfMessageID: String? = nil
if peerID == myPeerID {
resolvedSelfMessageID = selfBroadcastTracker.takeMessageID(for: packet)
}
notifyUI { [weak self] in
self?.deliverTransportEvent(
.publicMessageReceived(
peerID: peerID,
nickname: senderNickname,
content: content,
timestamp: ts,
messageID: resolvedSelfMessageID
)
)
}
publicMessageHandler.handle(packet, from: peerID)
}
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
// Use NoiseEncryptionService for handshake processing
if PeerID(hexData: packet.recipientID) == myPeerID {
// Handshake is for us
do {
if let response = try noiseService.processHandshakeMessage(from: peerID, message: packet.payload) {
// Send response
let responsePacket = BitchatPacket(
type: MessageType.noiseHandshake.rawValue,
senderID: myPeerIDData,
recipientID: Data(hexString: peerID.id),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: response,
signature: nil,
ttl: messageTTL
/// Builds the public-message handler environment. All queue hops stay here
/// so `BLEPublicMessageHandler` remains queue-agnostic and synchronously
/// testable.
private func makePublicMessageHandlerEnvironment() -> BLEPublicMessageHandlerEnvironment {
BLEPublicMessageHandlerEnvironment(
localPeerID: { [weak self] in
self?.myPeerID ?? PeerID(str: "")
},
localNickname: { [weak self] in
self?.myNickname ?? ""
},
now: { Date() },
peersSnapshot: { [weak self] in
guard let self = self else { return [:] }
return self.collectionsQueue.sync { self.peerRegistry.snapshotByID }
},
signedSenderDisplayName: { [weak self] packet, peerID in
self?.signedSenderDisplayName(for: packet, from: peerID)
},
trackPacketSeen: { [weak self] packet in
self?.gossipSyncManager?.onPublicPacketSeen(packet)
},
linkState: { [weak self] peerID in
self?.linkState(for: peerID) ?? (hasPeripheral: false, hasCentral: false)
},
takeSelfBroadcastMessageID: { [weak self] packet in
// Caller is on messageQueue, where the tracker is owned.
self?.selfBroadcastTracker.takeMessageID(for: packet)
},
deliverPublicMessage: { [weak self] peerID, nickname, content, timestamp, messageID in
// Single main-actor hop delivering `.publicMessageReceived`.
self?.notifyUI { [weak self] in
self?.deliverTransportEvent(
.publicMessageReceived(
peerID: peerID,
nickname: nickname,
content: content,
timestamp: timestamp,
messageID: messageID
)
)
// We're on messageQueue from delegate callback
broadcastPacket(responsePacket)
}
// Session establishment will trigger onPeerAuthenticated callback
// which will send any pending messages at the right time
} catch {
SecureLogger.error("Failed to process handshake: \(error)")
// Try initiating a new handshake
if !noiseService.hasSession(with: peerID) {
initiateNoiseHandshake(with: peerID)
}
}
}
)
}
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
noisePacketHandler.handleHandshake(packet, from: peerID)
}
private func handleNoiseEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
guard let recipientID = PeerID(hexData: packet.recipientID) else {
SecureLogger.warning("⚠️ Encrypted message has no recipient ID", category: .session)
return
}
if recipientID != myPeerID {
SecureLogger.debug("🔐 Encrypted message not for me (for \(recipientID.id.prefix(8))…, I am \(myPeerID.id.prefix(8))…)", category: .session)
return
}
// Update lastSeen for the peer we received from (important for private messages)
updatePeerLastSeen(peerID)
do {
let decrypted = try noiseService.decrypt(packet.payload, from: peerID)
guard decrypted.count > 0 else { return }
// First byte indicates the payload type
let payloadType = decrypted[0]
let payloadData = decrypted.dropFirst()
noisePacketHandler.handleEncrypted(packet, from: peerID)
}
guard let noisePayloadType = NoisePayloadType(rawValue: payloadType) else {
SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)")
return
/// Builds the Noise packet handler environment. All queue hops and
/// `noiseService` crypto calls stay here so `BLENoisePacketHandler`
/// remains queue-agnostic and synchronously testable.
private func makeNoisePacketHandlerEnvironment() -> BLENoisePacketHandlerEnvironment {
BLENoisePacketHandlerEnvironment(
localPeerID: { [weak self] in
self?.myPeerID ?? PeerID(str: "")
},
localPeerIDData: { [weak self] in
self?.myPeerIDData ?? Data()
},
messageTTL: messageTTL,
now: { Date() },
processHandshakeMessage: { [weak self] peerID, message in
try self?.noiseService.processHandshakeMessage(from: peerID, message: message)
},
hasNoiseSession: { [weak self] peerID in
self?.noiseService.hasSession(with: peerID) ?? false
},
initiateHandshake: { [weak self] peerID in
self?.initiateNoiseHandshake(with: peerID)
},
broadcastPacket: { [weak self] packet in
self?.broadcastPacket(packet)
},
updatePeerLastSeen: { [weak self] peerID in
self?.updatePeerLastSeen(peerID)
},
decrypt: { [weak self] payload, peerID in
guard let self = self else { throw NoiseEncryptionError.sessionNotEstablished }
return try self.noiseService.decrypt(payload, from: peerID)
},
clearSession: { [weak self] peerID in
self?.noiseService.clearSession(for: peerID)
},
deliverNoisePayload: { [weak self] peerID, type, payload, timestamp in
// Single main-actor hop delivering `.noisePayloadReceived`.
self?.notifyUI { [weak self] in
self?.deliverTransportEvent(.noisePayloadReceived(
peerID: peerID,
type: type,
payload: payload,
timestamp: timestamp
))
}
}
SecureLogger.debug("🔐 Decrypted noise payload type \(noisePayloadType.description) from \(peerID.id.prefix(8))", category: .session)
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
notifyUI { [weak self] in
self?.deliverTransportEvent(.noisePayloadReceived(
peerID: peerID,
type: noisePayloadType,
payload: Data(payloadData),
timestamp: ts
))
}
} catch NoiseEncryptionError.sessionNotEstablished {
// We received an encrypted message before establishing a session with this peer.
// Trigger a handshake so future messages can be decrypted.
SecureLogger.debug("🔑 Encrypted message from \(peerID.id.prefix(8))… without session; initiating handshake")
if !noiseService.hasSession(with: peerID) {
initiateNoiseHandshake(with: peerID)
}
} catch {
// Decryption failed - clear the corrupted session and re-initiate handshake
// This handles cases where session state got out of sync (nonce mismatch, etc.)
SecureLogger.error("❌ Failed to decrypt message from \(peerID.id.prefix(8))…: \(error) - clearing session and re-initiating handshake")
noiseService.clearSession(for: peerID)
initiateNoiseHandshake(with: peerID)
}
)
}
// MARK: Helper Functions
+234 -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,148 @@ 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 {
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 }
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,145 @@ struct ChatViewModelDeliveryStatusTests {
}
}
}
// MARK: - Mock Delivery Context
/// Lightweight stand-in for `ChatDeliveryContext` proving that
/// `ChatDeliveryCoordinator` is testable without constructing a `ChatViewModel`.
@MainActor
private final class MockChatDeliveryContext: ChatDeliveryContext {
var messages: [BitchatMessage] = []
var privateChats: [PeerID: [BitchatMessage]] = [:]
var sentReadReceipts: Set<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 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()
+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)
}
}