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 d7f190df..7bd3a3ca 100644 --- a/bitchat/Services/BLEService.swift +++ b/bitchat/Services/BLEService.swift @@ -1831,28 +1831,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 } @@ -2198,9 +2209,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 + } } } @@ -2215,7 +2229,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 } @@ -2586,9 +2600,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 diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index 56a1da75..b8b8fee8 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -72,6 +72,9 @@ enum TransportConfig { static let bleRSSIConnectedThreshold: Int = -85 static let bleRSSIHighTimeoutThreshold: Int = -80 static let blePeerInactivityTimeoutSeconds: TimeInterval = 20.0 + // How long to retain a peer as "reachable" (not directly connected) since lastSeen + static let bleReachabilityRetentionVerifiedSeconds: TimeInterval = 600.0 // 10 minutes for verified/favorites + static let bleReachabilityRetentionUnverifiedSeconds: TimeInterval = 120.0 // 2 minutes for unknown/unverified static let bleFragmentLifetimeSeconds: TimeInterval = 30.0 static let bleIngressRecordLifetimeSeconds: TimeInterval = 3.0 static let bleConnectTimeoutBackoffWindowSeconds: TimeInterval = 120.0 diff --git a/bitchat/Services/UnifiedPeerService.swift b/bitchat/Services/UnifiedPeerService.swift index aa04b7ff..2855ab21 100644 --- a/bitchat/Services/UnifiedPeerService.swift +++ b/bitchat/Services/UnifiedPeerService.swift @@ -74,8 +74,8 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { 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 @@ -85,7 +85,7 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { ) enrichedPeers.append(peer) - connected.insert(peerID) + if peer.isConnected { connected.insert(peerID) } addedPeerIDs.insert(peerID) // Update fingerprint cache @@ -117,14 +117,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 } @@ -164,12 +162,21 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { peerInfo: TransportPeerSnapshot, favorites: [Data: FavoritesPersistenceService.FavoriteRelationship] ) -> 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 + let isReachable = now.timeIntervalSince(peerInfo.lastSeen) <= retention + 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 +223,8 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate { noisePublicKey: favorite.peerNoisePublicKey, nickname: favorite.peerNickname, lastSeen: favorite.lastUpdated, - isConnected: false + isConnected: false, + isReachable: false ) peer.favoriteStatus = favorite