mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 19:45:22 +00:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user