diff --git a/bitchat/Services/BLE/BLERecentPeripheralCache.swift b/bitchat/Services/BLE/BLERecentPeripheralCache.swift deleted file mode 100644 index 1f7819fd..00000000 --- a/bitchat/Services/BLE/BLERecentPeripheralCache.swift +++ /dev/null @@ -1,53 +0,0 @@ -import Foundation - -/// Remembers recently seen bitchat peripherals (fresh discoveries and dropped -/// links) so the service can arm pending background connections against them -/// when the app leaves the foreground. Generic over the peripheral type so -/// the eviction/expiry logic is testable without CoreBluetooth. -final class BLERecentPeripheralCache { - private struct Entry { - let peripheral: Peripheral - var lastSeen: Date - } - - private var entries: [String: Entry] = [:] - private let capacity: Int - private let maxAge: TimeInterval - - init( - capacity: Int = TransportConfig.bleRecentPeripheralCacheCap, - maxAge: TimeInterval = TransportConfig.bleRecentPeripheralMaxAgeSeconds - ) { - self.capacity = capacity - self.maxAge = maxAge - } - - var count: Int { entries.count } - - func record(_ peripheral: Peripheral, peripheralID: String, at now: Date) { - entries[peripheralID] = Entry(peripheral: peripheral, lastSeen: now) - guard entries.count > capacity else { return } - // Inserts overshoot capacity by at most one; evict the stalest entry - if let stalest = entries.min(by: { $0.value.lastSeen < $1.value.lastSeen }) { - entries.removeValue(forKey: stalest.key) - } - } - - /// Most-recently-seen peripherals eligible for a pending background - /// connect, freshest first, capped at `limit`. Expired entries are - /// pruned as a side effect. - func reconnectTargets( - now: Date, - limit: Int, - excluding: (String) -> Bool - ) -> [(peripheralID: String, peripheral: Peripheral)] { - let cutoff = now.addingTimeInterval(-maxAge) - entries = entries.filter { $0.value.lastSeen >= cutoff } - guard limit > 0 else { return [] } - return entries - .filter { !excluding($0.key) } - .sorted { $0.value.lastSeen > $1.value.lastSeen } - .prefix(limit) - .map { (peripheralID: $0.key, peripheral: $0.value.peripheral) } - } -} diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift index 673210a0..8d9234af 100644 --- a/bitchat/Services/BLE/BLEService.swift +++ b/bitchat/Services/BLE/BLEService.swift @@ -197,7 +197,6 @@ final class BLEService: NSObject { private var maintenanceTimer: DispatchSourceTimer? // Single timer for all maintenance tasks private var maintenanceCounter = 0 // Track maintenance cycles - private var lastMaintenanceAt = Date.distantPast // bleQueue-confined; drives background-wake catch-up passes /// Whether real CoreBluetooth managers were initialized. When false (unit /// tests), periodic mesh background work is not started — the maintenance /// timer and the gossip-sync timers only drain BLE writes/notifications, @@ -208,9 +207,6 @@ final class BLEService: NSObject { // MARK: - Connection budget & scheduling (central role) private var connectionScheduler = BLEConnectionScheduler() - // Recently seen peripherals retained for background wake-on-proximity - // connects (bleQueue-confined, like the link state store) - private let recentPeripheralCache = BLERecentPeripheralCache() // MARK: - Adaptive scanning duty-cycle private var scanDutyTimer: DispatchSourceTimer? @@ -1613,9 +1609,6 @@ extension BLEService: CBCentralManagerDelegate { isConnectable: isConnectable, discoveredAt: Date() ) - if isConnectable { - recentPeripheralCache.record(peripheral, peripheralID: peripheralID, at: candidate.discoveredAt) - } let existingState = linkStateStore.state(forPeripheralID: peripheralID).map(BLEExistingConnectionState.init) switch connectionScheduler.handleDiscovery( @@ -1642,15 +1635,7 @@ extension BLEService: CBCentralManagerDelegate { func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { let peripheralID = peripheral.identifier.uuidString - - #if os(iOS) - // A connect completing while backgrounded is the wake-on-proximity - // path doing its job — worth an info line for field verification. - if !isAppActive { - SecureLogger.info("🌙 Background wake: connected to \(peripheral.name ?? peripheralID) while backgrounded", category: .session) - } - #endif - + // Update state to connected linkStateStore.markConnected(peripheral) @@ -1675,24 +1660,7 @@ extension BLEService: CBCentralManagerDelegate { if error != nil { connectionScheduler.recordDisconnectError(peripheralID: peripheralID, at: Date()) } - - // Retain the handle: a dropped link is the best wake-on-proximity - // candidate if the app backgrounds before the peer returns. - recentPeripheralCache.record(peripheral, peripheralID: peripheralID, at: Date()) - - #if os(iOS) - // Link lost while backgrounded (peer walked away): re-arm a pending - // connect during this wake window so the peer's return wakes us again. - // Delayed past the disconnect-settle window to avoid reconnect thrash - // at range edge. - if !isAppActive { - bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleDisconnectDiscoveryIgnoreSeconds) { [weak self] in - guard let self, !self.isAppActive else { return } - self.armPendingBackgroundConnects() - } - } - #endif - + // Clean up references and peer mappings _ = linkStateStore.removePeripheral(peripheralID) if let peerID { @@ -1820,18 +1788,6 @@ extension BLEService { return } - #if os(iOS) - if !self.isAppActive { - // Backgrounded: leave the connect pending. iOS never expires - // it — the controller completes it whenever the peer comes - // back into range, waking the app (state restoration relaunches - // us if we were terminated). Foreground return cancels stale - // pendings via cancelStalePendingConnects(). - SecureLogger.info("🌙 Connect timeout deferred while backgrounded, left pending for wake-on-proximity: \(candidate.name)", category: .session) - return - } - #endif - SecureLogger.debug("⏱️ Timeout: \(candidate.name)", category: .session) central.cancelPeripheralConnection(peripheral) _ = self.linkStateStore.removePeripheral(peripheralID) @@ -3451,12 +3407,11 @@ extension BLEService { centralManager?.stopScan() startScanning() } - cancelStalePendingConnects() logBluetoothStatus("became-active") scheduleBluetoothStatusSample(after: 5.0, context: "active-5s") // No Local Name; nothing to refresh for advertising policy } - + @objc private func appDidEnterBackground() { isAppActive = false // Restart scanning without allow duplicates in background @@ -3464,7 +3419,6 @@ extension BLEService { centralManager?.stopScan() startScanning() } - armPendingBackgroundConnects() // Backgrounding may precede a kill; flush the public-history archive // outside its 30s maintenance cadence. gossipSyncManager?.persistNow() @@ -3472,76 +3426,6 @@ extension BLEService { scheduleBluetoothStatusSample(after: 15.0, context: "background-15s") // No Local Name; nothing to refresh for advertising policy } - - /// Issue indefinite `connect()` requests to recently seen peripherals on - /// backgrounding. Pending connects live in the Bluetooth controller's - /// allowlist — no scanning and no app CPU — and complete whenever a peer - /// comes into range, waking (or relaunching) the app. A couple of central - /// slots stay reserved for connects driven by live background discovery. - private func armPendingBackgroundConnects() { - bleQueue.async { [weak self] in - guard let self, let central = self.centralManager, central.state == .poweredOn else { return } - let budget = TransportConfig.bleMaxCentralLinks - - TransportConfig.bleBackgroundPendingConnectSlotReserve - - self.linkStateStore.connectedOrConnectingPeripheralCount - let now = Date() - let targets = self.recentPeripheralCache.reconnectTargets(now: now, limit: budget) { peripheralID in - let state = self.linkStateStore.state(forPeripheralID: peripheralID) - return state?.isConnected == true || state?.isConnecting == true - } - guard !targets.isEmpty else { return } - for target in targets { - // lastConnectionAttempt stays nil: an indefinite pending connect - // has no attempt clock, and nil marks it always-stale so - // cancelStalePendingConnects() reclaims it on foreground even - // after a quick background→foreground bounce. - self.linkStateStore.setPeripheralState( - BLEPeripheralLinkState( - peripheral: target.peripheral, - characteristic: nil, - peerID: nil, - isConnecting: true, - isConnected: false, - lastConnectionAttempt: nil, - assembler: NotificationStreamAssembler() - ), - for: target.peripheralID - ) - target.peripheral.delegate = self - central.connect(target.peripheral, options: [ - CBConnectPeripheralOptionNotifyOnConnectionKey: true, - CBConnectPeripheralOptionNotifyOnDisconnectionKey: true, - CBConnectPeripheralOptionNotifyOnNotificationKey: true - ]) - } - SecureLogger.info("🌙 Armed \(targets.count) pending background connect(s) for wake-on-proximity", category: .session) - } - } - - /// Foreground restores normal connection management: pending connects - /// older than the connect timeout (including ones rebuilt by state - /// restoration after a relaunch) are cancelled so live scanning and the - /// scheduler take over. Anything still nearby is rediscovered within - /// seconds by the allow-duplicates foreground scan. - private func cancelStalePendingConnects() { - bleQueue.async { [weak self] in - guard let self, let central = self.centralManager else { return } - let now = Date() - var cancelled = 0 - for state in self.linkStateStore.peripheralStates where state.isConnecting && !state.isConnected { - let age = state.lastConnectionAttempt.map { now.timeIntervalSince($0) } ?? .infinity - guard age > TransportConfig.bleConnectTimeoutSeconds else { continue } - let peripheralID = state.peripheral.identifier.uuidString - central.cancelPeripheralConnection(state.peripheral) - _ = self.linkStateStore.removePeripheral(peripheralID) - cancelled += 1 - } - if cancelled > 0 { - SecureLogger.info("🌅 Cancelled \(cancelled) stale pending connect(s) on foreground", category: .session) - self.tryConnectFromQueue() - } - } - } #endif // MARK: Private Message Handling @@ -3890,16 +3774,6 @@ extension BLEService { } } - #if os(iOS) - // The maintenance timer is suspended with the app, so a packet arriving - // while backgrounded means the radio woke us — use the wake window to - // run the announce/flush/drain pass the timer would have run. - if !isAppActive { - bleQueue.async { [weak self] in self?.performBackgroundWakeMaintenanceIfStale() } - } - #endif - - // Process by type switch context.messageType { case .announce: @@ -4396,7 +4270,6 @@ extension BLEService { private func performMaintenance() { maintenanceCounter += 1 - lastMaintenanceAt = Date() let now = Date() let connectedCount = collectionsQueue.sync { peerRegistry.connectedCount } @@ -4461,18 +4334,6 @@ extension BLEService { } } - #if os(iOS) - /// Catch-up maintenance for background wake windows (bleQueue-confined). - /// Rate-limited to the normal maintenance cadence so a burst of inbound - /// packets during one wake still runs at most one extra pass. - private func performBackgroundWakeMaintenanceIfStale() { - guard meshBackgroundEnabled, - !isAppActive, - Date().timeIntervalSince(lastMaintenanceAt) >= TransportConfig.bleMaintenanceInterval else { return } - performMaintenance() - } - #endif - private func checkPeerConnectivity() { let now = Date() let peerIDsForLinkState: [PeerID] = collectionsQueue.sync { peerRegistry.peerIDs } diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index 63eff4a4..07e11666 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -242,16 +242,6 @@ enum TransportConfig { static let bleDisconnectNotifyDebounceSeconds: TimeInterval = 0.9 static let bleReconnectLogDebounceSeconds: TimeInterval = 2.0 - // Background wake-on-proximity (iOS). Pending connects issued on - // backgrounding never expire at the OS level: the Bluetooth controller - // completes them whenever the peer reappears in range and relaunches the - // app via state restoration. Entries older than the BLE address-rotation - // window no longer map to a reachable address, so the cache prunes them. - static let bleRecentPeripheralCacheCap: Int = 16 - static let bleRecentPeripheralMaxAgeSeconds: TimeInterval = 15 * 60 - // Central slots kept free for connects driven by live background discovery - static let bleBackgroundPendingConnectSlotReserve: Int = 2 - // Weak-link cooldown after connection timeouts static let bleWeakLinkCooldownSeconds: TimeInterval = 30.0 static let bleWeakLinkRSSICutoff: Int = -90 diff --git a/bitchatTests/Services/BLERecentPeripheralCacheTests.swift b/bitchatTests/Services/BLERecentPeripheralCacheTests.swift deleted file mode 100644 index 15eb057e..00000000 --- a/bitchatTests/Services/BLERecentPeripheralCacheTests.swift +++ /dev/null @@ -1,99 +0,0 @@ -// -// BLERecentPeripheralCacheTests.swift -// bitchatTests -// -// Eviction, expiry, and reconnect-target selection for the background -// wake-on-proximity peripheral cache. -// - -import Testing -import Foundation -@testable import bitchat - -struct BLERecentPeripheralCacheTests { - - private let base = Date(timeIntervalSince1970: 1_700_000_000) - - private func makeCache(capacity: Int = 4, maxAge: TimeInterval = 900) -> BLERecentPeripheralCache { - BLERecentPeripheralCache(capacity: capacity, maxAge: maxAge) - } - - @Test - func recordUpsertsByPeripheralID() { - let cache = makeCache() - cache.record("p1", peripheralID: "A", at: base) - cache.record("p1-updated", peripheralID: "A", at: base.addingTimeInterval(10)) - - #expect(cache.count == 1) - let targets = cache.reconnectTargets(now: base.addingTimeInterval(11), limit: 10) { _ in false } - #expect(targets.map(\.peripheral) == ["p1-updated"]) - } - - @Test - func overCapacityEvictsStalestEntry() { - let cache = makeCache(capacity: 2) - cache.record("p1", peripheralID: "A", at: base) - cache.record("p2", peripheralID: "B", at: base.addingTimeInterval(1)) - cache.record("p3", peripheralID: "C", at: base.addingTimeInterval(2)) - - #expect(cache.count == 2) - let targets = cache.reconnectTargets(now: base.addingTimeInterval(3), limit: 10) { _ in false } - #expect(targets.map(\.peripheralID) == ["C", "B"]) - } - - @Test - func refreshingAnEntryProtectsItFromEviction() { - let cache = makeCache(capacity: 2) - cache.record("p1", peripheralID: "A", at: base) - cache.record("p2", peripheralID: "B", at: base.addingTimeInterval(1)) - // A becomes the freshest again; adding C must evict B, not A - cache.record("p1", peripheralID: "A", at: base.addingTimeInterval(2)) - cache.record("p3", peripheralID: "C", at: base.addingTimeInterval(3)) - - let targets = cache.reconnectTargets(now: base.addingTimeInterval(4), limit: 10) { _ in false } - #expect(targets.map(\.peripheralID) == ["C", "A"]) - } - - @Test - func expiredEntriesArePruned() { - let cache = makeCache(maxAge: 100) - cache.record("p1", peripheralID: "A", at: base) - cache.record("p2", peripheralID: "B", at: base.addingTimeInterval(50)) - - let targets = cache.reconnectTargets(now: base.addingTimeInterval(120), limit: 10) { _ in false } - #expect(targets.map(\.peripheralID) == ["B"]) - #expect(cache.count == 1) - } - - @Test - func targetsAreFreshestFirstAndCappedAtLimit() { - let cache = makeCache(capacity: 8) - for (index, id) in ["A", "B", "C", "D"].enumerated() { - cache.record("p\(id)", peripheralID: id, at: base.addingTimeInterval(TimeInterval(index))) - } - - let targets = cache.reconnectTargets(now: base.addingTimeInterval(10), limit: 2) { _ in false } - #expect(targets.map(\.peripheralID) == ["D", "C"]) - } - - @Test - func excludedPeripheralsAreSkippedWithoutConsumingTheLimit() { - let cache = makeCache(capacity: 8) - for (index, id) in ["A", "B", "C"].enumerated() { - cache.record("p\(id)", peripheralID: id, at: base.addingTimeInterval(TimeInterval(index))) - } - - // C (freshest) is already connected; the two slots go to B and A - let targets = cache.reconnectTargets(now: base.addingTimeInterval(10), limit: 2) { $0 == "C" } - #expect(targets.map(\.peripheralID) == ["B", "A"]) - } - - @Test - func nonPositiveLimitReturnsNothing() { - let cache = makeCache() - cache.record("p1", peripheralID: "A", at: base) - - #expect(cache.reconnectTargets(now: base, limit: 0) { _ in false }.isEmpty) - #expect(cache.reconnectTargets(now: base, limit: -3) { _ in false }.isEmpty) - } -}