From 96c6fc0c0d9ba1f63067e2c0ea2216f35575a270 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Tue, 26 Aug 2025 17:28:08 +0200 Subject: [PATCH] Improve mesh relaying, presence, and DM robustness; signed public msgs; reachability UI; quicker announces; store-and-forward (#527) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Sign public broadcasts; verify relayed messages via persisted signing keys; keep scheduled relays in sparse graphs and speed their jitter; persist announce signing key for offline auth; add short backoff after disconnect errors to reduce reconnect thrash * Add connected vs reachable model: retain peers after link drop, expire after reachability window; expose all peers in snapshots; compute isReachable in UI; add meshReachable state and sorting; avoid removing peers on link events; notify UI on stale removals * ContentView: handle new .meshReachable connection state in header icon switch (exhaustive switch fix) * Logs: tag relayed announces as 'Reachable via mesh' and annotate public message logs with (direct|mesh) path for easier field analysis * Fix syntax error: remove stray else/log inserted into writeOrEnqueue; keep logs clean * UI: use 'point.3.connected.trianglepath.dotted' for mesh-reachable; change people count to include connected+reachable (exclude Nostr-only) * UI: switch to 'point.3.filled.connected.trianglepath.dotted' for mesh-reachable icons in list and header * Reachability: reduce retention to 21s for all peers (verified and unverified) to minimize stale presence * mesh DMs/acks: route to reachable peers; queue READ/DELIVERED until handshake; add Transport.isPeerReachable; UI: hide offline non-mutuals; DM header: better name fallback + show transport + encryption icons; fix NostrTransport conformance * Verification sheet: compute encryption status and fingerprint using short mesh ID mapping (fix 'not encrypted/handshake' for DMs with stable key) * Announce cadence: faster discovery (4s), sparse 15±4s, dense 30±8s; initial 0.6s; post-subscribe 50ms; min-force 150ms; maintenance 5s; proactive announces on handshake + recent-traffic nudge * Relay: increase broadcast TTL cap in sparse graphs to 6; tighten jitter for handshake (10–35ms) and directed (20–60ms) relays * Range/robustness: store-and-forward for directed packets (15s) with flush on new links + periodic; announces: no subset + afterglow re-announce on first-seen; adaptive scanning: force ON when <=2 neighbors or recent traffic * Fix warnings: remove unused msgID and unused mutable var in directed spool flush * Announces: TTL 7 (sparse only) via RelayController; no fanout subset for announces; neighbor-change rebroadcast of last 2–3 announces. Fragments: faster pacing (5ms global, 4ms directed). * Peer list: real-time icon updates by publishing snapshots on connectivity checks; add unread message indicator (envelope) next to peers with unread DMs * UI: unread envelope uses orange; hasUnreadMessages checks Nostr conv key for peers with known Nostr pubkeys (geohash DM consistency) * Logs/robustness: debounce disconnect notifications (1.5s), debounce 'reconnected' logs (2s), add weak-link cooldown after timeouts on very weak RSSI (<= -90) * Peer icons: faster, accurate reachability\n\n- Run connectivity checks every maintenance tick (5s)\n- Publish peer snapshots on central unsubscribe for instant UI refresh\n- Lower inactivity timeout to 8s and disconnect debounce to 0.9s\n- Gate reachability on mesh-attached (>=1 direct link); no links => no reachable peers\n- Keep 21s retention for verified/unverified, but only when attached to mesh\n\nImproves list responsiveness when walking out of range and prevents stale 'reachable' states when isolated. --------- Co-authored-by: jack --- bitchat.xcodeproj/project.pbxproj | 23 +- bitchat/Identity/IdentityModels.swift | 2 + .../Identity/SecureIdentityStateManager.swift | 85 +++ bitchat/Models/BitchatPeer.swift | 10 +- bitchat/Services/BLEService.swift | 549 ++++++++++++++---- bitchat/Services/MessageRouter.swift | 20 +- bitchat/Services/NostrTransport.swift | 1 + bitchat/Services/RelayController.swift | 17 +- bitchat/Services/Transport.swift | 1 + bitchat/Services/TransportConfig.swift | 43 +- bitchat/Services/UnifiedPeerService.swift | 52 +- bitchat/ViewModels/ChatViewModel.swift | 24 +- bitchat/Views/ContentView.swift | 70 ++- bitchat/Views/FingerprintView.swift | 26 +- bitchat/Views/MeshPeerList.swift | 13 + 15 files changed, 725 insertions(+), 211 deletions(-) diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index 41f0019b..d14e89fd 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -328,7 +328,6 @@ A2E8C336FA1ADBEC03261DFD /* bitchatShareExtension */, C3D98EB3E1B455E321F519F4 /* bitchatTests */, 9F37F9F2C353B58AC809E93B /* Products */, - 048A4BE52E5CCC5C00162C4A /* Recovered References */, ); sourceTree = ""; }; @@ -743,14 +742,13 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */, - 1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */, + 048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */, + 1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */, AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */, AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */, A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */, AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */, 9B51E9B63A3EA59B1A7874BD /* BinaryEncodingUtils.swift in Sources */, - 049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */, 049BD3B52E51F319001A566B /* MessageRouter.swift in Sources */, 4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */, @@ -803,14 +801,13 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */, - 1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */, + 048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */, + 1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */, AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */, AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */, A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */, ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */, AFB6AEFCABBE97441CB3102B /* BinaryEncodingUtils.swift in Sources */, - 049BD3B22E51F319001A566B /* NostrTransport.swift in Sources */, 049BD3B32E51F319001A566B /* MessageRouter.swift in Sources */, F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */, @@ -1007,7 +1004,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.3.2; + MARKETING_VERSION = 1.3.3; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1038,7 +1035,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.2; + MARKETING_VERSION = 1.3.3; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_NAME = bitchat; SDKROOT = iphoneos; @@ -1093,7 +1090,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.2; + MARKETING_VERSION = 1.3.3; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_NAME = bitchat; SDKROOT = iphoneos; @@ -1125,7 +1122,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.3.1; + MARKETING_VERSION = 1.3.3; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_NAME = bitchat; REGISTER_APP_GROUPS = YES; @@ -1214,7 +1211,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.3.1; + MARKETING_VERSION = 1.3.3; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_NAME = bitchat; REGISTER_APP_GROUPS = YES; @@ -1307,7 +1304,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.3.2; + MARKETING_VERSION = 1.3.3; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; diff --git a/bitchat/Identity/IdentityModels.swift b/bitchat/Identity/IdentityModels.swift index 00eff84e..8b20aec6 100644 --- a/bitchat/Identity/IdentityModels.swift +++ b/bitchat/Identity/IdentityModels.swift @@ -106,6 +106,8 @@ enum HandshakeState { struct CryptographicIdentity: Codable { let fingerprint: String // SHA256 of public key let publicKey: Data // Noise static public key + // Optional Ed25519 signing public key (used to authenticate public messages) + var signingPublicKey: Data? = nil let firstSeen: Date let lastHandshake: Date? } diff --git a/bitchat/Identity/SecureIdentityStateManager.swift b/bitchat/Identity/SecureIdentityStateManager.swift index 2a836826..dcedf5e3 100644 --- a/bitchat/Identity/SecureIdentityStateManager.swift +++ b/bitchat/Identity/SecureIdentityStateManager.swift @@ -229,6 +229,91 @@ class SecureIdentityStateManager { return cache.socialIdentities[fingerprint] } } + + // MARK: - Cryptographic Identities + + /// Insert or update a cryptographic identity and optionally persist its signing key and claimed nickname. + /// - Parameters: + /// - fingerprint: SHA-256 hex of the Noise static public key + /// - noisePublicKey: Noise static public key data + /// - signingPublicKey: Optional Ed25519 signing public key for authenticating public messages + /// - claimedNickname: Optional latest claimed nickname to persist into social identity + func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String? = nil) { + queue.async(flags: .barrier) { + let now = Date() + if var existing = self.cryptographicIdentities[fingerprint] { + // Update keys if changed + if existing.publicKey != noisePublicKey { + existing = CryptographicIdentity( + fingerprint: fingerprint, + publicKey: noisePublicKey, + signingPublicKey: signingPublicKey ?? existing.signingPublicKey, + firstSeen: existing.firstSeen, + lastHandshake: now + ) + self.cryptographicIdentities[fingerprint] = existing + } else { + // Update signing key and lastHandshake + existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey + let updated = CryptographicIdentity( + fingerprint: existing.fingerprint, + publicKey: existing.publicKey, + signingPublicKey: existing.signingPublicKey, + firstSeen: existing.firstSeen, + lastHandshake: now + ) + self.cryptographicIdentities[fingerprint] = updated + } + // Persist updated state (already assigned in branches above) + } else { + // New entry + let entry = CryptographicIdentity( + fingerprint: fingerprint, + publicKey: noisePublicKey, + signingPublicKey: signingPublicKey, + firstSeen: now, + lastHandshake: now + ) + self.cryptographicIdentities[fingerprint] = entry + } + + // Optionally persist claimed nickname into social identity + if let claimed = claimedNickname { + var identity = self.cache.socialIdentities[fingerprint] ?? SocialIdentity( + fingerprint: fingerprint, + localPetname: nil, + claimedNickname: claimed, + trustLevel: .unknown, + isFavorite: false, + isBlocked: false, + notes: nil + ) + // Update claimed nickname if changed + if identity.claimedNickname != claimed { + identity.claimedNickname = claimed + self.cache.socialIdentities[fingerprint] = identity + } else if self.cache.socialIdentities[fingerprint] == nil { + self.cache.socialIdentities[fingerprint] = identity + } + } + + self.saveIdentityCache() + } + } + + /// Retrieve cryptographic identity by fingerprint + func getCryptographicIdentity(for fingerprint: String) -> CryptographicIdentity? { + queue.sync { cryptographicIdentities[fingerprint] } + } + + /// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID + func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] { + queue.sync { + // Defensive: ensure hex and correct length + guard peerID.count == 16, peerID.allSatisfy({ $0.isHexDigit }) else { return [] } + return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID) } + } + } func getAllSocialIdentities() -> [SocialIdentity] { queue.sync { diff --git a/bitchat/Models/BitchatPeer.swift b/bitchat/Models/BitchatPeer.swift index e33ce464..e8f82df0 100644 --- a/bitchat/Models/BitchatPeer.swift +++ b/bitchat/Models/BitchatPeer.swift @@ -8,6 +8,7 @@ struct BitchatPeer: Identifiable, Equatable { let nickname: String let lastSeen: Date let isConnected: Bool + let isReachable: Bool // Favorite-related properties var favoriteStatus: FavoritesPersistenceService.FavoriteRelationship? @@ -18,6 +19,7 @@ struct BitchatPeer: Identifiable, Equatable { // Connection state enum ConnectionState { case bluetoothConnected + case meshReachable // Seen via mesh recently, not directly connected case nostrAvailable // Mutual favorite, reachable via Nostr case offline // Not connected via any transport } @@ -25,6 +27,8 @@ struct BitchatPeer: Identifiable, Equatable { var connectionState: ConnectionState { if isConnected { return .bluetoothConnected + } else if isReachable { + return .meshReachable } else if favoriteStatus?.isMutual == true { // Mutual favorites can communicate via Nostr when offline return .nostrAvailable @@ -54,6 +58,8 @@ struct BitchatPeer: Identifiable, Equatable { switch connectionState { case .bluetoothConnected: return "📻" // Radio icon for mesh connection + case .meshReachable: + return "📡" // Antenna for mesh reachable case .nostrAvailable: return "🌐" // Purple globe for Nostr case .offline: @@ -71,13 +77,15 @@ struct BitchatPeer: Identifiable, Equatable { noisePublicKey: Data, nickname: String, lastSeen: Date = Date(), - isConnected: Bool = false + isConnected: Bool = false, + isReachable: Bool = false ) { self.id = id self.noisePublicKey = noisePublicKey self.nickname = nickname self.lastSeen = lastSeen self.isConnected = isConnected + self.isReachable = isReachable // Load favorite status - will be set later by the manager self.favoriteStatus = nil diff --git a/bitchat/Services/BLEService.swift b/bitchat/Services/BLEService.swift index 9a86ec56..e16cc698 100644 --- a/bitchat/Services/BLEService.swift +++ b/bitchat/Services/BLEService.swift @@ -102,6 +102,12 @@ final class BLEService: NSObject { // Queue for messages pending handshake completion private var pendingMessagesAfterHandshake: [String: [(content: String, messageID: String)]] = [:] + // Noise typed payloads (ACKs, read receipts, etc.) pending handshake + private var pendingNoisePayloadsAfterHandshake: [String: [Data]] = [:] + // Keep a tiny buffer of the last few unique announces we've seen (by sender) + private var recentAnnounceBySender: [String: BitchatPacket] = [:] + private var recentAnnounceOrder: [String] = [] + private let recentAnnounceBufferCap = 3 // Queue for notifications that failed due to full queue private var pendingNotifications: [(data: Data, centrals: [CBCentral]?)] = [] @@ -122,6 +128,13 @@ final class BLEService: NSObject { // Backpressure-aware write queue per peripheral private var pendingPeripheralWrites: [String: [Data]] = [:] + // Debounce duplicate disconnect notifies + private var recentDisconnectNotifies: [String: Date] = [:] + // Store-and-forward for directed messages when we have no links + // Keyed by recipient short peerID -> messageID -> (packet, enqueuedAt) + private var pendingDirectedRelays: [String: [String: (packet: BitchatPacket, enqueuedAt: Date)]] = [:] + // Debounce for 'reconnected' logs + private var lastReconnectLogAt: [String: Date] = [:] // MARK: - Maintenance Timer @@ -307,6 +320,11 @@ final class BLEService: NSObject { // Send any messages that were queued during handshake self?.messageQueue.async { [weak self] in self?.sendPendingMessagesAfterHandshake(for: peerID) + self?.sendPendingNoisePayloadsAfterHandshake(for: peerID) + } + // Proactive presence nudge: announce immediately after handshake + self?.messageQueue.async { [weak self] in + self?.sendAnnounce(forceSend: true) } } @@ -502,6 +520,27 @@ final class BLEService: NSObject { return collectionsQueue.sync { peers[shortID]?.isConnected ?? false } } + func isPeerReachable(_ peerID: String) -> Bool { + // Accept both 16-hex short IDs and 64-hex Noise keys + let shortID: String = { + if peerID.count == 64, let key = Data(hexString: peerID) { + return PeerIDUtils.derivePeerID(fromPublicKey: key) + } + return peerID + }() + return collectionsQueue.sync { + // Must be mesh-attached: at least one live direct link to the mesh + let meshAttached = peers.values.contains { $0.isConnected } + guard let info = peers[shortID] else { return false } + if info.isConnected { return true } + guard meshAttached else { return false } + // Apply reachability retention window + let isVerified = info.isVerifiedNickname + let retention: TimeInterval = isVerified ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds + return Date().timeIntervalSince(info.lastSeen) <= retention + } + } + func peerNickname(peerID: String) -> String? { collectionsQueue.sync { guard let peer = peers[peerID], peer.isConnected else { return nil } @@ -541,43 +580,41 @@ final class BLEService: NSObject { } func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) { - // Send encrypted read receipt - guard noiseService.hasSession(with: peerID) else { - SecureLogger.log("Cannot send read receipt - no Noise session with \(peerID)", category: SecureLogger.noise, level: .warning) - return - } - - SecureLogger.log("📤 Sending READ receipt for message \(receipt.originalMessageID) to \(peerID)", - category: SecureLogger.session, level: .debug) - - // Create read receipt payload: [type byte] + [message ID] - var receiptPayload = Data([NoisePayloadType.readReceipt.rawValue]) - receiptPayload.append(contentsOf: receipt.originalMessageID.utf8) - - do { - let encrypted = try noiseService.encrypt(receiptPayload, for: peerID) - let packet = BitchatPacket( - type: MessageType.noiseEncrypted.rawValue, - senderID: Data(hexString: myPeerID) ?? Data(), - recipientID: Data(hexString: peerID), - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: encrypted, - signature: nil, - ttl: messageTTL - ) - - // If already on messageQueue, call directly - if DispatchQueue.getSpecific(key: messageQueueKey) != nil { - broadcastPacket(packet) - } else { - messageQueue.async { [weak self] in - self?.broadcastPacket(packet) + // Create typed payload: [type byte] + [message ID] + var payload = Data([NoisePayloadType.readReceipt.rawValue]) + payload.append(contentsOf: receipt.originalMessageID.utf8) + + if noiseService.hasEstablishedSession(with: peerID) { + SecureLogger.log("📤 Sending READ receipt for message \(receipt.originalMessageID) to \(peerID)", + category: SecureLogger.session, level: .debug) + do { + let encrypted = try noiseService.encrypt(payload, for: peerID) + let packet = BitchatPacket( + type: MessageType.noiseEncrypted.rawValue, + senderID: Data(hexString: myPeerID) ?? Data(), + recipientID: Data(hexString: peerID), + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: encrypted, + signature: nil, + ttl: messageTTL + ) + if DispatchQueue.getSpecific(key: messageQueueKey) != nil { + broadcastPacket(packet) + } else { + messageQueue.async { [weak self] in self?.broadcastPacket(packet) } } + } catch { + SecureLogger.log("Failed to send read receipt: \(error)", category: SecureLogger.noise, level: .error) } - - // Read receipt sent - } catch { - SecureLogger.log("Failed to send read receipt: \(error)", category: SecureLogger.noise, level: .error) + } else { + // Queue for after handshake and initiate if needed + collectionsQueue.async(flags: .barrier) { [weak self] in + guard let self = self else { return } + self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload) + } + if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) } + SecureLogger.log("🕒 Queued READ receipt for \(peerID) until handshake completes", + category: SecureLogger.session, level: .debug) } } @@ -697,19 +734,26 @@ final class BLEService: NSObject { self.sendPrivateMessage(content, to: recipientID, messageID: finalMessageID) } else { // Public broadcast - // Public message - logged at relay point for mesh debugging - let packet = BitchatPacket( + // Create packet with explicit fields so we can sign it + let basePacket = BitchatPacket( type: MessageType.message.rawValue, - ttl: self.messageTTL, - senderID: self.myPeerID, - payload: Data(content.utf8) + senderID: Data(hexString: self.myPeerID) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: Data(content.utf8), + signature: nil, + ttl: self.messageTTL ) + guard let signedPacket = self.noiseService.signPacket(basePacket) else { + SecureLogger.log("❌ Failed to sign public message", category: SecureLogger.security, level: .error) + return + } // Pre-mark our own broadcast as processed to avoid handling relayed self copy - let senderHex = packet.senderID.hexEncodedString() - let dedupID = "\(senderHex)-\(packet.timestamp)-\(packet.type)" + let senderHex = signedPacket.senderID.hexEncodedString() + let dedupID = "\(senderHex)-\(signedPacket.timestamp)-\(signedPacket.type)" self.messageDeduplicator.markProcessed(dedupID) // Call synchronously since we're already on background queue - self.broadcastPacket(packet) + self.broadcastPacket(signedPacket) } } } @@ -1037,15 +1081,23 @@ final class BLEService: NSObject { } // For broadcast (no directed peer) and non-fragment, choose a subset deterministically + // Special-case announces: do NOT subset to maximize reach for presence var selectedPeripheralIDs = Set(allowedPeripheralIDs) var selectedCentralIDs = Set(allowedCentralIDs) - if directedOnlyPeer == nil && packet.type != MessageType.fragment.rawValue { + if directedOnlyPeer == nil && packet.type != MessageType.fragment.rawValue && packet.type != MessageType.announce.rawValue { let kp = subsetSizeForFanout(allowedPeripheralIDs.count) let kc = subsetSizeForFanout(allowedCentralIDs.count) selectedPeripheralIDs = selectDeterministicSubset(ids: allowedPeripheralIDs, k: kp, seed: messageID) selectedCentralIDs = selectDeterministicSubset(ids: allowedCentralIDs, k: kc, seed: messageID) } + // If directed and we currently have no links to forward on, spool for a short window + if let only = directedOnlyPeer, + selectedPeripheralIDs.isEmpty && selectedCentralIDs.isEmpty, + (packet.type == MessageType.noiseEncrypted.rawValue || packet.type == MessageType.noiseHandshake.rawValue) { + spoolDirectedPacket(packet, recipientPeerID: only) + } + // Writes to selected connected peripherals for s in states where s.isConnected { let pid = s.peripheral.identifier.uuidString @@ -1062,6 +1114,57 @@ final class BLEService: NSObject { } } } + + // MARK: - Directed store-and-forward + private func spoolDirectedPacket(_ packet: BitchatPacket, recipientPeerID: String) { + let msgID = makeMessageID(for: packet) + collectionsQueue.async(flags: .barrier) { [weak self] in + guard let self = self else { return } + var byMsg = self.pendingDirectedRelays[recipientPeerID] ?? [:] + if byMsg[msgID] == nil { + byMsg[msgID] = (packet: packet, enqueuedAt: Date()) + self.pendingDirectedRelays[recipientPeerID] = byMsg + SecureLogger.log("🧳 Spooling directed packet for \(recipientPeerID) mid=\(msgID.prefix(8))…", category: SecureLogger.session, level: .debug) + } + } + } + + private func flushDirectedSpool() { + // Move items out and attempt broadcast; if still no links, they'll be re-spooled + let toSend: [(String, BitchatPacket)] = collectionsQueue.sync(flags: .barrier) { + var out: [(String, BitchatPacket)] = [] + let now = Date() + for (recipient, dict) in pendingDirectedRelays { + for (_, entry) in dict { + if now.timeIntervalSince(entry.enqueuedAt) <= TransportConfig.bleDirectedSpoolWindowSeconds { + out.append((recipient, entry.packet)) + } + } + // Clear recipient bucket; items will be re-spooled if still no links + pendingDirectedRelays.removeValue(forKey: recipient) + } + return out + } + guard !toSend.isEmpty else { return } + for (_, packet) in toSend { + messageQueue.async { [weak self] in self?.broadcastPacket(packet) } + } + } + + private func rebroadcastRecentAnnounces() { + // Snapshot sender order to preserve ordering and avoid holding locks while sending + let packets: [BitchatPacket] = collectionsQueue.sync { + recentAnnounceOrder.compactMap { recentAnnounceBySender[$0] } + } + guard !packets.isEmpty else { return } + for (idx, pkt) in packets.enumerated() { + // Stagger slightly to avoid bursts + let delayMs = idx * 20 + messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs)) { [weak self] in + self?.broadcastPacket(pkt) + } + } + } private func sendData(_ data: Data, to peripheral: CBPeripheral) { // Fire-and-forget: Simple send without complex fallback logic @@ -1127,7 +1230,8 @@ final class BLEService: NSObject { ttl: packet.ttl ) // Pace fragments with small jitter to avoid bursts - let delayMs = index * TransportConfig.bleFragmentSpacingMs // ~6ms spacing per fragment + let perFragMs = (directedOnlyPeer != nil || packet.recipientID != nil) ? TransportConfig.bleFragmentSpacingDirectedMs : TransportConfig.bleFragmentSpacingMs + let delayMs = index * perFragMs messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs)) { [weak self] in self?.broadcastPacket(fragmentPacket) } @@ -1222,10 +1326,14 @@ final class BLEService: NSObject { SecureLogger.log("⚠️ Duplicate packet ignored: \(messageID)", category: SecureLogger.session, level: .debug) } - // Cancel any pending relay for this message (arrived via another neighbor) - collectionsQueue.async(flags: .barrier) { [weak self] in - if let task = self?.scheduledRelays.removeValue(forKey: messageID) { - task.cancel() + // In sparse graphs (<=2 neighbors), keep the pending relay to ensure bridging. + // In denser graphs, cancel the pending relay to reduce redundant floods. + let connectedCount = collectionsQueue.sync { peers.values.filter { $0.isConnected }.count } + if connectedCount > 2 { + collectionsQueue.async(flags: .barrier) { [weak self] in + if let task = self?.scheduledRelays.removeValue(forKey: messageID) { + task.cancel() + } } } return // Duplicate ignored @@ -1284,6 +1392,7 @@ final class BLEService: NSObject { isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue) && (packet.recipientID != nil), isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil, isHandshake: packet.type == MessageType.noiseHandshake.rawValue, + isAnnounce: packet.type == MessageType.announce.rawValue, degree: degree, highDegreeThreshold: highDegreeThreshold ) @@ -1334,11 +1443,14 @@ final class BLEService: NSObject { collectionsQueue.sync(flags: .barrier) { // Check if we have an actual BLE connection to this peer let peripheralUUID = peerToPeripheralUUID[peerID] - _ = peripheralUUID != nil && peripherals[peripheralUUID!]?.isConnected == true // hasPeripheralConnection + let hasPeripheralConnection = peripheralUUID != nil && peripherals[peripheralUUID!]?.isConnected == true // Check if this peer is subscribed to us as a central // Note: We can't identify which specific central is which peer without additional mapping - _ = !subscribedCentrals.isEmpty // hasCentralSubscription + let hasCentralSubscription = centralToPeerID.values.contains(peerID) + + // Direct announces arrive with full TTL (no prior hop) + let isDirectAnnounce = (packet.ttl == messageTTL) // Check if we already have this peer (might be reconnecting) let existingPeer = peers[peerID] @@ -1376,7 +1488,7 @@ final class BLEService: NSObject { peers[peerID] = PeerInfo( id: existing.id, nickname: announcement.nickname, - isConnected: true, + isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription, noisePublicKey: announcement.noisePublicKey, signingPublicKey: announcement.signingPublicKey, isVerifiedNickname: true, @@ -1387,7 +1499,7 @@ final class BLEService: NSObject { peers[peerID] = PeerInfo( id: peerID, nickname: announcement.nickname, - isConnected: true, + isConnected: isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription, noisePublicKey: announcement.noisePublicKey, signingPublicKey: announcement.signingPublicKey, isVerifiedNickname: true, @@ -1395,16 +1507,52 @@ final class BLEService: NSObject { ) } - // Log connection status - if existingPeer == nil { - SecureLogger.log("🆕 New peer: \(announcement.nickname)", category: SecureLogger.session, level: .debug) - } else if wasDisconnected { - SecureLogger.log("🔄 Peer \(announcement.nickname) reconnected", category: SecureLogger.session, level: .debug) - } else if existingPeer?.nickname != announcement.nickname { - SecureLogger.log("🔄 Peer \(peerID) changed nickname: \(existingPeer?.nickname ?? "Unknown") -> \(announcement.nickname)", category: SecureLogger.session, level: .debug) + // Log connection status only for direct connectivity changes; debounce to reduce spam + if isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription { + let now = Date() + if existingPeer == nil { + SecureLogger.log("🆕 New peer: \(announcement.nickname)", category: SecureLogger.session, level: .debug) + } else if wasDisconnected { + // Debounce 'reconnected' logs within short window + if let last = lastReconnectLogAt[peerID], now.timeIntervalSince(last) < TransportConfig.bleReconnectLogDebounceSeconds { + // Skip duplicate log + } else { + SecureLogger.log("🔄 Peer \(announcement.nickname) reconnected", category: SecureLogger.session, level: .debug) + lastReconnectLogAt[peerID] = now + } + } else if existingPeer?.nickname != announcement.nickname { + SecureLogger.log("🔄 Peer \(peerID) changed nickname: \(existingPeer?.nickname ?? "Unknown") -> \(announcement.nickname)", category: SecureLogger.session, level: .debug) + } } } - + + // Persist cryptographic identity and signing key for robust offline verification + do { + // Derive fingerprint from Noise public key + let hash = SHA256.hash(data: announcement.noisePublicKey) + let fingerprint = hash.map { String(format: "%02x", $0) }.joined() + SecureIdentityStateManager.shared.upsertCryptographicIdentity( + fingerprint: fingerprint, + noisePublicKey: announcement.noisePublicKey, + signingPublicKey: announcement.signingPublicKey, + claimedNickname: announcement.nickname + ) + } + + // Record this announce for lightweight rebroadcast buffer (exclude self) + if peerID != myPeerID { + collectionsQueue.async(flags: .barrier) { [weak self] in + guard let self = self else { return } + self.recentAnnounceBySender[peerID] = packet + if !self.recentAnnounceOrder.contains(peerID) { self.recentAnnounceOrder.append(peerID) } + // Trim to cap, oldest first + while self.recentAnnounceOrder.count > self.recentAnnounceBufferCap { + let victim = self.recentAnnounceOrder.removeFirst() + self.recentAnnounceBySender.removeValue(forKey: victim) + } + } + } + // Notify UI on main thread notifyUI { [weak self] in guard let self = self else { return } @@ -1412,8 +1560,8 @@ final class BLEService: NSObject { // Get current peer list (after addition) let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) } - // Only notify of connection for new or reconnected peers - if isNewPeer || isReconnectedPeer { + // Only notify of connection for new or reconnected peers when it is a direct announce + if (packet.ttl == self.messageTTL) && (isNewPeer || isReconnectedPeer) { self.delegate?.didConnectToPeer(peerID) } @@ -1433,6 +1581,14 @@ final class BLEService: NSObject { // 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 isNewPeer { + let delay = Double.random(in: 0.3...0.6) + messageQueue.asyncAfter(deadline: .now() + delay) { [weak self] in + self?.sendAnnounce(forceSend: true) + } + } } // Mention parsing moved to ChatViewModel @@ -1440,9 +1596,41 @@ final class BLEService: NSObject { private func handleMessage(_ packet: BitchatPacket, from peerID: String) { // Ignore self-origin public messages that may be seen again via relay if peerID == myPeerID { return } - - // Enforce: only accept public messages from verified peers we know - guard let info = peers[peerID], info.isVerifiedNickname else { + + var accepted = false + var senderNickname: String = "" + + if let info = peers[peerID], info.isVerifiedNickname { + // Known verified peer path + accepted = true + senderNickname = info.nickname + // Handle nickname collisions + let hasCollision = peers.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.id != peerID } || (myNickname == info.nickname) + if hasCollision { + senderNickname += "#" + String(peerID.prefix(4)) + } + } else { + // Fallback: verify signature using persisted signing key for this peerID's fingerprint prefix + if let signature = packet.signature, let packetData = packet.toBinaryDataForSigning() { + // Find candidate identities by peerID prefix (16 hex) + let candidates = SecureIdentityStateManager.shared.getCryptoIdentitiesByPeerIDPrefix(peerID) + for candidate in candidates { + if let signingKey = candidate.signingPublicKey, + noiseService.verifySignature(signature, for: packetData, publicKey: signingKey) { + accepted = true + // Prefer persisted social petname or claimed nickname + if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: candidate.fingerprint) { + senderNickname = social.localPetname ?? social.claimedNickname + } else { + senderNickname = "anon" + String(peerID.prefix(4)) + } + break + } + } + } + } + + guard accepted else { SecureLogger.log("🚫 Dropping public message from unverified or unknown peer \(peerID.prefix(8))…", category: SecureLogger.security, level: .warning) return } @@ -1451,17 +1639,17 @@ final class BLEService: NSObject { SecureLogger.log("❌ Failed to decode message payload as UTF-8", category: SecureLogger.session, level: .error) return } - - // Resolve display nickname; if collisions exist, append short peerID suffix - var senderNickname = info.nickname - // Treat a collision if another connected peer shares the nickname OR our own nickname matches - let hasCollision = peers.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.id != peerID } || (myNickname == info.nickname) - if hasCollision { - senderNickname += "#" + String(peerID.prefix(4)) + // Determine if we have a direct link to the sender + let hasDirectLink: Bool = collectionsQueue.sync { + let perUUID = peerToPeripheralUUID[peerID] + let perConnected = perUUID != nil && peripherals[perUUID!]?.isConnected == true + let hasCentral = centralToPeerID.values.contains(peerID) + return perConnected || hasCentral } - SecureLogger.log("💬 [\(senderNickname)] TTL:\(packet.ttl): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: SecureLogger.session, level: .debug) - + let pathTag = hasDirectLink ? "direct" : "mesh" + SecureLogger.log("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: SecureLogger.session, level: .debug) + let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) notifyUI { [weak self] in self?.delegate?.didReceivePublicMessage(from: peerID, nickname: senderNickname, content: content, timestamp: ts) @@ -1659,31 +1847,64 @@ final class BLEService: NSObject { } func sendDeliveryAck(for messageID: String, to peerID: String) { - // Send encrypted delivery ACK - guard noiseService.hasSession(with: peerID) else { - SecureLogger.log("Cannot send ACK - no Noise session with \(peerID)", category: SecureLogger.noise, level: .warning) - return + // Create typed payload: [type byte] + [message ID] + var payload = Data([NoisePayloadType.delivered.rawValue]) + payload.append(contentsOf: messageID.utf8) + + if noiseService.hasEstablishedSession(with: peerID) { + do { + let encrypted = try noiseService.encrypt(payload, for: peerID) + let packet = BitchatPacket( + type: MessageType.noiseEncrypted.rawValue, + senderID: Data(hexString: myPeerID) ?? Data(), + recipientID: Data(hexString: peerID), + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: encrypted, + signature: nil, + ttl: messageTTL + ) + broadcastPacket(packet) + } catch { + SecureLogger.log("Failed to send delivery ACK: \(error)", category: SecureLogger.noise, level: .error) + } + } else { + // Queue for after handshake and initiate if needed + collectionsQueue.async(flags: .barrier) { [weak self] in + guard let self = self else { return } + self.pendingNoisePayloadsAfterHandshake[peerID, default: []].append(payload) + } + if !noiseService.hasSession(with: peerID) { initiateNoiseHandshake(with: peerID) } + SecureLogger.log("🕒 Queued DELIVERED ack for \(peerID) until handshake completes", + category: SecureLogger.session, level: .debug) } - - // Create ACK payload: [type byte] + [message ID] - var ackPayload = Data([NoisePayloadType.delivered.rawValue]) - ackPayload.append(contentsOf: messageID.utf8) - - do { - let encrypted = try noiseService.encrypt(ackPayload, for: peerID) - let packet = BitchatPacket( - type: MessageType.noiseEncrypted.rawValue, - senderID: Data(hexString: myPeerID) ?? Data(), - recipientID: Data(hexString: peerID), - timestamp: UInt64(Date().timeIntervalSince1970 * 1000), - payload: encrypted, - signature: nil, - ttl: messageTTL - ) - broadcastPacket(packet) - // Delivery ACK sent - } catch { - SecureLogger.log("Failed to send delivery ACK: \(error)", category: SecureLogger.noise, level: .error) + } + + private func sendPendingNoisePayloadsAfterHandshake(for peerID: String) { + let payloads = collectionsQueue.sync(flags: .barrier) { () -> [Data] in + let list = pendingNoisePayloadsAfterHandshake[peerID] ?? [] + pendingNoisePayloadsAfterHandshake.removeValue(forKey: peerID) + return list + } + guard !payloads.isEmpty else { return } + SecureLogger.log("📤 Sending \(payloads.count) pending noise payloads to \(peerID) after handshake", + category: SecureLogger.session, level: .debug) + for payload in payloads { + do { + let encrypted = try noiseService.encrypt(payload, for: peerID) + let packet = BitchatPacket( + type: MessageType.noiseEncrypted.rawValue, + senderID: Data(hexString: myPeerID) ?? Data(), + recipientID: Data(hexString: peerID), + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: encrypted, + signature: nil, + ttl: messageTTL + ) + broadcastPacket(packet) + } catch { + SecureLogger.log("❌ Failed to send pending noise payload to \(peerID): \(error)", + category: SecureLogger.noise, level: .error) + } } } @@ -1696,6 +1917,18 @@ final class BLEService: NSObject { } } } + + // Debounced disconnect notifier to avoid duplicate disconnect callbacks within a short window + private func notifyPeerDisconnectedDebounced(_ peerID: String) { + let now = Date() + let last = recentDisconnectNotifies[peerID] + if last == nil || now.timeIntervalSince(last!) >= TransportConfig.bleDisconnectNotifyDebounceSeconds { + delegate?.didDisconnectFromPeer(peerID) + recentDisconnectNotifies[peerID] = now + } else { + // Suppressed duplicate disconnect notification + } + } // NEW: Publish peer snapshots to subscribers and notify Transport delegates private func publishFullPeerData() { @@ -1748,6 +1981,16 @@ final class BLEService: NSObject { let target = base + Double.random(in: -jitter...jitter) if elapsed >= target { sendAnnounce(forceSend: true) } } + + // Activity-driven quick-announce: if we've seen any packet in last 5s and it has + // been >=10s since the last announce, send a presence nudge. + let recentSeen = collectionsQueue.sync { () -> Bool in + let cutoff = now.addingTimeInterval(-5.0) + return recentPacketTimestamps.contains(where: { $0 >= cutoff }) + } + if recentSeen && elapsed >= 10.0 { + sendAnnounce(forceSend: true) + } // If we have no peers, ensure we're scanning and advertising if peers.isEmpty { @@ -1761,15 +2004,18 @@ final class BLEService: NSObject { updateScanningDutyCycle(connectedCount: connectedCount) updateRSSIThreshold(connectedCount: connectedCount) - // Every 20 seconds (2 cycles): Check peer connectivity - if maintenanceCounter % 2 == 0 { - checkPeerConnectivity() - } + // Check peer connectivity every cycle for snappier UI updates + checkPeerConnectivity() // Every 30 seconds (3 cycles): Cleanup if maintenanceCounter % 3 == 0 { performCleanup() } + + // Attempt to flush any spooled directed messages periodically (~every 5 seconds) + if maintenanceCounter % 2 == 1 { + flushDirectedSpool() + } // No rotating alias: nothing to refresh @@ -1783,28 +2029,39 @@ final class BLEService: NSObject { let now = Date() var disconnectedPeers: [String] = [] + var removedOfflineCount = 0 collectionsQueue.sync(flags: .barrier) { for (peerID, peer) in peers { - if peer.isConnected && now.timeIntervalSince(peer.lastSeen) > TransportConfig.blePeerInactivityTimeoutSeconds { + let age = now.timeIntervalSince(peer.lastSeen) + let retention: TimeInterval = peer.isVerifiedNickname ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds + if peer.isConnected && age > TransportConfig.blePeerInactivityTimeoutSeconds { // Check if we still have an active BLE connection to this peer let hasPeripheralConnection = peerToPeripheralUUID[peerID] != nil && peripherals[peerToPeripheralUUID[peerID]!]?.isConnected == true let hasCentralConnection = centralToPeerID.values.contains(peerID) - // Only remove if we don't have an active BLE connection + // If direct link is gone, mark as not connected (retain entry for reachability) if !hasPeripheralConnection && !hasCentralConnection { - // Remove the peer completely (they'll be re-added when they reconnect) - SecureLogger.log("⏱️ Peer timed out (no packets for 20s): \(peerID) (\(peer.nickname))", - category: SecureLogger.session, level: .debug) - peers.removeValue(forKey: peerID) + var updated = peer + updated.isConnected = false + peers[peerID] = updated disconnectedPeers.append(peerID) } } + // Cleanup: remove peers that are not connected and past reachability retention + if !peer.isConnected { + if age > retention { + SecureLogger.log("🗑️ Removing stale peer after reachability window: \(peerID) (\(peer.nickname))", + category: SecureLogger.session, level: .debug) + peers.removeValue(forKey: peerID) + removedOfflineCount += 1 + } + } } } - // Update UI if any peers were disconnected - if !disconnectedPeers.isEmpty { + // Update UI if there were direct disconnections or offline removals + if !disconnectedPeers.isEmpty || removedOfflineCount > 0 { notifyUI { [weak self] in guard let self = self else { return } @@ -1814,6 +2071,8 @@ final class BLEService: NSObject { for peerID in disconnectedPeers { self.delegate?.didDisconnectFromPeer(peerID) } + // Publish snapshots so UnifiedPeerService updates connection/reachability icons + self.publishFullPeerData() self.delegate?.didUpdatePeerList(currentPeerIDs) } } @@ -1857,6 +2116,15 @@ final class BLEService: NSObject { if !self.ingressByMessageID.isEmpty { self.ingressByMessageID = self.ingressByMessageID.filter { $0.value.timestamp >= cutoff } } + // Clean expired directed spooled items + if !self.pendingDirectedRelays.isEmpty { + var cleaned: [String: [String: (packet: BitchatPacket, enqueuedAt: Date)]] = [:] + for (recipient, dict) in self.pendingDirectedRelays { + let pruned = dict.filter { now.timeIntervalSince($0.value.enqueuedAt) <= TransportConfig.bleDirectedSpoolWindowSeconds } + if !pruned.isEmpty { cleaned[recipient] = pruned } + } + self.pendingDirectedRelays = cleaned + } } } @@ -1868,7 +2136,13 @@ final class BLEService: NSObject { #else let active = true #endif - let shouldDuty = dutyEnabled && active && connectedCount > 0 + // Force full-time scanning if we have very few neighbors or very recent traffic + let hasRecentTraffic: Bool = collectionsQueue.sync { + let cutoff = Date().addingTimeInterval(-TransportConfig.bleRecentTrafficForceScanSeconds) + return recentPacketTimestamps.contains(where: { $0 >= cutoff }) + } + let forceScanOn = (connectedCount <= 2) || hasRecentTraffic + let shouldDuty = dutyEnabled && active && connectedCount > 0 && !forceScanOn if shouldDuty { if scanDutyTimer == nil { // Start timer to toggle scanning on/off @@ -2137,6 +2411,11 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph SecureLogger.log("📱 Disconnect: \(peerID ?? peripheralID)\(error != nil ? " (\(error!.localizedDescription))" : "")", category: SecureLogger.session, level: .debug) + + // If disconnect carried an error (often timeout), apply short backoff to avoid thrash + if error != nil { + recentConnectTimeouts[peripheralID] = Date() + } // Clean up references peripherals.removeValue(forKey: peripheralID) @@ -2145,9 +2424,12 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph if let peerID = peerID { peerToPeripheralUUID.removeValue(forKey: peerID) - // Remove peer completely (they'll be re-added when they reconnect and announce) - _ = collectionsQueue.sync(flags: .barrier) { - peers.removeValue(forKey: peerID) + // Do not remove peer; mark as not connected but retain for reachability + collectionsQueue.sync(flags: .barrier) { + if var info = peers[peerID] { + info.isConnected = false + peers[peerID] = info + } } } @@ -2162,7 +2444,7 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph // Attempt to fill freed slot from queue bleQueue.async { [weak self] in self?.tryConnectFromQueue() } - // Notify delegate about disconnection on main thread + // Notify delegate about disconnection on main thread (direct link dropped) notifyUI { [weak self] in guard let self = self else { return } @@ -2170,7 +2452,7 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) } if let peerID = peerID { - self.delegate?.didDisconnectFromPeer(peerID) + self.notifyPeerDisconnectedDebounced(peerID) } self.publishFullPeerData() self.delegate?.didUpdatePeerList(currentPeerIDs) @@ -2222,6 +2504,18 @@ extension BLEService { guard candidate.isConnectable else { return } let peripheral = candidate.peripheral let peripheralID = peripheral.identifier.uuidString + // Weak-link cooldown: if we recently timed out and RSSI is very weak, delay retries + if let lastTO = recentConnectTimeouts[peripheralID] { + let elapsed = Date().timeIntervalSince(lastTO) + if elapsed < TransportConfig.bleWeakLinkCooldownSeconds && candidate.rssi <= TransportConfig.bleWeakLinkRSSICutoff { + // Requeue the candidate and try again later + connectionCandidates.append(candidate) + let remaining = TransportConfig.bleWeakLinkCooldownSeconds - elapsed + let delay = min(max(2.0, remaining), 15.0) + bleQueue.asyncAfter(deadline: .now() + delay) { [weak self] in self?.tryConnectFromQueue() } + return + } + } if peripherals[peripheralID]?.isConnected == true || peripherals[peripheralID]?.isConnecting == true { // Already in progress; skip bleQueue.async { [weak self] in self?.tryConnectFromQueue() } @@ -2355,6 +2649,10 @@ extension BLEService: CBPeripheralDelegate { // Send announce after subscription is confirmed (force send for new connection) messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostSubscribeAnnounceDelaySeconds) { [weak self] in self?.sendAnnounce(forceSend: true) + // Try flushing any spooled directed packets now that we have a link + self?.flushDirectedSpool() + // Rebroadcast a couple of recent announces to seed the new link + self?.rebroadcastRecentAnnounces() } } else { SecureLogger.log("⚠️ Characteristic does not support notifications", category: SecureLogger.session, level: .warning) @@ -2517,6 +2815,10 @@ extension BLEService: CBPeripheralManagerDelegate { // Send announce to the newly subscribed central after a small delay to avoid overwhelming messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in self?.sendAnnounce(forceSend: true) + // Flush any spooled directed packets now that we have a central subscribed + self?.flushDirectedSpool() + // Rebroadcast a couple of recent announces to seed the new link + self?.rebroadcastRecentAnnounces() } } @@ -2533,9 +2835,12 @@ extension BLEService: CBPeripheralManagerDelegate { // Find and disconnect the peer associated with this central let centralUUID = central.identifier.uuidString if let peerID = centralToPeerID[centralUUID] { - // Remove peer completely (they'll be re-added when they reconnect) - _ = collectionsQueue.sync(flags: .barrier) { - peers.removeValue(forKey: peerID) + // Mark peer as not connected; retain for reachability + collectionsQueue.sync(flags: .barrier) { + if var info = peers[peerID] { + info.isConnected = false + peers[peerID] = info + } } // Clean up mappings @@ -2548,7 +2853,9 @@ extension BLEService: CBPeripheralManagerDelegate { // Get current peer list (after removal) let currentPeerIDs = self.collectionsQueue.sync { Array(self.peers.keys) } - self.delegate?.didDisconnectFromPeer(peerID) + self.notifyPeerDisconnectedDebounced(peerID) + // Publish snapshots so UnifiedPeerService can refresh icons promptly + self.publishFullPeerData() self.delegate?.didUpdatePeerList(currentPeerIDs) } } diff --git a/bitchat/Services/MessageRouter.swift b/bitchat/Services/MessageRouter.swift index 1bf2bd4e..5937bdec 100644 --- a/bitchat/Services/MessageRouter.swift +++ b/bitchat/Services/MessageRouter.swift @@ -37,11 +37,11 @@ final class MessageRouter { } func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) { - let hasMesh = mesh.isPeerConnected(peerID) - let hasEstablished = mesh.getNoiseService().hasEstablishedSession(with: peerID) - if hasMesh && hasEstablished { - SecureLogger.log("Routing PM via mesh to \(peerID.prefix(8))… id=\(messageID.prefix(8))…", + let reachableMesh = mesh.isPeerReachable(peerID) + if reachableMesh { + SecureLogger.log("Routing PM via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: SecureLogger.session, level: .debug) + // 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.log("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))…", @@ -57,9 +57,9 @@ final class MessageRouter { } func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) { - // Prefer mesh only if a Noise session is established; else use Nostr to avoid handshakeRequired spam - if mesh.isPeerConnected(peerID) && mesh.getNoiseService().hasEstablishedSession(with: peerID) { - SecureLogger.log("Routing READ ack via mesh to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", + // Prefer mesh for reachable peers; BLE will queue if handshake is needed + if mesh.isPeerReachable(peerID) { + SecureLogger.log("Routing READ ack via mesh (reachable) to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: SecureLogger.session, level: .debug) mesh.sendReadReceipt(receipt, to: peerID) } else { @@ -70,7 +70,9 @@ final class MessageRouter { } func sendDeliveryAck(_ messageID: String, to peerID: String) { - if mesh.isPeerConnected(peerID) && mesh.getNoiseService().hasEstablishedSession(with: peerID) { + if mesh.isPeerReachable(peerID) { + SecureLogger.log("Routing DELIVERED ack via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))…", + category: SecureLogger.session, level: .debug) mesh.sendDeliveryAck(for: messageID, to: peerID) } else { nostr.sendDeliveryAck(for: messageID, to: peerID) @@ -101,7 +103,7 @@ final class MessageRouter { category: SecureLogger.session, level: .debug) // Prefer mesh if connected; else try Nostr if mapping exists for (content, nickname, messageID) in queued { - if mesh.isPeerConnected(peerID) { + if mesh.isPeerReachable(peerID) { SecureLogger.log("Outbox -> mesh for \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: SecureLogger.session, level: .debug) mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID) diff --git a/bitchat/Services/NostrTransport.swift b/bitchat/Services/NostrTransport.swift index b19a6924..c1fad64d 100644 --- a/bitchat/Services/NostrTransport.swift +++ b/bitchat/Services/NostrTransport.swift @@ -31,6 +31,7 @@ final class NostrTransport: Transport { func emergencyDisconnectAll() { /* no-op */ } func isPeerConnected(_ peerID: String) -> Bool { false } + func isPeerReachable(_ peerID: String) -> Bool { false } func peerNickname(peerID: String) -> String? { nil } func getPeerNicknames() -> [String : String] { [:] } diff --git a/bitchat/Services/RelayController.swift b/bitchat/Services/RelayController.swift index ef3087ca..45291c66 100644 --- a/bitchat/Services/RelayController.swift +++ b/bitchat/Services/RelayController.swift @@ -15,6 +15,7 @@ struct RelayController { isDirectedEncrypted: Bool, isDirectedFragment: Bool, isHandshake: Bool, + isAnnounce: Bool, degree: Int, highDegreeThreshold: Int) -> RelayDecision { // Suppress obvious non-relays @@ -25,7 +26,8 @@ struct RelayController { // Always relay with no TTL cap for these types let newTTL = (ttl &- 1) // Slight jitter to desynchronize without adding too much latency - let delayRange: ClosedRange = isHandshake ? 20...60 : 40...120 + // Tighter for faster multi-hop handshakes and directed DMs + let delayRange: ClosedRange = isHandshake ? 10...35 : 20...60 let delayMs = Int.random(in: delayRange) return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs) } @@ -42,15 +44,22 @@ struct RelayController { let prob = baseProb let shouldRelay = Double.random(in: 0...1) <= prob - // TTL clamping in dense graphs (only for broadcast) - let ttlCap: UInt8 = degree >= highDegreeThreshold ? 3 : 5 + // TTL clamping for broadcast + // - Dense graphs: keep very low to avoid floods + // - Sparse graphs: allow slightly longer reach for multi-hop discovery + // - Announces in sparse graphs get a bit more headroom + let ttlCap: UInt8 = { + if degree >= highDegreeThreshold { return 3 } + return isAnnounce ? 7 : 6 + }() let clamped = max(1, min(ttl, ttlCap)) let newTTL = clamped &- 1 // Wider jitter window to allow duplicate suppression to win more often + // For sparse graphs (<=2), relay quickly to avoid cancellation races let delayMs: Int switch degree { - case 0...2: delayMs = Int.random(in: 40...100) + case 0...2: delayMs = Int.random(in: 10...40) case 3...5: delayMs = Int.random(in: 60...150) case 6...9: delayMs = Int.random(in: 80...180) default: delayMs = Int.random(in: 100...220) diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift index 372e3e3b..fe352a83 100644 --- a/bitchat/Services/Transport.swift +++ b/bitchat/Services/Transport.swift @@ -29,6 +29,7 @@ protocol Transport: AnyObject { // Connectivity and peers func isPeerConnected(_ peerID: String) -> Bool + func isPeerReachable(_ peerID: String) -> Bool func peerNickname(peerID: String) -> String? func getPeerNicknames() -> [String: String] diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index 56a1da75..3614a7c9 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -62,7 +62,7 @@ enum TransportConfig { static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60 // BLE maintenance & thresholds - static let bleMaintenanceInterval: TimeInterval = 10.0 + static let bleMaintenanceInterval: TimeInterval = 5.0 static let bleMaintenanceLeewaySeconds: Int = 1 static let bleIsolationRelaxThresholdSeconds: TimeInterval = 60 static let bleRecentTimeoutWindowSeconds: TimeInterval = 60 @@ -71,23 +71,32 @@ enum TransportConfig { static let bleRSSIIsolatedRelaxed: Int = -92 static let bleRSSIConnectedThreshold: Int = -85 static let bleRSSIHighTimeoutThreshold: Int = -80 - static let blePeerInactivityTimeoutSeconds: TimeInterval = 20.0 + // How long without seeing traffic before we sanity-check the direct link + // Lowered to make connected→reachable icon changes react faster when walking out of range + static let blePeerInactivityTimeoutSeconds: TimeInterval = 8.0 + // How long to retain a peer as "reachable" (not directly connected) since lastSeen + static let bleReachabilityRetentionVerifiedSeconds: TimeInterval = 21.0 // 21s for verified/favorites + static let bleReachabilityRetentionUnverifiedSeconds: TimeInterval = 21.0 // 21s for unknown/unverified static let bleFragmentLifetimeSeconds: TimeInterval = 30.0 static let bleIngressRecordLifetimeSeconds: TimeInterval = 3.0 static let bleConnectTimeoutBackoffWindowSeconds: TimeInterval = 120.0 static let bleRecentPacketWindowSeconds: TimeInterval = 30.0 static let bleRecentPacketWindowMaxCount: Int = 100 + // Keep scanning fully ON when we saw traffic very recently + static let bleRecentTrafficForceScanSeconds: TimeInterval = 10.0 static let bleThreadSleepWriteShortDelaySeconds: TimeInterval = 0.05 static let bleExpectedWritePerFragmentMs: Int = 8 static let bleExpectedWriteMaxMs: Int = 2000 - static let bleFragmentSpacingMs: Int = 6 - static let bleAnnounceIntervalSeconds: TimeInterval = 10.0 + // Faster fragment pacing; use slightly tighter spacing for directed trains + static let bleFragmentSpacingMs: Int = 5 + static let bleFragmentSpacingDirectedMs: Int = 4 + static let bleAnnounceIntervalSeconds: TimeInterval = 4.0 static let bleDutyOnDurationDense: TimeInterval = 3.0 static let bleDutyOffDurationDense: TimeInterval = 15.0 - static let bleConnectedAnnounceBaseSecondsDense: TimeInterval = 90.0 - static let bleConnectedAnnounceBaseSecondsSparse: TimeInterval = 45.0 - static let bleConnectedAnnounceJitterDense: TimeInterval = 20.0 - static let bleConnectedAnnounceJitterSparse: TimeInterval = 7.5 + static let bleConnectedAnnounceBaseSecondsDense: TimeInterval = 30.0 + static let bleConnectedAnnounceBaseSecondsSparse: TimeInterval = 15.0 + static let bleConnectedAnnounceJitterDense: TimeInterval = 8.0 + static let bleConnectedAnnounceJitterSparse: TimeInterval = 4.0 // Location static let locationDistanceFilterMeters: Double = 1000 @@ -126,12 +135,24 @@ enum TransportConfig { static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24 // BLE operational delays - static let bleInitialAnnounceDelaySeconds: TimeInterval = 2.0 + static let bleInitialAnnounceDelaySeconds: TimeInterval = 0.6 static let bleConnectTimeoutSeconds: TimeInterval = 8.0 static let bleRestartScanDelaySeconds: TimeInterval = 0.1 - static let blePostSubscribeAnnounceDelaySeconds: TimeInterval = 0.1 + static let blePostSubscribeAnnounceDelaySeconds: TimeInterval = 0.05 static let blePostAnnounceDelaySeconds: TimeInterval = 0.4 - static let bleForceAnnounceMinIntervalSeconds: TimeInterval = 0.2 + static let bleForceAnnounceMinIntervalSeconds: TimeInterval = 0.15 + + // Store-and-forward for directed packets at relays + static let bleDirectedSpoolWindowSeconds: TimeInterval = 15.0 + + // Log/UI debounce windows + // Shorter debounce so UI reacts faster while still suppressing duplicate callbacks + static let bleDisconnectNotifyDebounceSeconds: TimeInterval = 0.9 + static let bleReconnectLogDebounceSeconds: TimeInterval = 2.0 + + // Weak-link cooldown after connection timeouts + static let bleWeakLinkCooldownSeconds: TimeInterval = 30.0 + static let bleWeakLinkRSSICutoff: Int = -90 // Content hashing / formatting static let contentKeyPrefixLength: Int = 256 diff --git a/bitchat/Services/UnifiedPeerService.swift b/bitchat/Services/UnifiedPeerService.swift index aa04b7ff..9675a4aa 100644 --- a/bitchat/Services/UnifiedPeerService.swift +++ b/bitchat/Services/UnifiedPeerService.swift @@ -68,24 +68,28 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { private func updatePeers() { let meshPeers = meshService.currentPeerSnapshots() + // If we have no direct links at all, peers should not be marked reachable + // "Reachable" means mesh-attached via at least one live link. + let hasAnyConnected = meshPeers.contains { $0.isConnected } let favorites = favoritesService.favorites var enrichedPeers: [BitchatPeer] = [] var connected: Set = [] var addedPeerIDs: Set = [] - // Phase 1: Add all connected mesh peers - for peerInfo in meshPeers where peerInfo.isConnected { + // Phase 1: Add all mesh peers (connected and reachable) + for peerInfo in meshPeers { let peerID = peerInfo.id guard peerID != meshService.myPeerID else { continue } // Never add self let peer = buildPeerFromMesh( peerInfo: peerInfo, - favorites: favorites + favorites: favorites, + meshAttached: hasAnyConnected ) enrichedPeers.append(peer) - connected.insert(peerID) + if peer.isConnected { connected.insert(peerID) } addedPeerIDs.insert(peerID) // Update fingerprint cache @@ -117,14 +121,12 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { // Phase 3: Sort peers enrichedPeers.sort { lhs, rhs in - // Connected first - if lhs.isConnected != rhs.isConnected { - return lhs.isConnected - } - // Then favorites - if lhs.isFavorite != rhs.isFavorite { - return lhs.isFavorite - } + // Connectivity rank: connected > reachable > others + func rank(_ p: BitchatPeer) -> Int { p.isConnected ? 2 : (p.isReachable ? 1 : 0) } + let lr = rank(lhs), rr = rank(rhs) + if lr != rr { return lr > rr } + // Then favorites inside same rank + if lhs.isFavorite != rhs.isFavorite { return lhs.isFavorite } // Finally alphabetical return lhs.displayName < rhs.displayName } @@ -145,8 +147,11 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { } } - // Phase 5: Update published properties - self.peers = enrichedPeers + // Phase 5: Filter out offline non-mutual peers and update published properties + let filtered = enrichedPeers.filter { p in + p.isConnected || p.isReachable || p.isMutualFavorite + } + self.peers = filtered self.connectedPeerIDs = connected self.favorites = favoritesList self.mutualFavorites = mutualsList @@ -162,14 +167,26 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { private func buildPeerFromMesh( peerInfo: TransportPeerSnapshot, - favorites: [Data: FavoritesPersistenceService.FavoriteRelationship] + favorites: [Data: FavoritesPersistenceService.FavoriteRelationship], + meshAttached: Bool ) -> BitchatPeer { + // Determine reachability based on lastSeen and identity trust + let now = Date() + let fingerprint = peerInfo.noisePublicKey?.sha256Fingerprint() + let isVerified = fingerprint.map { SecureIdentityStateManager.shared.isVerified(fingerprint: $0) } ?? false + let isFav = peerInfo.noisePublicKey.flatMap { favorites[$0]?.isFavorite } ?? false + let retention: TimeInterval = (isVerified || isFav) ? TransportConfig.bleReachabilityRetentionVerifiedSeconds : TransportConfig.bleReachabilityRetentionUnverifiedSeconds + // A peer is reachable if we recently saw them AND we are attached to the mesh + let withinRetention = now.timeIntervalSince(peerInfo.lastSeen) <= retention + let isReachable = peerInfo.isConnected ? true : (withinRetention && meshAttached) + var peer = BitchatPeer( id: peerInfo.id, noisePublicKey: peerInfo.noisePublicKey ?? Data(), nickname: peerInfo.nickname, lastSeen: peerInfo.lastSeen, - isConnected: true + isConnected: peerInfo.isConnected, + isReachable: isReachable ) // Check for favorite status @@ -216,7 +233,8 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { noisePublicKey: favorite.peerNoisePublicKey, nickname: favorite.peerNickname, lastSeen: favorite.lastUpdated, - isConnected: false + isConnected: false, + isReachable: false ) peer.favoriteStatus = favorite diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 80b089db..860b5125 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -935,6 +935,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate { if unreadPrivateMessages.contains(noiseKeyHex) { return true } + // Also check for geohash (Nostr) DM conv key if this peer has a known Nostr pubkey + if let nostrHex = peer.nostrPublicKey { + let convKey = "nostr_" + String(nostrHex.prefix(TransportConfig.nostrConvKeyPrefixLength)) + if unreadPrivateMessages.contains(convKey) { + return true + } + } } // Get the peer's nickname to check for temporary Nostr peer IDs @@ -1874,6 +1881,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Determine routing method and recipient nickname guard let noiseKey = Data(hexString: peerID) else { return } let isConnected = meshService.isPeerConnected(peerID) + let isReachable = meshService.isPeerReachable(peerID) let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) let isMutualFavorite = favoriteStatus?.isMutual ?? false let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil @@ -1913,8 +1921,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Trigger UI update for sent message objectWillChange.send() - // Send via appropriate transport (BLE if connected, else Nostr when possible) - if isConnected || (isMutualFavorite && hasNostrKey) { + // Send via appropriate transport (BLE if connected/reachable, else Nostr when possible) + if isConnected || isReachable || (isMutualFavorite && hasNostrKey) { messageRouter.sendPrivate(content, to: peerID, recipientNickname: recipientNickname ?? "user", messageID: messageID) // Optimistically mark as sent for both transports; delivery/read will update subsequently if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) { @@ -2542,16 +2550,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } } - // If we know the original transport, use it for the read receipt + // If this originated over Nostr, skip (handled by Nostr code paths) if originalTransport == "nostr" { - // Skip read receipts for Nostr messages - unnecessary complexity - // The radical simplification plan says to accept occasional loss - } else if meshService.peerNickname(peerID: actualPeerID) != nil { - // Use mesh for connected peers (default behavior) - messageRouter.sendReadReceipt(receipt, to: actualPeerID) - } else { - // Skip read receipts for offline peers - fire and forget principle + return } + // Use router to decide (mesh if reachable, else Nostr if available) + messageRouter.sendReadReceipt(receipt, to: actualPeerID) } @MainActor diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 0940bdd4..83b51bff 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -1036,9 +1036,8 @@ struct ContentView: View { case .mesh: let counts = viewModel.allPeers.reduce(into: (others: 0, mesh: 0)) { counts, peer in guard peer.id != viewModel.meshService.myPeerID else { return } - let isMeshConnected = peer.isConnected - if isMeshConnected { counts.mesh += 1; counts.others += 1 } - else if peer.isMutualFavorite { counts.others += 1 } + if peer.isConnected { counts.mesh += 1; counts.others += 1 } + else if peer.isReachable { counts.others += 1 } } let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82) let color: Color = counts.mesh > 0 ? meshBlue : Color.secondary @@ -1181,20 +1180,11 @@ struct ContentView: View { @ViewBuilder private func privateHeaderContent(for privatePeerID: String) -> some View { - // Prefer short (mesh) ID when mesh-connected (radio). Only use full Noise key when not connected (globe). + // Prefer short (mesh) ID whenever available for encryption/session status; keep stable key for display resolution only. let headerPeerID: String = { - if privatePeerID.count == 16 { - let isMeshConnected = viewModel.meshService.isPeerConnected(privatePeerID) || viewModel.connectedPeers.contains(privatePeerID) - if !isMeshConnected, let stable = viewModel.getNoiseKeyForShortID(privatePeerID) { - return stable - } - } else if privatePeerID.count == 64 { - // If we have a full Noise key and a corresponding short ID is currently mesh-connected, prefer short ID - if let short = viewModel.getShortIDForNoiseKey(privatePeerID) { - if viewModel.meshService.isPeerConnected(short) || viewModel.connectedPeers.contains(short) { - return short - } - } + if privatePeerID.count == 64 { + // Map stable Noise key to short ID if we know it (even if not directly connected) + if let short = viewModel.getShortIDForNoiseKey(privatePeerID) { return short } } return privatePeerID }() @@ -1209,10 +1199,29 @@ struct ContentView: View { return "#\(ch.geohash)/@\(disp)" } } - return peer?.displayName ?? - viewModel.meshService.peerNickname(peerID: headerPeerID) ?? - FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data())?.peerNickname ?? - "Unknown" + // Try mesh/unified peer display + if let name = peer?.displayName { return name } + // Try direct mesh nickname (connected-only) + if let name = viewModel.meshService.peerNickname(peerID: headerPeerID) { return name } + // Try favorite nickname by stable Noise key + if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data()), + !fav.peerNickname.isEmpty { return fav.peerNickname } + // Fallback: resolve from persisted social identity via fingerprint mapping + if headerPeerID.count == 16 { + let candidates = SecureIdentityStateManager.shared.getCryptoIdentitiesByPeerIDPrefix(headerPeerID) + if let id = candidates.first, + let social = SecureIdentityStateManager.shared.getSocialIdentity(for: id.fingerprint) { + if let pet = social.localPetname, !pet.isEmpty { return pet } + if !social.claimedNickname.isEmpty { return social.claimedNickname } + } + } else if headerPeerID.count == 64, let keyData = Data(hexString: headerPeerID) { + let fp = keyData.sha256Fingerprint() + if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: fp) { + if let pet = social.localPetname, !pet.isEmpty { return pet } + if !social.claimedNickname.isEmpty { return social.claimedNickname } + } + } + return "Unknown" }() let isNostrAvailable: Bool = { guard let connectionState = peer?.connectionState else { @@ -1247,6 +1256,12 @@ struct ContentView: View { .font(.system(size: 14)) .foregroundColor(textColor) .accessibilityLabel("Connected via mesh") + case .meshReachable: + // point.3 filled icon for reachable via mesh (not directly connected) + Image(systemName: "point.3.filled.connected.trianglepath.dotted") + .font(.system(size: 14)) + .foregroundColor(textColor) + .accessibilityLabel("Reachable via mesh") case .nostrAvailable: // Purple globe for Nostr Image(systemName: "globe") @@ -1257,6 +1272,12 @@ struct ContentView: View { // Should not happen for PM header, but handle gracefully EmptyView() } + } else if viewModel.meshService.isPeerReachable(headerPeerID) { + // Fallback: reachable via mesh but not in current peer list + Image(systemName: "point.3.filled.connected.trianglepath.dotted") + .font(.system(size: 14)) + .foregroundColor(textColor) + .accessibilityLabel("Reachable via mesh") } else if isNostrAvailable { // Fallback to Nostr if peer not in list but is mutual favorite Image(systemName: "globe") @@ -1275,7 +1296,14 @@ struct ContentView: View { .font(.system(size: 16, weight: .medium, design: .monospaced)) .foregroundColor(textColor) // Dynamic encryption status icon (hide for geohash DMs) if !privatePeerID.hasPrefix("nostr_") { - let encryptionStatus = viewModel.getEncryptionStatus(for: headerPeerID) + // Use short peer ID if available for encryption status (sessions keyed by short ID) + let statusPeerID: String = { + if privatePeerID.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID) { + return short + } + return headerPeerID + }() + let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID) if let icon = encryptionStatus.icon { Image(systemName: icon) .font(.system(size: 14)) diff --git a/bitchat/Views/FingerprintView.swift b/bitchat/Views/FingerprintView.swift index fe8dabdb..b99a3739 100644 --- a/bitchat/Views/FingerprintView.swift +++ b/bitchat/Views/FingerprintView.swift @@ -41,9 +41,27 @@ struct FingerprintView: View { .padding() VStack(alignment: .leading, spacing: 16) { - // Peer info - let peerNickname = viewModel.meshService.peerNickname(peerID: peerID) ?? "Unknown" - let encryptionStatus = viewModel.getEncryptionStatus(for: peerID) + // Prefer short mesh ID for session/encryption status + let statusPeerID: String = { + if peerID.count == 64, let short = viewModel.getShortIDForNoiseKey(peerID) { return short } + return peerID + }() + // Resolve a friendly name + let peerNickname: String = { + if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName } + if let name = viewModel.meshService.peerNickname(peerID: statusPeerID) { return name } + if peerID.count == 64, let data = Data(hexString: peerID) { + if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname } + let fp = data.sha256Fingerprint() + if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: fp) { + if let pet = social.localPetname, !pet.isEmpty { return pet } + if !social.claimedNickname.isEmpty { return social.claimedNickname } + } + } + return "Unknown" + }() + // Accurate encryption state based on short ID session + let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID) HStack { if let icon = encryptionStatus.icon { @@ -74,7 +92,7 @@ struct FingerprintView: View { .font(.system(size: 12, weight: .bold, design: .monospaced)) .foregroundColor(textColor.opacity(0.7)) - if let fingerprint = viewModel.getFingerprint(for: peerID) { + if let fingerprint = viewModel.getFingerprint(for: statusPeerID) { Text(formatFingerprint(fingerprint)) .font(.system(size: 14, design: .monospaced)) .foregroundColor(textColor) diff --git a/bitchat/Views/MeshPeerList.swift b/bitchat/Views/MeshPeerList.swift index 4b0c8dc1..8e03b8ec 100644 --- a/bitchat/Views/MeshPeerList.swift +++ b/bitchat/Views/MeshPeerList.swift @@ -52,6 +52,11 @@ struct MeshPeerList: View { Image(systemName: "antenna.radiowaves.left.and.right") .font(.system(size: 10)) .foregroundColor(baseColor) + } else if peer.isReachable { + // Mesh-reachable (relayed): point.3 icon + Image(systemName: "point.3.filled.connected.trianglepath.dotted") + .font(.system(size: 10)) + .foregroundColor(baseColor) } else if peer.isMutualFavorite { // Mutual favorite reachable via Nostr: globe icon (purple) Image(systemName: "globe") @@ -110,6 +115,14 @@ struct MeshPeerList: View { Spacer() + // Unread message indicator for this peer + if !isMe, item.hasUnread { + Image(systemName: "envelope.fill") + .font(.system(size: 10)) + .foregroundColor(.orange) + .help("New messages") + } + if !isMe { Button(action: { onToggleFavorite(peer.id) }) { Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")