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

This commit is contained in:
jack
2025-08-26 02:11:30 +02:00
parent dc9b9e996d
commit acfce858ec
4 changed files with 65 additions and 29 deletions
+9 -1
View File
@@ -8,6 +8,7 @@ struct BitchatPeer: Identifiable, Equatable {
let nickname: String let nickname: String
let lastSeen: Date let lastSeen: Date
let isConnected: Bool let isConnected: Bool
let isReachable: Bool
// Favorite-related properties // Favorite-related properties
var favoriteStatus: FavoritesPersistenceService.FavoriteRelationship? var favoriteStatus: FavoritesPersistenceService.FavoriteRelationship?
@@ -18,6 +19,7 @@ struct BitchatPeer: Identifiable, Equatable {
// Connection state // Connection state
enum ConnectionState { enum ConnectionState {
case bluetoothConnected case bluetoothConnected
case meshReachable // Seen via mesh recently, not directly connected
case nostrAvailable // Mutual favorite, reachable via Nostr case nostrAvailable // Mutual favorite, reachable via Nostr
case offline // Not connected via any transport case offline // Not connected via any transport
} }
@@ -25,6 +27,8 @@ struct BitchatPeer: Identifiable, Equatable {
var connectionState: ConnectionState { var connectionState: ConnectionState {
if isConnected { if isConnected {
return .bluetoothConnected return .bluetoothConnected
} else if isReachable {
return .meshReachable
} else if favoriteStatus?.isMutual == true { } else if favoriteStatus?.isMutual == true {
// Mutual favorites can communicate via Nostr when offline // Mutual favorites can communicate via Nostr when offline
return .nostrAvailable return .nostrAvailable
@@ -54,6 +58,8 @@ struct BitchatPeer: Identifiable, Equatable {
switch connectionState { switch connectionState {
case .bluetoothConnected: case .bluetoothConnected:
return "📻" // Radio icon for mesh connection return "📻" // Radio icon for mesh connection
case .meshReachable:
return "📡" // Antenna for mesh reachable
case .nostrAvailable: case .nostrAvailable:
return "🌐" // Purple globe for Nostr return "🌐" // Purple globe for Nostr
case .offline: case .offline:
@@ -71,13 +77,15 @@ struct BitchatPeer: Identifiable, Equatable {
noisePublicKey: Data, noisePublicKey: Data,
nickname: String, nickname: String,
lastSeen: Date = Date(), lastSeen: Date = Date(),
isConnected: Bool = false isConnected: Bool = false,
isReachable: Bool = false
) { ) {
self.id = id self.id = id
self.noisePublicKey = noisePublicKey self.noisePublicKey = noisePublicKey
self.nickname = nickname self.nickname = nickname
self.lastSeen = lastSeen self.lastSeen = lastSeen
self.isConnected = isConnected self.isConnected = isConnected
self.isReachable = isReachable
// Load favorite status - will be set later by the manager // Load favorite status - will be set later by the manager
self.favoriteStatus = nil self.favoriteStatus = nil
+32 -15
View File
@@ -1831,28 +1831,39 @@ final class BLEService: NSObject {
let now = Date() let now = Date()
var disconnectedPeers: [String] = [] var disconnectedPeers: [String] = []
var removedOfflineCount = 0
collectionsQueue.sync(flags: .barrier) { collectionsQueue.sync(flags: .barrier) {
for (peerID, peer) in peers { 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 // Check if we still have an active BLE connection to this peer
let hasPeripheralConnection = peerToPeripheralUUID[peerID] != nil && let hasPeripheralConnection = peerToPeripheralUUID[peerID] != nil &&
peripherals[peerToPeripheralUUID[peerID]!]?.isConnected == true peripherals[peerToPeripheralUUID[peerID]!]?.isConnected == true
let hasCentralConnection = centralToPeerID.values.contains(peerID) 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 { if !hasPeripheralConnection && !hasCentralConnection {
// Remove the peer completely (they'll be re-added when they reconnect) var updated = peer
SecureLogger.log("⏱️ Peer timed out (no packets for 20s): \(peerID) (\(peer.nickname))", updated.isConnected = false
category: SecureLogger.session, level: .debug) peers[peerID] = updated
peers.removeValue(forKey: peerID)
disconnectedPeers.append(peerID) 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 // Update UI if there were direct disconnections or offline removals
if !disconnectedPeers.isEmpty { if !disconnectedPeers.isEmpty || removedOfflineCount > 0 {
notifyUI { [weak self] in notifyUI { [weak self] in
guard let self = self else { return } guard let self = self else { return }
@@ -2198,9 +2209,12 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
if let peerID = peerID { if let peerID = peerID {
peerToPeripheralUUID.removeValue(forKey: peerID) peerToPeripheralUUID.removeValue(forKey: peerID)
// Remove peer completely (they'll be re-added when they reconnect and announce) // Do not remove peer; mark as not connected but retain for reachability
_ = collectionsQueue.sync(flags: .barrier) { collectionsQueue.sync(flags: .barrier) {
peers.removeValue(forKey: peerID) 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 // Attempt to fill freed slot from queue
bleQueue.async { [weak self] in self?.tryConnectFromQueue() } 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 notifyUI { [weak self] in
guard let self = self else { return } guard let self = self else { return }
@@ -2586,9 +2600,12 @@ extension BLEService: CBPeripheralManagerDelegate {
// Find and disconnect the peer associated with this central // Find and disconnect the peer associated with this central
let centralUUID = central.identifier.uuidString let centralUUID = central.identifier.uuidString
if let peerID = centralToPeerID[centralUUID] { if let peerID = centralToPeerID[centralUUID] {
// Remove peer completely (they'll be re-added when they reconnect) // Mark peer as not connected; retain for reachability
_ = collectionsQueue.sync(flags: .barrier) { collectionsQueue.sync(flags: .barrier) {
peers.removeValue(forKey: peerID) if var info = peers[peerID] {
info.isConnected = false
peers[peerID] = info
}
} }
// Clean up mappings // Clean up mappings
+3
View File
@@ -72,6 +72,9 @@ enum TransportConfig {
static let bleRSSIConnectedThreshold: Int = -85 static let bleRSSIConnectedThreshold: Int = -85
static let bleRSSIHighTimeoutThreshold: Int = -80 static let bleRSSIHighTimeoutThreshold: Int = -80
static let blePeerInactivityTimeoutSeconds: TimeInterval = 20.0 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 bleFragmentLifetimeSeconds: TimeInterval = 30.0
static let bleIngressRecordLifetimeSeconds: TimeInterval = 3.0 static let bleIngressRecordLifetimeSeconds: TimeInterval = 3.0
static let bleConnectTimeoutBackoffWindowSeconds: TimeInterval = 120.0 static let bleConnectTimeoutBackoffWindowSeconds: TimeInterval = 120.0
+21 -13
View File
@@ -74,8 +74,8 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
var connected: Set<String> = [] var connected: Set<String> = []
var addedPeerIDs: Set<String> = [] var addedPeerIDs: Set<String> = []
// Phase 1: Add all connected mesh peers // Phase 1: Add all mesh peers (connected and reachable)
for peerInfo in meshPeers where peerInfo.isConnected { for peerInfo in meshPeers {
let peerID = peerInfo.id let peerID = peerInfo.id
guard peerID != meshService.myPeerID else { continue } // Never add self guard peerID != meshService.myPeerID else { continue } // Never add self
@@ -85,7 +85,7 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
) )
enrichedPeers.append(peer) enrichedPeers.append(peer)
connected.insert(peerID) if peer.isConnected { connected.insert(peerID) }
addedPeerIDs.insert(peerID) addedPeerIDs.insert(peerID)
// Update fingerprint cache // Update fingerprint cache
@@ -117,14 +117,12 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
// Phase 3: Sort peers // Phase 3: Sort peers
enrichedPeers.sort { lhs, rhs in enrichedPeers.sort { lhs, rhs in
// Connected first // Connectivity rank: connected > reachable > others
if lhs.isConnected != rhs.isConnected { func rank(_ p: BitchatPeer) -> Int { p.isConnected ? 2 : (p.isReachable ? 1 : 0) }
return lhs.isConnected let lr = rank(lhs), rr = rank(rhs)
} if lr != rr { return lr > rr }
// Then favorites // Then favorites inside same rank
if lhs.isFavorite != rhs.isFavorite { if lhs.isFavorite != rhs.isFavorite { return lhs.isFavorite }
return lhs.isFavorite
}
// Finally alphabetical // Finally alphabetical
return lhs.displayName < rhs.displayName return lhs.displayName < rhs.displayName
} }
@@ -164,12 +162,21 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
peerInfo: TransportPeerSnapshot, peerInfo: TransportPeerSnapshot,
favorites: [Data: FavoritesPersistenceService.FavoriteRelationship] favorites: [Data: FavoritesPersistenceService.FavoriteRelationship]
) -> BitchatPeer { ) -> 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( var peer = BitchatPeer(
id: peerInfo.id, id: peerInfo.id,
noisePublicKey: peerInfo.noisePublicKey ?? Data(), noisePublicKey: peerInfo.noisePublicKey ?? Data(),
nickname: peerInfo.nickname, nickname: peerInfo.nickname,
lastSeen: peerInfo.lastSeen, lastSeen: peerInfo.lastSeen,
isConnected: true isConnected: peerInfo.isConnected,
isReachable: isReachable
) )
// Check for favorite status // Check for favorite status
@@ -216,7 +223,8 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
noisePublicKey: favorite.peerNoisePublicKey, noisePublicKey: favorite.peerNoisePublicKey,
nickname: favorite.peerNickname, nickname: favorite.peerNickname,
lastSeen: favorite.lastUpdated, lastSeen: favorite.lastUpdated,
isConnected: false isConnected: false,
isReachable: false
) )
peer.favoriteStatus = favorite peer.favoriteStatus = favorite