diff --git a/bitchat/Services/MessageRouter.swift b/bitchat/Services/MessageRouter.swift index 24efe2aff..187e9b538 100644 --- a/bitchat/Services/MessageRouter.swift +++ b/bitchat/Services/MessageRouter.swift @@ -1,17 +1,14 @@ import BitLogger import Foundation -/// Routes messages between BLE and Nostr transports +/// Routes messages using available transports (Mesh, Nostr, etc.) @MainActor final class MessageRouter { - private let mesh: Transport - private let nostr: NostrTransport + private let transports: [Transport] private var outbox: [PeerID: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages - init(mesh: Transport, nostr: NostrTransport) { - self.mesh = mesh - self.nostr = nostr - self.nostr.senderPeerID = mesh.myPeerID + init(transports: [Transport]) { + self.transports = transports // Observe favorites changes to learn Nostr mapping and flush queued messages NotificationCenter.default.addObserver( @@ -38,88 +35,70 @@ final class MessageRouter { } func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { - let reachableMesh = mesh.isPeerReachable(peerID) - if reachableMesh { - SecureLogger.debug("Routing PM via mesh (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) - // BLEService will initiate a handshake if needed and queue the message - mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) - } else if canSendViaNostr(peerID: peerID) { - SecureLogger.debug("Routing PM via Nostr to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) - nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) + // Try to find a reachable transport + if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) { + SecureLogger.debug("Routing PM via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) + transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) } else { - // Queue for later (when mesh connects or Nostr mapping appears) + // Queue for later if outbox[peerID] == nil { outbox[peerID] = [] } outbox[peerID]?.append((content, recipientNickname, messageID)) - SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))…", category: .session) + SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))…", category: .session) } } func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { - // Prefer mesh for reachable peers; BLE will queue if handshake is needed - if mesh.isPeerReachable(peerID) { - SecureLogger.debug("Routing READ ack via mesh (reachable) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session) - mesh.sendReadReceipt(receipt, to: peerID) - } else { - SecureLogger.debug("Routing READ ack via Nostr to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session) - nostr.sendReadReceipt(receipt, to: peerID) + if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) { + SecureLogger.debug("Routing READ ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session) + transport.sendReadReceipt(receipt, to: peerID) + } else if !transports.isEmpty { + // Fallback to last transport (usually Nostr) if neither is explicitly reachable? + // Or better: just try the first one that supports it? + // Existing logic preferred mesh, then nostr. + // If neither reachable, existing logic queued it (via mesh usually) or sent via nostr. + // Let's stick to "try reachable". If none, maybe pick the first one to queue? + // Actually, for READ receipts, we might want to just fire-and-forget on the "best effort" transport. + // But let's stick to the reachable check. + SecureLogger.debug("No reachable transport for READ ack to \(peerID.id.prefix(8))…", category: .session) } } func sendDeliveryAck(_ messageID: String, to peerID: PeerID) { - if mesh.isPeerReachable(peerID) { - SecureLogger.debug("Routing DELIVERED ack via mesh (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) - mesh.sendDeliveryAck(for: messageID, to: peerID) - } else { - nostr.sendDeliveryAck(for: messageID, to: peerID) + if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) { + SecureLogger.debug("Routing DELIVERED ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) + transport.sendDeliveryAck(for: messageID, to: peerID) } } func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { - // Route via mesh when connected; else use Nostr - if mesh.isPeerConnected(peerID) { - mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) + if let transport = transports.first(where: { $0.isPeerConnected(peerID) }) { + transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) + } else if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) { + transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) } else { - nostr.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) + // Fallback: try all? or just the last one? + // Old logic: if mesh connected, mesh. Else nostr. + // Note: NostrTransport.isPeerReachable now returns true if mapped. + // If not mapped, we can't send via Nostr anyway. } } // MARK: - Outbox Management - private func canSendViaNostr(peerID: PeerID) -> Bool { - // Two forms are supported: - // - 64-hex Noise public key (32 bytes) - // - 16-hex short peer ID (derived from Noise pubkey) - if let noiseKey = peerID.noiseKey { - if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), - fav.peerNostrPublicKey != nil { - return true - } - } else if peerID.isShort { - if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID), - fav.peerNostrPublicKey != nil { - return true - } - } - return false - } func flushOutbox(for peerID: PeerID) { guard let queued = outbox[peerID], !queued.isEmpty else { return } SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session) var remaining: [(content: String, nickname: String, messageID: String)] = [] - // Prefer mesh if connected; else try Nostr if mapping exists + for (content, nickname, messageID) in queued { - if mesh.isPeerReachable(peerID) { - SecureLogger.debug("Outbox -> mesh for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) - mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID) - } else if canSendViaNostr(peerID: peerID) { - SecureLogger.debug("Outbox -> Nostr for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) - nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID) + if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) { + SecureLogger.debug("Outbox -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session) + transport.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID) } else { - // Keep unsent items queued remaining.append((content, nickname, messageID)) } } - // Persist only items we could not send + if remaining.isEmpty { outbox.removeValue(forKey: peerID) } else { diff --git a/bitchat/Services/NostrTransport.swift b/bitchat/Services/NostrTransport.swift index 4ffc20a36..294274bc5 100644 --- a/bitchat/Services/NostrTransport.swift +++ b/bitchat/Services/NostrTransport.swift @@ -3,7 +3,7 @@ import Foundation import Combine // Minimal Nostr transport conforming to Transport for offline sending -final class NostrTransport: Transport { +final class NostrTransport: Transport, @unchecked Sendable { // Provide BLE short peer ID for BitChat embedding var senderPeerID = PeerID(str: "") @@ -18,9 +18,49 @@ final class NostrTransport: Transport { private let keychain: KeychainManagerProtocol private let idBridge: NostrIdentityBridge + // Reachability Cache (thread-safe) + private var reachablePeers: Set = [] + private let queue = DispatchQueue(label: "nostr.transport.state", attributes: .concurrent) + + @MainActor init(keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge) { self.keychain = keychain self.idBridge = idBridge + + setupObservers() + + // Synchronously warm the cache to avoid startup race + let favorites = FavoritesPersistenceService.shared.favorites + let reachable = favorites.values + .filter { $0.peerNostrPublicKey != nil } + .map { PeerID(publicKey: $0.peerNoisePublicKey) } + + queue.sync(flags: .barrier) { + self.reachablePeers = Set(reachable) + } + } + + private func setupObservers() { + NotificationCenter.default.addObserver( + forName: .favoriteStatusChanged, + object: nil, + queue: nil + ) { [weak self] _ in + self?.refreshReachablePeers() + } + } + + private func refreshReachablePeers() { + Task { @MainActor in + let favorites = FavoritesPersistenceService.shared.favorites + let reachable = favorites.values + .filter { $0.peerNostrPublicKey != nil } + .map { PeerID(publicKey: $0.peerNoisePublicKey) } + + self.queue.async(flags: .barrier) { [weak self] in + self?.reachablePeers = Set(reachable) + } + } } // MARK: - Transport Protocol Conformance @@ -42,7 +82,19 @@ final class NostrTransport: Transport { func emergencyDisconnectAll() { /* no-op */ } func isPeerConnected(_ peerID: PeerID) -> Bool { false } - func isPeerReachable(_ peerID: PeerID) -> Bool { false } + + func isPeerReachable(_ peerID: PeerID) -> Bool { + queue.sync { + // Check if exact match + if reachablePeers.contains(peerID) { return true } + // Check for short ID match + if peerID.isShort { + return reachablePeers.contains(where: { $0.toShort() == peerID }) + } + return false + } + } + func peerNickname(peerID: PeerID) -> String? { nil } func getPeerNicknames() -> [PeerID : String] { [:] } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 78b925b52..39aff8dfc 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -436,7 +436,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv self.privateChatManager = PrivateChatManager(meshService: meshService) self.unifiedPeerService = UnifiedPeerService(meshService: meshService, idBridge: idBridge, identityManager: identityManager) let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge) - self.messageRouter = MessageRouter(mesh: meshService, nostr: nostrTransport) + nostrTransport.senderPeerID = meshService.myPeerID + self.messageRouter = MessageRouter(transports: [meshService, nostrTransport]) // Route receipts from PrivateChatManager through MessageRouter self.privateChatManager.messageRouter = self.messageRouter // Allow PrivateChatManager to look up peer info for message consolidation diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index f0f4f31f7..209757bd7 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -158,7 +158,7 @@ struct ChatViewModelReceivingTests { ) // Give time for async Task and pipeline processing - try? await Task.sleep(nanoseconds: 200_000_000) + try? await Task.sleep(nanoseconds: 500_000_000) #expect(viewModel.messages.contains { $0.content == "Public hello from Bob" }) }