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 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
+32 -15
View File
@@ -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
+3
View File
@@ -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
+21 -13
View File
@@ -74,8 +74,8 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
var connected: Set<String> = []
var addedPeerIDs: Set<String> = []
// 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