mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 20:25:19 +00:00
* BLE background presence: pending-connect wake-on-proximity + wake-window maintenance (#1395) iOS cancels nothing for us: pending CBCentralManager connects never expire, complete whenever the peer reappears in range, and relaunch the app via the existing state-restoration path. Use that as the wake-on-proximity mechanism: - BLERecentPeripheralCache: retains handles to recently seen/dropped peripherals (LRU 16, 15 min max age to respect BLE address rotation) - On backgrounding, arm indefinite pending connects to cached peripherals within a slot budget (2 of 6 central slots reserved for live background discovery); armed entries carry lastConnectionAttempt == nil so a quick background/foreground bounce can't strand them as connecting - The 8s app-level connect timeout defers while backgrounded so discovery-driven background connects also stay pending - Foreground return cancels stale pending connects (including connecting entries rebuilt by state restoration after a relaunch) and hands control back to the scanner/scheduler - A link dropped while backgrounded re-arms after the disconnect-settle window, so a peer walking away and returning wakes us again - Packet ingress while backgrounded triggers a catch-up maintenance pass (announce/flush/drain) since the maintenance timer is suspended with the app; rate-limited to the normal 5s cadence Battery cost ~0: pending connects live in the controller's allowlist (no scanning, no app CPU), and the catch-up pass only runs inside wake windows the radio already granted. Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> * Restore: seed wake-on-proximity cache and resume service discovery Field finding from on-device testing: after a state-restoration relaunch, the recent-peripheral cache starts empty, so backgrounding shortly after a restore armed no pending connects. Seed the cache from the restored peripherals — they are the freshest proximity candidates we have. Also resume service discovery for peripherals restored as connected with no characteristic: the CBCharacteristic reference dies with the old process, and without rediscovery the link sits connected-but-unusable until the peer drops it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Defer restored-link service rediscovery until poweredOn Field finding: CBPeripheral.discoverServices issued inside willRestoreState fires before the central manager reaches poweredOn — CoreBluetooth drops the command with an API MISUSE warning, leaving restored-connected links characteristic-less after all. Move the rediscovery to centralManagerDidUpdateState(.poweredOn), which restoration guarantees runs afterwards. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Let disconnect re-arms use the freed slot (Codex P2 on #1396) The background-entry arm reserves 2 of 6 central slots for live discovery, but the disconnect re-arm path shared that budget: with 4+ links remaining the budget hit zero and the just-dropped peer was never armed — defeating walk-away/walk-back re-arming in dense meshes. The disconnect path now arms with no reserve, consuming the slot the disconnect itself freed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
54 lines
2.0 KiB
Swift
54 lines
2.0 KiB
Swift
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<Peripheral> {
|
|
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) }
|
|
}
|
|
}
|