Fix duplicate peer entries when device restarts

- Added cleanup timer to remove stale peers after 60 seconds of inactivity
- Track last seen timestamp for all peers on packet receipt
- Detect and remove stale peer IDs with duplicate nicknames in announce handler
- Clean up all associated data when removing stale peers
- Notify UI immediately when stale peers are removed

This fixes the issue where restarting the iPhone app would show duplicate
"jack" entries on macOS. Now when a device reconnects with a new peer ID
but same nickname, the old entry is automatically removed.
This commit is contained in:
jack
2025-07-04 17:17:17 +02:00
parent 2e9601b585
commit 44d97ffb7e
+191 -2
View File
@@ -55,6 +55,8 @@ class BluetoothMeshService: NSObject {
private var announcedPeers = Set<String>() // Track peers who have already been announced
private var processedKeyExchanges = Set<String>() // Track processed key exchanges to prevent duplicates
private var intentionalDisconnects = Set<String>() // Track peripherals we're disconnecting intentionally
private var peerLastSeenTimestamps: [String: Date] = [:] // Track when we last heard from each peer
private var cleanupTimer: Timer? // Timer to clean up stale peers
// Store-and-forward message cache
private struct StoredMessage {
@@ -86,6 +88,10 @@ class BluetoothMeshService: NSObject {
private var peerListUpdateTimer: Timer?
private let peerListUpdateDebounceInterval: TimeInterval = 0.1 // 100ms debounce for more responsive updates
// Stale peer cleanup
private var stalePeerCleanupTimer: Timer?
private var peerLastSeenTimestamps: [String: Date] = [:] // Track when we last saw each peer
// Cover traffic for privacy
private var coverTrafficTimer: Timer?
private let coverTrafficPrefix = "☂DUMMY☂" // Prefix to identify dummy messages after decryption
@@ -293,6 +299,11 @@ class BluetoothMeshService: NSObject {
}
}
// Start stale peer cleanup timer (every 30 seconds)
cleanupTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { [weak self] _ in
self?.cleanupStalePeers()
}
// Register for app termination notifications
#if os(macOS)
NotificationCenter.default.addObserver(
@@ -318,6 +329,7 @@ class BluetoothMeshService: NSObject {
coverTrafficTimer?.invalidate()
bloomFilterResetTimer?.invalidate()
aggregationTimer?.invalidate()
cleanupTimer?.invalidate()
}
@objc private func appWillTerminate() {
@@ -359,6 +371,9 @@ class BluetoothMeshService: NSObject {
// Clear announcement tracking
announcedToPeers.removeAll()
// Clear last seen timestamps
peerLastSeenTimestamps.removeAll()
}
func startServices() {
@@ -768,7 +783,17 @@ class BluetoothMeshService: NSObject {
peerID != myPeerID
}
// print("[DEBUG] Active peers: \(peersCopy), Valid peers: \(validPeers)")
// Get nicknames for logging
peerNicknamesLock.lock()
let peerNicknamesCopy = peerNicknames
peerNicknamesLock.unlock()
let peerInfo = validPeers.map { peerID in
let nickname = peerNicknamesCopy[peerID] ?? "unknown"
return "\(peerID):\(nickname)"
}
print("[PEERS] Active peers: \(peerInfo.joined(separator: ", "))")
return Array(validPeers).sorted()
}
@@ -803,6 +828,44 @@ class BluetoothMeshService: NSObject {
}
}
// Clean up stale peers that haven't been seen in a while
private func cleanupStalePeers() {
let staleThreshold: TimeInterval = 60.0 // 60 seconds
let now = Date()
activePeersLock.lock()
let peersToRemove = activePeers.filter { peerID in
if let lastSeen = peerLastSeenTimestamps[peerID] {
return now.timeIntervalSince(lastSeen) > staleThreshold
}
return false // Keep peers we haven't tracked yet
}
for peerID in peersToRemove {
activePeers.remove(peerID)
peerLastSeenTimestamps.removeValue(forKey: peerID)
// Clean up all associated data
connectedPeripherals.removeValue(forKey: peerID)
peerRSSI.removeValue(forKey: peerID)
announcedPeers.remove(peerID)
announcedToPeers.remove(peerID)
processedKeyExchanges.removeAll { $0.contains(peerID) }
peerNicknamesLock.lock()
let nickname = peerNicknames[peerID]
peerNicknames.removeValue(forKey: peerID)
peerNicknamesLock.unlock()
// print("[CLEANUP] Removed stale peer \(peerID) (\(nickname ?? "unknown"))")
}
activePeersLock.unlock()
if !peersToRemove.isEmpty {
notifyPeerListUpdate()
}
}
// MARK: - Store-and-Forward Methods
private func cacheMessage(_ packet: BitchatPacket, messageID: String) {
@@ -1056,6 +1119,12 @@ class BluetoothMeshService: NSObject {
return
}
// Update last seen timestamp for this peer
if let senderID = String(data: packet.senderID.trimmingNullBytes(), encoding: .utf8),
senderID != "unknown" && senderID != self.myPeerID {
peerLastSeenTimestamps[senderID] = Date()
}
// Replay attack protection: Check timestamp is within reasonable window (5 minutes)
let currentTime = UInt64(Date().timeIntervalSince1970 * 1000) // milliseconds
let timeDiff = abs(Int64(currentTime) - Int64(packet.timestamp))
@@ -1392,7 +1461,60 @@ class BluetoothMeshService: NSObject {
// Check if we've already announced this peer
let isFirstAnnounce = !announcedPeers.contains(senderID)
// Clean up stale peer IDs with the same nickname
peerNicknamesLock.lock()
var stalePeerIDs: [String] = []
for (existingPeerID, existingNickname) in peerNicknames {
if existingNickname == nickname && existingPeerID != senderID {
// Found a stale peer ID with the same nickname
stalePeerIDs.append(existingPeerID)
// print("[ANNOUNCE] Found stale peer ID \(existingPeerID) with same nickname '\(nickname)' as new peer \(senderID)")
}
}
// Remove stale peer IDs
for stalePeerID in stalePeerIDs {
// print("[ANNOUNCE] Removing stale peer \(stalePeerID) -> '\(nickname)' (replaced by \(senderID))")
peerNicknames.removeValue(forKey: stalePeerID)
// Also remove from active peers
activePeersLock.lock()
activePeers.remove(stalePeerID)
activePeersLock.unlock()
// Remove from announced peers
announcedPeers.remove(stalePeerID)
announcedToPeers.remove(stalePeerID)
// Disconnect any peripherals associated with stale ID
if let peripheral = connectedPeripherals[stalePeerID] {
intentionalDisconnects.insert(peripheral.identifier.uuidString)
centralManager.cancelPeripheralConnection(peripheral)
connectedPeripherals.removeValue(forKey: stalePeerID)
peripheralCharacteristics.removeValue(forKey: peripheral)
}
// Remove RSSI data
peerRSSI.removeValue(forKey: stalePeerID)
// Clear cached messages tracking
cachedMessagesSentToPeer.remove(stalePeerID)
// Remove from last seen timestamps
peerLastSeenTimestamps.removeValue(forKey: stalePeerID)
// Remove from processed key exchanges
processedKeyExchanges.removeAll { $0.contains(stalePeerID) }
}
// If we had stale peers, notify the UI immediately
if !stalePeerIDs.isEmpty {
DispatchQueue.main.async { [weak self] in
self?.notifyPeerListUpdate(immediate: true)
}
}
// Now add the new peer ID with the nickname
peerNicknames[senderID] = nickname
peerNicknamesLock.unlock()
@@ -1406,7 +1528,7 @@ class BluetoothMeshService: NSObject {
let wasInserted = activePeers.insert(senderID).inserted
activePeersLock.unlock()
if wasInserted {
// print("[DEBUG] Added peer \(senderID) to active peers via announce")
print("[ANNOUNCE] Added peer \(senderID) (\(nickname)) to active peers")
}
// Show join message only for first announce
@@ -2270,4 +2392,71 @@ extension BluetoothMeshService: CBPeripheralManagerDelegate {
// Prefix with dummy marker (will be encrypted)
return coverTrafficPrefix + (templates.randomElement() ?? "ok")
}
// MARK: - Stale Peer Cleanup
private func cleanupStalePeers() {
let staleThreshold: TimeInterval = 60.0 // Consider peers stale after 60 seconds of no activity
let now = Date()
var peersToRemove: [String] = []
// Check for stale peers
activePeersLock.lock()
for peerID in activePeers {
if let lastSeen = peerLastSeenTimestamps[peerID] {
if now.timeIntervalSince(lastSeen) > staleThreshold {
peersToRemove.append(peerID)
}
} else {
// No timestamp recorded, consider it stale
peersToRemove.append(peerID)
}
}
activePeersLock.unlock()
// Remove stale peers
for peerID in peersToRemove {
print("[CLEANUP] Removing stale peer \(peerID) - last seen: \(peerLastSeenTimestamps[peerID]?.description ?? "never")")
// Remove from all tracking structures
activePeersLock.lock()
activePeers.remove(peerID)
activePeersLock.unlock()
peerNicknamesLock.lock()
let nickname = peerNicknames[peerID]
peerNicknames.removeValue(forKey: peerID)
peerNicknamesLock.unlock()
// Remove from other tracking
announcedPeers.remove(peerID)
announcedToPeers.remove(peerID)
peerRSSI.removeValue(forKey: peerID)
peerLastSeenTimestamps.removeValue(forKey: peerID)
cachedMessagesSentToPeer.remove(peerID)
// Disconnect any associated peripherals
if let peripheral = connectedPeripherals[peerID] {
intentionalDisconnects.insert(peripheral.identifier.uuidString)
centralManager.cancelPeripheralConnection(peripheral)
connectedPeripherals.removeValue(forKey: peerID)
peripheralCharacteristics.removeValue(forKey: peripheral)
}
// Log the cleanup
if let nick = nickname {
print("[CLEANUP] Removed stale peer: \(nick) (\(peerID))")
}
}
// Notify UI if any peers were removed
if !peersToRemove.isEmpty {
notifyPeerListUpdate()
}
}
private func updatePeerLastSeen(_ peerID: String) {
peerLastSeenTimestamps[peerID] = Date()
}
}