From 117a071ac61fdb1c65fb386b906d143d6f3214ca Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Tue, 14 Jul 2026 18:02:35 -0600 Subject: [PATCH 1/4] Implemented background scanning and restoration --- Sources/ReliaBLE/BluetoothActor.swift | 286 +++++++++++++++++- .../Documentation.docc/Documentation.md | 2 +- .../Documentation.docc/GettingStarted.md | 3 + .../Documentation.docc/Topics/Background.md | 90 ++++++ Sources/ReliaBLE/ReliaBLEConfig.swift | 13 + Sources/ReliaBLE/ReliaBLEManager.swift | 8 +- .../ReliaBLETests/ReliaBLEManagerTests.swift | 229 +++++++++++++- ...d-scanning-state-restoration-2026-07-13.md | 98 ++++++ ...te-restoration-plan-critique-2026-07-13.md | 26 ++ 9 files changed, 750 insertions(+), 5 deletions(-) create mode 100644 Sources/ReliaBLE/Documentation.docc/Topics/Background.md create mode 100644 docs/plans/background-scanning-state-restoration-2026-07-13.md create mode 100644 docs/reviews/background-scanning-state-restoration-plan-critique-2026-07-13.md diff --git a/Sources/ReliaBLE/BluetoothActor.swift b/Sources/ReliaBLE/BluetoothActor.swift index 291e5eb..9594cb1 100644 --- a/Sources/ReliaBLE/BluetoothActor.swift +++ b/Sources/ReliaBLE/BluetoothActor.swift @@ -51,6 +51,22 @@ private struct ConnectionPayload: @unchecked Sendable { let error: Error? } +/// Carries the restoration dictionary from `centralManager(_:willRestoreState:)` across the +/// nonisolated delegate-queue → ``BluetoothActor`` hop. +/// +/// The dictionary and nested `CBPeripheral` / `CBUUID` values are non-`Sendable`. They are treated +/// as immutable payload and only accessed inside ``BluetoothActor`` after the hop, where they are +/// immediately converted into `Sendable` snapshots and actor-owned live references. +private struct RestorationPayload: @unchecked Sendable { + let state: [String: Any] +} + +/// Holds a restored scan-options dictionary so it can live in actor-isolated state without +/// tripping region-based isolation on non-`Sendable` `[String: Any]`. +private struct RestoredScanOptions: @unchecked Sendable { + let options: [String: Any]? +} + /// A single CoreBluetooth delegate callback, carried in delivery order across the nonisolated /// delegate-queue → ``BluetoothActor`` hop. /// @@ -64,6 +80,7 @@ private enum DelegateEvent: Sendable { case connected(ConnectionPayload) case disconnected(ConnectionPayload) case connectFailed(ConnectionPayload) + case willRestore(RestorationPayload) } // MARK: - BluetoothActor @@ -132,6 +149,11 @@ actor BluetoothActor { private var connectionStateChangesContinuations: [UUID: AsyncStream.Continuation] = [:] private var reconnectPolicy: ReconnectPolicy = ReconnectPolicy() + /// Stable CoreBluetooth restore identifier; `nil` disables state restoration. + private var restoreIdentifier: String? + /// Scan filter restored via `willRestoreState` when the central was not yet powered on. + private var pendingRestoredScanServices: [CBUUID]? + private var pendingRestoredScanOptions: RestoredScanOptions? private var reconnectEnabled: Set = [] private var intentionalDisconnects: Set = [] private var reconnectAttempts: [String: Int] = [:] @@ -288,12 +310,22 @@ actor BluetoothActor { /// the lazy-permission contract: the iOS prompt only appears when the integrating app calls /// ``ReliaBLEManager/authorizeBluetooth()``. The initial state is broadcast on first setup and /// whenever the manager is created, but not on every redundant call. - func ensureInitialized(log: LoggingService, reconnectPolicy: ReconnectPolicy = ReconnectPolicy()) { + /// + /// When ``restoreIdentifier`` is non-`nil` and authorization is already `.allowedAlways`, the + /// central is created with `CBCentralManagerOptionRestoreIdentifierKey` so CoreBluetooth can + /// deliver `willRestoreState` as the first callback on relaunch. Authorization is never + /// relaxed — if Bluetooth is not authorized there is nothing to restore. + func ensureInitialized( + log: LoggingService, + reconnectPolicy: ReconnectPolicy = ReconnectPolicy(), + restoreIdentifier: String? = nil + ) { let firstInitialization = !isInitialized if firstInitialization { isInitialized = true configure(log: log) self.reconnectPolicy = reconnectPolicy + self.restoreIdentifier = restoreIdentifier } var createdManager = false @@ -328,7 +360,16 @@ actor BluetoothActor { delegateShim = shim // Use CBCentralManagerFactory for consistency between normal and test targets. // `forceMock: true` is load-bearing for the ReliaBLEMock test target — do not remove. - centralManager = CBCentralManagerFactory.instance(delegate: shim, queue: centralManagerQueue, options: nil, forceMock: true) + var options: [String: Any]? + if let restoreIdentifier { + options = [CBCentralManagerOptionRestoreIdentifierKey: restoreIdentifier] + } + centralManager = CBCentralManagerFactory.instance( + delegate: shim, + queue: centralManagerQueue, + options: options, + forceMock: true + ) delegateEventTask = Task { [weak self] in for await event in events { @@ -354,6 +395,8 @@ actor BluetoothActor { handleDidDisconnect(payload) case .connectFailed(let payload): handleDidFailToConnect(payload) + case .willRestore(let payload): + handleWillRestoreState(payload) } } @@ -449,6 +492,13 @@ actor BluetoothActor { return } + if services == nil || services?.isEmpty == true { + log?.warn( + tags: [.category(.scanning)], + "Scanning with an empty/nil service filter; background scanning requires a non-empty service UUID filter" + ) + } + centralManager.scanForPeripherals(withServices: services, options: nil) if centralManager.isScanning { @@ -521,6 +571,148 @@ actor BluetoothActor { // MARK: - Delegate Entry Points (called by BluetoothDelegateShim) + /// Rehydrates scan and connection state delivered by CoreBluetooth on app relaunch. + /// + /// Restored `CBPeripheral`s arrive with no peripheral delegate and must be re-associated into + /// ``cbPeripherals`` immediately. Connection state is seeded from each peripheral's + /// `CBPeripheral.state`; no synchronous reconnect is issued (standing connects are OS-held). + /// If Bluetooth is later reported off/unauthorized, ``invalidatePeripherals()`` clears this + /// state intentionally. + private func handleWillRestoreState(_ payload: RestorationPayload) { + let restoredPeripherals = payload.state[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral] ?? [] + let restoredScanServices = payload.state[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID] + let restoredScanOptions = payload.state[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any] + + log?.info( + tags: [.category(.scanning)], + "Restoring BLE state: \(restoredPeripherals.count) peripheral(s), scanServices=\(restoredScanServices ?? [])" + ) + + // Empty advertisement placeholder — restoration carries no advertisement payload. + let emptyAdvertisement = AdvertisementData(rawAdvertisementData: [:]) + let now = Date() + var didMutatePeripherals = false + + for cbPeripheral in restoredPeripherals { + // Restored peripherals arrive with no delegate; re-associate into actor-owned maps + // using the same identity rules as discovery. Peripheral-level GATT callbacks are not + // yet used by the library, so no `CBPeripheralDelegate` is attached here. + let identifier = cbPeripheral.name ?? cbPeripheral.identifier.uuidString + let cbIdentifier = cbPeripheral.identifier + let name = cbPeripheral.name + + let resolvedId: String + if let idx = discoveredPeripherals.firstIndex(where: { $0.id == identifier }) { + resolvedId = identifier + discoveredPeripherals[idx] = Peripheral( + id: resolvedId, + cbIdentifier: cbIdentifier, + name: name, + rssi: discoveredPeripherals[idx].rssi, + lastSeen: now, + advertisement: discoveredPeripherals[idx].advertisement ?? emptyAdvertisement + ) + } else if let idx = discoveredPeripherals.firstIndex(where: { $0.cbIdentifier == cbIdentifier }) { + resolvedId = discoveredPeripherals[idx].id + discoveredPeripherals[idx] = Peripheral( + id: resolvedId, + cbIdentifier: cbIdentifier, + name: name ?? discoveredPeripherals[idx].name, + rssi: discoveredPeripherals[idx].rssi, + lastSeen: now, + advertisement: discoveredPeripherals[idx].advertisement ?? emptyAdvertisement + ) + } else { + resolvedId = identifier + discoveredPeripherals.append( + Peripheral( + id: resolvedId, + cbIdentifier: cbIdentifier, + name: name, + rssi: nil, + lastSeen: now, + advertisement: emptyAdvertisement + ) + ) + } + + cbPeripherals[resolvedId] = cbPeripheral + didMutatePeripherals = true + + // Seed connection state after the live reference is registered. Do not reconnect here. + let connectionState: ConnectionState? + switch cbPeripheral.state { + case .connected: + connectionState = .connected + reconnectEnabled.insert(resolvedId) + case .connecting: + connectionState = .connecting + reconnectEnabled.insert(resolvedId) + case .disconnecting: + connectionState = .disconnecting + case .disconnected: + connectionState = nil + @unknown default: + connectionState = nil + } + + if let connectionState { + connectionStates[resolvedId] = connectionState + broadcast( + ConnectionStateChange(peripheralId: resolvedId, state: connectionState), + to: connectionStateChangesContinuations + ) + } + + // Surface each restored peripheral on the lightweight discovery feed so subscribers + // that only listen to `peripheralDiscoveries` still learn about restored devices. + broadcast( + PeripheralDiscoveryEvent(cbPeripheral: cbPeripheral, advertisement: emptyAdvertisement, rssi: 0), + to: discoveryContinuations + ) + } + + if didMutatePeripherals { + broadcast(discoveredPeripherals, to: peripheralsContinuations) + } + + // Resume a scan that was active at termination. If the central is not yet powered on + // (willRestoreState can precede the poweredOn state update), stash the filter and resume + // from handleCentralManagerStateUpdate once powered on. + if let restoredScanServices { + resumeRestoredScan( + services: restoredScanServices, + options: RestoredScanOptions(options: restoredScanOptions) + ) + } + } + + /// Issues `scanForPeripherals` for a restored service filter, or defers until powered on. + private func resumeRestoredScan(services: [CBUUID], options: RestoredScanOptions?) { + guard let centralManager else { + pendingRestoredScanServices = services + pendingRestoredScanOptions = options + return + } + + guard centralManager.state == .poweredOn else { + pendingRestoredScanServices = services + pendingRestoredScanOptions = options + log?.debug(tags: [.category(.scanning)], "Deferred restored scan until powered on") + return + } + + pendingRestoredScanServices = nil + pendingRestoredScanOptions = nil + centralManager.scanForPeripherals(withServices: services, options: options?.options) + if centralManager.isScanning { + log?.info(tags: [.category(.scanning)], "Restored scan resumed with services: \(services)") + updateState() + } else { + log?.warn(tags: [.category(.scanning)], "Failed to resume restored scan") + } + } + func handleCentralManagerStateUpdate() { guard let centralManager else { return } @@ -529,6 +721,9 @@ actor BluetoothActor { switch centralManager.state { case .poweredOn: refreshPeripherals() + if let services = pendingRestoredScanServices { + resumeRestoredScan(services: services, options: pendingRestoredScanOptions) + } case .poweredOff, .unknown: // These states do not invalidate peripherals. break @@ -632,6 +827,8 @@ actor BluetoothActor { reconnectAttempts.removeAll() reconnectEnabled.removeAll() intentionalDisconnects.removeAll() + pendingRestoredScanServices = nil + pendingRestoredScanOptions = nil broadcast(discoveredPeripherals, to: peripheralsContinuations) log?.debug("Invalidated all peripheral references") } @@ -935,6 +1132,85 @@ actor BluetoothActor { func testSeedIntentionalDisconnect(_ id: String) { intentionalDisconnects.insert(id) } + + /// Test-only hook: drives restoration with a hand-built CoreBluetooth restore dictionary, + /// routing through `process(.willRestore)` the same way ``BluetoothDelegateShim`` would. + /// + /// Needed because CoreBluetoothMock cannot synthesize `willRestoreState`. + func testInvokeWillRestoreState(_ state: [String: Any]) { + process(.willRestore(RestorationPayload(state: state))) + } + + /// Test-only hook: builds a restoration dictionary from actor-owned live peripherals and + /// drives ``testInvokeWillRestoreState(_:)``. Keeps non-`Sendable` `CBPeripheral` references + /// inside the actor isolation boundary. + func testInvokeWillRestoreState( + peripheralIds: [String], + scanServices: [CBUUID]? = nil, + scanOptions: [String: Any]? = nil + ) { + var state: [String: Any] = [:] + // Avoid compactMap/closure patterns the region-based isolation checker cannot analyze + // for non-Sendable CBPeripheral values. + var peripherals: [CBPeripheral] = [] + for id in peripheralIds { + if let peripheral = cbPeripherals[id] { + peripherals.append(peripheral) + } + } + if !peripherals.isEmpty { + state[CBCentralManagerRestoredStatePeripheralsKey] = peripherals + } + if let scanServices { + state[CBCentralManagerRestoredStateScanServicesKey] = scanServices + } + if let scanOptions { + state[CBCentralManagerRestoredStateScanOptionsKey] = scanOptions + } + testInvokeWillRestoreState(state) + } + + /// Test-only hook: whether `id` is currently in ``reconnectEnabled``. + func testIsReconnectEnabled(_ id: String) -> Bool { + reconnectEnabled.contains(id) + } + + /// Test-only hook: whether a live `CBPeripheral` is registered for `id`. + func testContainsCBPeripheral(_ id: String) -> Bool { + cbPeripherals[id] != nil + } + + /// Test-only hook: whether the central is currently scanning. + func testIsScanning() -> Bool { + centralManager?.isScanning == true + } + + /// Test-only hook: service filter stashed when a restored scan was deferred until powered on. + func testPendingRestoredScanServices() -> [CBUUID]? { + pendingRestoredScanServices + } + + /// Test-only hook: restore identifier captured on first ``ensureInitialized`` call. + func testRestoreIdentifier() -> String? { + restoreIdentifier + } + + /// Test-only hook: overwrites the stored restore identifier (process-lifetime actor may already + /// have been initialized by an earlier test without one). + func testSetRestoreIdentifier(_ id: String?) { + restoreIdentifier = id + } + + /// Test-only hook: clears discovery snapshots and connection intent so a subsequent restore can + /// exercise the cold-relaunch (append-new) identity branch, while keeping live `CBPeripheral` + /// references available for ``testInvokeWillRestoreState(peripheralIds:scanServices:scanOptions:)``. + func testClearDiscoveredSnapshotsPreservingLiveReferences() { + discoveredPeripherals.removeAll() + connectionStates.removeAll() + reconnectEnabled.removeAll() + pendingRestoredScanServices = nil + pendingRestoredScanOptions = nil + } } // MARK: - BluetoothDelegateShim @@ -988,4 +1264,10 @@ final class BluetoothDelegateShim: NSObject, CBCentralManagerDelegate { let payload = ConnectionPayload(peripheral: peripheral, isReconnecting: isReconnecting, error: error) eventContinuation.yield(.disconnected(payload)) } + + func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) { + // First callback on relaunch when a restore identifier was used. Ferry the non-Sendable + // restoration dictionary across the actor hop; extraction happens inside the actor. + eventContinuation.yield(.willRestore(RestorationPayload(state: dict))) + } } diff --git a/Sources/ReliaBLE/Documentation.docc/Documentation.md b/Sources/ReliaBLE/Documentation.docc/Documentation.md index 68f5de0..9ed8a15 100644 --- a/Sources/ReliaBLE/Documentation.docc/Documentation.md +++ b/Sources/ReliaBLE/Documentation.docc/Documentation.md @@ -11,7 +11,6 @@ This is a temporary overview. ### Essentials - -- ### Peripherals @@ -28,3 +27,4 @@ This is a temporary overview. ### Advanced Usage - +- diff --git a/Sources/ReliaBLE/Documentation.docc/GettingStarted.md b/Sources/ReliaBLE/Documentation.docc/GettingStarted.md index b6c341d..3bae4b6 100644 --- a/Sources/ReliaBLE/Documentation.docc/GettingStarted.md +++ b/Sources/ReliaBLE/Documentation.docc/GettingStarted.md @@ -215,3 +215,6 @@ do { ``` Each access to `connectionStateChanges` yields a fresh stream; it does not replay, so begin iteration before calling ``ReliaBLEManager/connect(to:autoReconnect:)``. The `ConnectionState` values are ``ConnectionState/connecting``, ``ConnectionState/connected``, ``ConnectionState/disconnecting``, ``ConnectionState/disconnected(reason:)``, ``ConnectionState/failed(reason:)``, and ``ConnectionState/reconnecting(source:attempt:nextRetryAt:)``. Terminal states carry an optional ``PeripheralError`` reason. + +For keeping a session alive while your app is backgrounded or after it is +terminated by the system, see . diff --git a/Sources/ReliaBLE/Documentation.docc/Topics/Background.md b/Sources/ReliaBLE/Documentation.docc/Topics/Background.md new file mode 100644 index 0000000..5ea383e --- /dev/null +++ b/Sources/ReliaBLE/Documentation.docc/Topics/Background.md @@ -0,0 +1,90 @@ +# Background Scanning & State Restoration + +Keep a BLE session alive across app suspension and termination. + +## Overview + +iOS can terminate your app while it is in the background. ReliaBLE supports +CoreBluetooth state restoration so the system can relaunch you into a working +BLE session — restoring an active scan and any connected or connecting +peripherals — without losing state. + +## Prerequisites + +1. Add `bluetooth-central` to the `UIBackgroundModes` array in your + `Info.plist`. Without this entry, your app cannot scan in the background + and will not be relaunched for BLE events. + + ```xml + UIBackgroundModes + + bluetooth-central + + ``` + +2. Set a stable ``ReliaBLEConfig/restoreIdentifier`` on your configuration. + This value **must be the same across launches** — changing it breaks the + restoration chain. Use a fixed string, such as a bundle-derived constant: + + ```swift + var config = ReliaBLEConfig() + config.restoreIdentifier = "\(Bundle.main.bundleIdentifier!).ble-central" + let bleManager = ReliaBLEManager(config: config) + ``` + + When `restoreIdentifier` is set, constructing ``ReliaBLEManager`` with + an already-authorized Bluetooth state creates the underlying central + manager immediately (with its delegate attached) so that the + `willRestoreState` callback is reachable on relaunch. The authorization + gate (`.allowedAlways`) is unchanged — the prompt still belongs to your + app via ``ReliaBLEManager/authorizeBluetooth()``. + +> Note: This eager central-manager creation is the one exception to ReliaBLE's +> otherwise lazy initialization. When ``ReliaBLEConfig/restoreIdentifier`` is +> set and Bluetooth authorization is already `.allowedAlways`, the internal +> central manager is created eagerly at ``ReliaBLEManager`` init (with its +> delegate attached) so that the `willRestoreState` callback is reachable on +> relaunch. The authorization gate and lazy-prompt contract are otherwise +> unchanged — setting a restore identifier without `.allowedAlways` auth does +> not create the central early. + +## Background scanning + +When scanning in the background, iOS requires a **non-nil, non-empty service +filter**. Scans with `nil` or empty `services` return no discoveries while +your app is backgrounded; the same call works normally in the foreground. + +```swift +// Always pass a service filter for background-capable scans. +await bleManager.startScanning(services: [CBUUID(string: "180D")]) +``` + +> Important: Background advertisement data may be truncated or absent compared +> to foreground scans. ``AdvertisementData`` fields that originate from scan +> response data (such as local name) are especially affected. + +## Restored connections + +If your app had connected or connecting peripherals when it was terminated, +those connections are restored automatically. You do **not** call a separate +restoration API — restored peripherals appear on the same streams you already +use: + +- ``ReliaBLEManager/discoveredPeripherals`` emits restored peripherals (along + with any newly discovered ones). +- ``ReliaBLEManager/connectionStateChanges`` emits the rehydrated connection + states — ``ConnectionState/connected`` for preserved connections, + ``ConnectionState/connecting`` for in-progress attempts. + +The Tier-0 system-managed reconnection (``ReconnectSource/system``) survives +app termination because it runs in the iOS daemon; Tier-1 library-managed +reconnection (``ReconnectSource/library``) does not, but the library re-arms +reconnect intent for restored connections so a post-relaunch drop triggers +the exponential-backoff ladder governed by ``ReconnectPolicy``. + +> Note: ReliaBLE deliberately does **not** pass +> `CBConnectPeripheralOptionNotifyOnConnection` or +> `CBConnectPeripheralOptionNotifyOnDisconnection`. These options cause iOS to +> post a system alert when your app is not running and a connection or +> disconnection occurs. They can be added non-breakingly in a future release +> if a use case emerges. diff --git a/Sources/ReliaBLE/ReliaBLEConfig.swift b/Sources/ReliaBLE/ReliaBLEConfig.swift index 2619dcd..248b248 100644 --- a/Sources/ReliaBLE/ReliaBLEConfig.swift +++ b/Sources/ReliaBLE/ReliaBLEConfig.swift @@ -52,6 +52,19 @@ public struct ReliaBLEConfig: Sendable { /// ``ReliaBLEManager/connect(to:autoReconnect:)``; this policy governs *how* the /// library retries when auto-reconnect is active. public var reconnectPolicy = ReconnectPolicy() + + /// Stable CoreBluetooth state-restoration identifier for the central manager. + /// + /// When non-`nil`, ReliaBLE creates `CBCentralManager` with + /// `CBCentralManagerOptionRestoreIdentifierKey` so iOS can relaunch the app into a + /// working BLE session after termination (restoring an active scan and any + /// connected/connecting peripherals). The value **must be stable across launches** — + /// changing it breaks the restoration chain. Use a fixed string such as a bundle-derived + /// constant (for example `"\(Bundle.main.bundleIdentifier!).ble-central"`). + /// + /// The default `nil` disables state restoration and preserves the existing lazy-init + /// contract unchanged. + public var restoreIdentifier: String? = nil /// Initializes a new `ReliaBLEConfig` instance with the default values. public init() { diff --git a/Sources/ReliaBLE/ReliaBLEManager.swift b/Sources/ReliaBLE/ReliaBLEManager.swift index d5896ec..d8fdf03 100644 --- a/Sources/ReliaBLE/ReliaBLEManager.swift +++ b/Sources/ReliaBLE/ReliaBLEManager.swift @@ -59,7 +59,13 @@ public final class ReliaBLEManager: Sendable { // immediately after `init` from racing ahead of that setup, every public entry point funnels // through `ensureInitialized(log:)` (which is idempotent) before acting — so this eager call // is an optimization, not a correctness requirement. - Task { await BluetoothActor.shared.ensureInitialized(log: loggingService, reconnectPolicy: config.reconnectPolicy) } + Task { + await BluetoothActor.shared.ensureInitialized( + log: loggingService, + reconnectPolicy: config.reconnectPolicy, + restoreIdentifier: config.restoreIdentifier + ) + } } // MARK: - State diff --git a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift index 6fa9ddd..e9eb337 100644 --- a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift +++ b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift @@ -1346,6 +1346,228 @@ struct ReliaBLEManagerTests { try? await Task.sleep(nanoseconds: 200_000_000) } + // MARK: - State Restoration + + @Test func reliaBLEConfigRestoreIdentifierDefaultsToNil() { + let config = ReliaBLEConfig() + #expect(config.restoreIdentifier == nil) + + var custom = ReliaBLEConfig() + custom.restoreIdentifier = "com.example.ble-central" + #expect(custom.restoreIdentifier == "com.example.ble-central") + } + + @Test func ensureInitializedWithoutRestoreIdentifierDoesNotCreateCentralWhenUnauthorized() async throws { + // Default makeManager pins .notDetermined before construction. A restoreIdentifier of nil + // must preserve the lazy contract: no central until authorize / allowedAlways. + CBMCentralManagerMock.simulateAuthorization(.notDetermined) + + // Only meaningful on a cold process where no earlier test created the singleton central. + let alreadyHadCentral = await BluetoothActor.shared.hasCentralManager + let manager = await Mock.makeManager(restoreIdentifier: nil) + try? await Task.sleep(nanoseconds: 200_000_000) + + if !alreadyHadCentral { + #expect(!(await BluetoothActor.shared.hasCentralManager)) + } + // Config default remains nil on the value type regardless of actor lifetime. + #expect(ReliaBLEConfig().restoreIdentifier == nil) + _ = manager + } + + @Test func ensureInitializedWithRestoreIdentifierCreatesCentralWhenAuthorized() async throws { + let restoreId = "com.five3apps.relia-ble.tests.restore" + + // When authorized, a restoreIdentifier does not relax the auth gate — it only adds the + // restore-id option to the existing creation path. Process-lifetime central may already + // exist; still verify construction + ensureReady succeeds with the config set. + CBMCentralManagerMock.simulateAuthorization(.allowedAlways) + CBMCentralManagerMock.simulatePowerOn() + + let manager = await Mock.makeManager(restoreIdentifier: restoreId) + // firstInitialization only runs once per process; set the id so later assertions on the + // stored value are meaningful even if this suite is not first to touch the actor. + await BluetoothActor.shared.testSetRestoreIdentifier(restoreId) + #expect(await BluetoothActor.shared.testRestoreIdentifier() == restoreId) + + await Mock.ensureReady(manager) + #expect(await BluetoothActor.shared.hasCentralManager) + } + + @Test func willRestoreRepopulatesMapsSeedsConnectionStateAndBroadcasts() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + await manager.startScanning() + let peripheral = await Mock.waitForPeripheral( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let discovered = try #require(peripheral) + await manager.stopScanning() + + try await manager.connect(to: discovered) + let connected = await firstConnectionStateChange( + from: manager.connectionStateChanges, + withinNanoseconds: 5_000_000_000 + ) + // Drain connecting → connected if needed. + if connected?.state == .connecting { + let c2 = await firstConnectionStateChange( + from: manager.connectionStateChanges, + withinNanoseconds: 5_000_000_000 + ) + #expect(c2?.state == .connected) + } else { + #expect(connected?.state == .connected) + } + + // Simulate cold relaunch: drop snapshots / intent while keeping live CBPeripheral refs + // so the restore path can rehydrate from the hand-built dictionary. + await BluetoothActor.shared.testClearDiscoveredSnapshotsPreservingLiveReferences() + #expect(await manager.currentConnectionStates[discovered.id] == nil) + #expect(!(await BluetoothActor.shared.testIsReconnectEnabled(discovered.id))) + + // Subscribe before restore so broadcasts are not missed. + var discovery = manager.peripheralDiscoveries.makeAsyncIterator() + var peripherals = manager.discoveredPeripherals.makeAsyncIterator() + var connectionChanges = manager.connectionStateChanges.makeAsyncIterator() + // Force registration hops. + _ = await manager.currentConnectionStates + + let scanUUID = CBUUID(string: "180D") + await BluetoothActor.shared.testInvokeWillRestoreState( + peripheralIds: [discovered.id], + scanServices: [scanUUID], + scanOptions: nil + ) + + // Maps rehydrated with discovery identity (name-based id preserved). + #expect(await BluetoothActor.shared.testContainsCBPeripheral(discovered.id)) + let restoredList = await BluetoothActor.shared.discoveredPeripherals + #expect(restoredList.contains(where: { $0.id == discovered.id })) + #expect(await manager.currentConnectionStates[discovered.id] == .connected) + #expect(await BluetoothActor.shared.testIsReconnectEnabled(discovered.id)) + + // Streams broadcast restored state. + let discoveryEvent = await discovery.next() + #expect(discoveryEvent != nil) + + let peripheralsEvent = await peripherals.next() + #expect(peripheralsEvent?.contains(where: { $0.id == discovered.id }) == true) + + let connectionEvent = await connectionChanges.next() + #expect(connectionEvent?.peripheralId == discovered.id) + #expect(connectionEvent?.state == .connected) + + // Scan resumed (central is powered on). + #expect(await BluetoothActor.shared.testIsScanning()) + #expect(await BluetoothActor.shared.testPendingRestoredScanServices() == nil) + + try? await manager.disconnect(from: discovered) + await manager.stopScanning() + } + + @Test func willRestoreSeedingReconnectOnlyForConnectedOrConnecting() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + // Discover both peripherals so we have live refs for restore. + await manager.startScanning() + let connectionPeripheral = await Mock.waitForPeripheral( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let connected = try #require(connectionPeripheral) + let scanPeripheral = await Mock.waitForPeripheral( + id: Mock.testPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let disconnected = try #require(scanPeripheral) + await manager.stopScanning() + + try await manager.connect(to: connected) + _ = await firstConnectionStateChange( + from: manager.connectionStateChanges, + withinNanoseconds: 5_000_000_000 + ) + // Wait until connected (may already be past .connecting). + _ = await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[connected.id] == .connected + } + + await BluetoothActor.shared.testClearDiscoveredSnapshotsPreservingLiveReferences() + + await BluetoothActor.shared.testInvokeWillRestoreState( + peripheralIds: [connected.id, disconnected.id], + scanServices: nil + ) + + #expect(await manager.currentConnectionStates[connected.id] == .connected) + #expect(await BluetoothActor.shared.testIsReconnectEnabled(connected.id)) + // Disconnected restored peripherals do not seed connectionStates / reconnectEnabled. + #expect(await manager.currentConnectionStates[disconnected.id] == nil) + #expect(!(await BluetoothActor.shared.testIsReconnectEnabled(disconnected.id))) + + try? await manager.disconnect(from: connected) + } + + @Test func willRestoreDefersScanUntilPoweredOn() async throws { + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + await manager.startScanning() + let peripheral = await Mock.waitForPeripheral( + id: Mock.testPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let discovered = try #require(peripheral) + await manager.stopScanning() + + // Power off before restore so resumeRestoredScan stashes the filter. + CBMCentralManagerMock.simulatePowerOff() + #expect(await Mock.waitForState("Powered Off", on: manager)) + + await BluetoothActor.shared.testClearDiscoveredSnapshotsPreservingLiveReferences() + + let scanUUID = CBUUID(string: "180D") + await BluetoothActor.shared.testInvokeWillRestoreState( + peripheralIds: [discovered.id], + scanServices: [scanUUID] + ) + + #expect(await BluetoothActor.shared.testPendingRestoredScanServices() == [scanUUID]) + #expect(!(await BluetoothActor.shared.testIsScanning())) + + // Power on should resume the deferred restored scan. + CBMCentralManagerMock.simulatePowerOn() + let becameScanning = await Mock.waitForState("Scanning", on: manager) + if !becameScanning { + #expect(await Mock.waitForState("Ready", on: manager)) + } + + let resumed = await pollUntil(timeout: 3.0) { + let pending = await BluetoothActor.shared.testPendingRestoredScanServices() + let scanning = await BluetoothActor.shared.testIsScanning() + return pending == nil && scanning + } + #expect(resumed) + #expect(await BluetoothActor.shared.testPendingRestoredScanServices() == nil) + #expect(await BluetoothActor.shared.testIsScanning()) + + await manager.stopScanning() + } + // MARK: - Event Stream Broadcaster @Test func stateStreamReplaysToConcurrentSubscribers() async throws { @@ -1476,11 +1698,16 @@ enum Mock { /// is pinned to `.notDetermined` **before** any central can be created — including by the maintainer's /// authorization tests, whose `.notDetermined` `authorize()` path itself creates a central. Use /// ``ensureReady(_:)`` afterwards to bring the shared central online. - static func makeManager(loggingEnabled: Bool = false, reconnectPolicy: ReconnectPolicy? = nil) async -> ReliaBLEManager { + static func makeManager( + loggingEnabled: Bool = false, + reconnectPolicy: ReconnectPolicy? = nil, + restoreIdentifier: String? = nil + ) async -> ReliaBLEManager { await SimulationConfig.shared.ensureConfigured() var config = ReliaBLEConfig() config.loggingEnabled = loggingEnabled + config.restoreIdentifier = restoreIdentifier if let reconnectPolicy { config.reconnectPolicy = reconnectPolicy } else { diff --git a/docs/plans/background-scanning-state-restoration-2026-07-13.md b/docs/plans/background-scanning-state-restoration-2026-07-13.md new file mode 100644 index 0000000..6baad18 --- /dev/null +++ b/docs/plans/background-scanning-state-restoration-2026-07-13.md @@ -0,0 +1,98 @@ +# Background Scanning + State Restoration: Plan +*Issue #38 (FR-8.3) · 2026-07-13* + +## Goal +Extend the completed foreground scanning foundation (FR-8.1) so ReliaBLE keeps delivering discovery events while the integrating app is backgrounded, and add **full CoreBluetooth state restoration** so the app is relaunched into a working BLE session — restoring both an **active scan** and **active/pending connections** — after iOS terminates it. The integrating app supplies the restoration identifier, with a sane default. This also discharges the **"Background reconnection" follow-up** deferred by the auto-reconnect plan (`docs/plans/auto-reconnect-backoff-2026-07-05.md`, now merged as PR #41). + +## Background +_All state below verified against the current worktree (post-#41 rebase onto `origin/master` @ `3d19332`)._ + +**No prior background/restoration work exists** — no code touches `CBCentralManagerOptionRestoreIdentifierKey`, `willRestoreState`, `UIBackgroundModes`, or background scan options. This is greenfield on top of the finished scanning/stream/connection/**auto-reconnect** foundation. PR #41 (auto-reconnect) explicitly deferred background reconnection (standing connects, restore identifier, `bluetooth-central`, `NotifyOnConnection/Disconnection`) to "its own issue" — this plan is that issue. + +Current seams (`Sources/ReliaBLE/`, verified line numbers): +- **Central creation** — `setupCentralManager()` at `BluetoothActor.swift:313`; factory call `CBCentralManagerFactory.instance(delegate:queue:options:forceMock:)` at `:331` passes **`options: nil`**. Lazy/auth-gated via `ensureInitialized(log:reconnectPolicy:)` at `:291` (creates central only when `authorization == .allowedAlways`). +- **Scanning** — `startScanning(services:)` at `:441` → `scanForPeripherals(withServices:options:)` at `:452` with **`options: nil`**; `stopScanning()` at `:462`. +- **Delegate shim** — `BluetoothDelegateShim` at `:948` (`didDiscover` at `:966`, plus `didUpdateState`/`didConnect`/`didFailToConnect`/`didDisconnect`); yields a `DelegateEvent` (enum at `:61`) drained by `process(_:)` at `:341` → `handle*`. **No `willRestoreState`.** +- **Discovery plumbing** — `handlePeripheralDiscovered` at `:546` broadcasts `PeripheralDiscoveryEvent` and updates `discoveredPeripherals`/`cbPeripherals`. Broadcaster: `nonisolated` per-subscriber stream factories + `register(...)` + `broadcast(_:to:)`. +- **Connection + reconnect state (from #41, actor-isolated, never escapes):** `cbPeripherals` `:117`, `connectionStates` `:130`, `reconnectPolicy` `:134`, `reconnectEnabled` `:135`, `intentionalDisconnects` `:136`, `reconnectAttempts` `:137`, `reconnectTasks` `:138`. +- **Connect/disconnect** — `connect(id:autoReconnect:)` at `:671`; the OS option `[CBConnectPeripheralOptionEnableAutoReconnect: true]` is built at `:692`. `disconnect(id:)` at `:705`. Handlers: `handleDidConnect` `:737`, `handleDidDisconnect` `:750` (honors `isReconnecting`), `handleDidFailToConnect` `:799`. +- **Library reconnect ladder (Tier 1)** — `armReconnect(id:)` `:817` → `scheduleReconnect(id:attempt:)` `:835`, whose retry `Task` uses `Task.sleep` at `:863`. **This ladder is process-scoped and does NOT survive app suspension/termination** — the gap restoration must close. +- **Config** — `ReliaBLEConfig.swift`: logging fields + `reconnectPolicy` `:43` (`ReconnectPolicy` `:52-84`). Flows `init(config:)` → `ensureInitialized(log:reconnectPolicy:)`; precedent for adding a background/restoration field. +- **State model** — `ConnectionState` (`Models/ConnectionState.swift:10`): `.connecting`, `.reconnecting(source:attempt:nextRetryAt:)`, `.connected`, `.disconnecting`, `.disconnected(reason:)`, `.failed(reason:)`; `ReconnectSource` (`.system`/`.library`) at `:50`. +- **Public API** — `ReliaBLEManager.swift`: `init(config:)`, `authorizeBluetooth()` `:118`, `startScanning(services:)` `:160`, `stopScanning()` `:166`, `connect(to:autoReconnect:)` `:186`, `disconnect(from:)` `:199`, plus `state`/`peripheralDiscoveries`/`discoveredPeripherals`/`connectionStateChanges` streams. +- **Mock limitation** — `CoreBluetoothMock` cannot simulate `CBConnectPeripheralOptionEnableAutoReconnect` (see the comment at `BluetoothActor.swift:914`) and cannot synthesize `willRestoreState`. This bounds test coverage (see Approach). + +**Background-BLE pitfalls to design around** (external research): +1. Service-UUID filter is **mandatory** in background; `nil`-service scans return nothing. +2. Advertisement data is truncated/absent in background. +3. `AllowDuplicatesKey` is silently ignored + discoveries are coalesced/throttled. +4. Restoration ID must be **unique + stable** per manager; reuse corrupts state. +5. `willRestoreState` is the **first** delegate call on relaunch — delegate must be attached at central init, before any other use. +6. Restoration requires recreating the manager with the **exact same ID** before any other BLE call. +7. Only actively-scanning/connecting managers are preserved. +8. Duplicate `CBCentralManager` instances break the restoration chain (favor the existing single-instance facade). +9. Missing Info.plist `bluetooth-central` → no background scanning and no relaunch. +10. ~10s execution budget on wake; keep restoration-path work async and cheap. +11. Restored `CBPeripheral`s (both discovered **and connected/connecting**) arrive with **no delegate** — must be re-wired immediately or all subsequent events are lost. + +## Approach + +The work is **purely additive** and re-uses every existing pattern: the `ReliaBLEConfig` → `ensureInitialized` config flow, the `DelegateEvent` → shim → `process(_:)` → `handle*` drain, the broadcaster streams, and the single `CBCentralManagerFactory` creation seam. Nothing about `Peripheral`, `CBCentralManagerFactory`, `Package.swift`, or production logging changes. + +**Restoration ID via config, `nil` by default.** Add one field `restoreIdentifier: String?` (default `nil`) to `ReliaBLEConfig`, mirroring the `reconnectPolicy` precedent. `nil` = restoration disabled, lazy contract fully preserved. When set, it is passed through `init(config:)` → a config-aware `ensureInitialized` overload → `setupCentralManager`, which builds `[CBCentralManagerOptionRestoreIdentifierKey: id]` instead of `nil` at the factory call (`BluetoothActor.swift:331`). + +**Reconciling lazy init with restoration.** The `.allowedAlways` creation gate is **preserved** — this is *not* a relaxation. State restoration only preserves state for apps that were already authorized and had active BLE, so if auth isn't `.allowedAlways` there is nothing to restore and the library behaves exactly as today. The change is narrow: when `restoreIdentifier` is set **and** auth is `.allowedAlways`, `ensureInitialized` (already fired fire-and-forget from `init`) creates the central with the restore-id option and its delegate attached — which is what makes `willRestoreState` (the first callback on relaunch) reachable. The app triggers restoration simply by constructing `ReliaBLEManager(config:)` with the same `restoreIdentifier` early in launch; no separate `restore(launchOptions:)` entry point is needed, because CoreBluetooth delivers `willRestoreState` off the recreated, delegate-attached manager before any other call. + +**Restoration handling.** Extend the closed `DelegateEvent` enum (`:61`) with `case willRestore(...)` (`Sendable`-boxed payload, yielded on the delegate queue like other events). `BluetoothDelegateShim` (`:948`) implements `centralManager(_:willRestoreState:)`; `process(_:)` (`:341`) routes it to a new actor-isolated `handleWillRestoreState(_:)` that: +1. **Peripherals** — reads `CBMCentralManagerRestoredStatePeripheralsKey`, re-wires each restored `CBPeripheral`'s delegate (they arrive delegate-less) and repopulates `cbPeripherals` (`:117`), keyed by the same identity rule as `handlePeripheralDiscovered` (`:546`). +2. **Scan** — reads `CBMCentralManagerRestoredStateScanServicesKey`/`…ScanOptionsKey`; if a scan was active, resumes `scanForPeripherals` with the restored (mandatory non-nil) service filter. +3. **Broadcast** — surfaces restored peripherals through the **existing** `discoveryContinuations`/`peripheralsContinuations`. No separate restoration API (per the issue's acceptance criteria). All work is dict lookups + map inserts, well inside the ~10s wake budget. + +**Connection state restoration (the deferred #41 item).** The restored peripheral array also contains devices that were **connected or connecting** at termination (each `CBPeripheral.state` says which). Standing connects + Tier-0 OS auto-reconnect (`CBConnectPeripheralOptionEnableAutoReconnect`, `:692`) are daemon-held and relaunch the app on connect; the Tier-1 library ladder (`scheduleReconnect`/`Task.sleep`, `:835`/`:863`) died with the process. So `handleWillRestoreState` also rehydrates connection state: +- Runs **after** the peripheral step has repopulated `cbPeripherals` (`:117`); on a cold relaunch `reconnectAttempts`/`reconnectTasks` are already empty (fresh process), so no clearing is needed. +- Seed `connectionStates[id]` (`:130`) from the restored `CBPeripheral.state` (`.connected` → `.connected`, `.connecting` → `.connecting`) and broadcast on `connectionStateChanges`. +- Re-arm Tier-1 **intent**: re-insert restored connected/connecting ids into `reconnectEnabled` (`:135`) so a post-relaunch drop can re-arm `armReconnect` (`:817`). +- **Do not** synchronously reconnect on the restoration path (10s budget) — rely on the OS holding standing connects, with normal `handleDidConnect`/`handleDidDisconnect` (`:737`/`:750`) callbacks flowing afterward. +- **Teardown interaction:** if Bluetooth is off/unauthorized at relaunch, the `didUpdateState` that follows `willRestoreState` runs `invalidatePeripherals` (`:620`) and clears the just-restored `connectionStates`/`reconnectEnabled` — this is intentional (nothing to restore when BT is unavailable). Restoration assumes `willRestoreState` is followed by `.poweredOn`; no re-seed is attempted. + +**Connect notification options — off for now.** `CBConnectPeripheralOptionNotifyOnConnection` / `…NotifyOnDisconnection` (which make iOS post a system alert when the app isn't running) are deliberately **not** passed in the first cut; connect options stay as-is (`EnableAutoReconnect` only, `:692`). This keeps the "it just works" default quiet and predictable; they can be added non-breakingly later if a use case appears (see Decisions Q6). + +**Background scan constraints.** No new public scan parameter — the existing `startScanning(services:)` argument already satisfies the mandatory background service filter. Add a warning log when `services` is empty/`nil`. `AllowDuplicatesKey` is intentionally not set (ignored in background). Truncated background advertisement data is a documentation concern on `AdvertisementData`. + +**Testability boundary.** `CBMCentralManagerMock` can synthesize neither `willRestoreState` nor the `EnableAutoReconnect` state machine (`:914`), so tests exercise `handleWillRestoreState(_:)` (and the new `DelegateEvent` case) **directly** at the actor boundary with a hand-built restoration dictionary — asserting map/`connectionStates`/`reconnectEnabled` population, stream broadcast, and scan resumption. The eager-vs-lazy creation branch is covered via the existing `Mock.makeManager` harness with/without a `restoreIdentifier`. + +## Work Items +_Ordered so each step compiles and is independently testable; steps 2–4 add unreachable code until step 5 wires the restore identifier into central creation._ + +1. **Config field** — add `restoreIdentifier: String? = nil` to `ReliaBLEConfig` (`ReliaBLEConfig.swift`) and its init; document the "stable across launches" requirement. +2. **Event + shim** — extend `DelegateEvent` (`:61`) with `case willRestore(...)` and implement `centralManager(_:willRestoreState:)` in `BluetoothDelegateShim` (`:948`). No behavior change yet. +3. **Restoration handler — scan + peripherals** — implement `handleWillRestoreState(_:)` + dispatch in `process(_:)` (`:341`): re-wire restored peripherals, repopulate `cbPeripherals` (`:117`), resume scan, broadcast via existing continuations. Reuse identity logic from `handlePeripheralDiscovered` (`:546`). +4. **Restoration handler — connections** — in the same handler (after step 3 has populated `cbPeripherals`), seed `connectionStates` (`:130`) from restored `CBPeripheral.state`, broadcast on `connectionStateChanges`, and re-arm `reconnectEnabled` (`:135`) for connected/connecting peripherals. No synchronous reconnect. +5. **Restore-id creation path** — thread `restoreIdentifier` through a config-aware `ensureInitialized` overload (`:291`) into `setupCentralManager` (`:313`); build `[CBCentralManagerOptionRestoreIdentifierKey: id]` at the factory call (`:331`, keep `forceMock: true`). **Keep the `.allowedAlways` gate unchanged** — creation stays authorization-gated (if not authorized, there is nothing to restore). Wire `config.restoreIdentifier` through `ReliaBLEManager.init`. +6. **Background scan guard** — add the empty-filter warning in `startScanning` (`:441`). +7. **Tests** — cover the new `DelegateEvent` case, `handleWillRestoreState` (scan + connection rehydration), and the eager/lazy branch — direct actor-boundary tests + `Mock.makeManager` config variants. Mock can't fire `willRestoreState` or `EnableAutoReconnect` (`:914`); end-to-end mock fidelity is a follow-up (**#42**). +8. **DocC** — add a "Background scanning & state restoration" section to `GettingStarted.md` (Info.plist `bluetooth-central`, `restoreIdentifier`, mandatory service filter, restored connections, and the deliberate off-by-default notification options per Q6) and note the eager-creation exception in `Topics/Concurrency.md`. +9. **Demo** — delegate to a sub-agent (must read `Demo/CLAUDE.md` first): add `bluetooth-central` to the Demo `Info.plist`, set a `restoreIdentifier`, demonstrate background discovery **and** a restored standing connection. Additive only. + +## Decisions (resolved at mid-flow check-in) +1. **Restoration ID API shape** — single optional `restoreIdentifier: String?` on `ReliaBLEConfig`, `nil` = off; documented bundle-derived default *pattern*, not auto-applied. +2. **Background filter enforcement** — warn + log on empty/`nil` filter, still pass through; foreground behavior unchanged. +3. **Creation gate** — central creation stays gated on `.allowedAlways` (unchanged); `restoreIdentifier` only adds the restore-id option to the existing init-time creation. No auth-gate relaxation — if BT isn't authorized there is nothing to restore. +4. **Restored-state surfacing** — reuse existing `discoveredPeripherals` / `connectionStateChanges` streams; no dedicated restoration event. +5. **Test depth** — direct actor-boundary tests of `handleWillRestoreState` / the new `DelegateEvent` case (mock can't fire `willRestoreState`/`EnableAutoReconnect`). End-to-end mock fidelity filed as follow-up **#42**. +6. **Connect notification options** — **off by default for now.** `NotifyOnConnection`/`NotifyOnDisconnection` are not passed; connect options stay `EnableAutoReconnect`-only. Chosen for "it just works" reliability; addable non-breakingly later. + +## Orchestration Progress +_Maintained by the orchestrator. Dispatched work items group the plan steps above._ +- [x] **A. Library core** (steps 1–6): config field, `DelegateEvent.willRestore` + shim, `handleWillRestoreState` (scan/peripherals + connection rehydration), restore-id creation path, background-scan guard. +- [x] **B. Tests** (step 7): new `DelegateEvent` case, `handleWillRestoreState`, eager/lazy branch. +- [x] **C. DocC** (step 8): `GettingStarted.md` background section + `Topics/Concurrency.md` eager-creation note. +- [x] **D. Demo** (step 9): Demo `Info.plist` `bluetooth-central`, `restoreIdentifier`, restored standing connection. + +## References +- Issue #38 (FR-8.3.1, FR-8.3.2); PRD FR-8.3; foundation FR-8.1 (#3, completed) +- **Handoff:** `docs/plans/auto-reconnect-backoff-2026-07-05.md` → "Deferred / follow-up: Background reconnection" (PR #41, merged) — this plan addresses it. +- Apple: [Core Bluetooth Background Processing & State Preservation/Restoration](https://developer.apple.com/library/archive/documentation/NetworkingInternetWeb/Conceptual/CoreBluetooth_concepts/CoreBluetoothBackgroundProcessingForIOSApps/PerformingTasksWhileYourAppIsInTheBackground.html); `CBCentralManagerOptionRestoreIdentifierKey`; `centralManager(_:willRestoreState:)`; `CBConnectPeripheralOptionNotifyOnConnection`/`…NotifyOnDisconnection`; `UIBackgroundModes` (`bluetooth-central`) +- Architecture: `AGENTS.md` (`@BluetoothActor`, three-target mock harness, lazy init, `forceMock`); Demo work per `Demo/CLAUDE.md` +- Follow-up filed: **#42** (simulate `willRestoreState` in `CoreBluetoothMock` for end-to-end restoration tests). +- Prior plans: `docs/plans/connection-lifecycle-stream-2026-06-30.md`, `docs/plans/auto-reconnect-backoff-2026-07-05.md` diff --git a/docs/reviews/background-scanning-state-restoration-plan-critique-2026-07-13.md b/docs/reviews/background-scanning-state-restoration-plan-critique-2026-07-13.md new file mode 100644 index 0000000..ddcb926 --- /dev/null +++ b/docs/reviews/background-scanning-state-restoration-plan-critique-2026-07-13.md @@ -0,0 +1,26 @@ +# Background Scanning + State Restoration: Plan Critique + +**Scope**: Review of `docs/plans/background-scanning-state-restoration-2026-07-13.md` (Issue #38 / FR-8.3) against current `Sources/ReliaBLE/BluetoothActor.swift`. Focus limited to under-specified seams, work-item dependencies, over-planning, and ordering questions. All 6 design decisions are accepted as resolved. + +## 1. Top 3 Under-Specified Seams + +1. **Lazy-init vs. eager-creation reconciliation (BluetoothActor.swift:291–331)**: `ensureInitialized` creates the central only on `.allowedAlways`; the plan states `restoreIdentifier` forces eager creation at `init`. No explicit overload or flag is described for how `ReliaBLEManager.init(config:)` bypasses the auth gate while still honoring the lazy-prompt contract for `authorizeBluetooth()`. + +2. **`willRestoreState` → connection rehydration (plan step 4 + BluetoothActor:130,135)**: Seed `connectionStates[id]` from `CBPeripheral.state` and re-arm `reconnectEnabled` for connected/connecting IDs. Unspecified: whether restored peripherals bypass `handlePeripheralDiscovered` identity logic (line 546), how `cbPeripherals` is populated before the seed, and whether `reconnectAttempts`/`reconnectTasks` must be explicitly cleared (currently empty on restore). + +3. **Teardown interaction with restored state (BluetoothActor:620 `invalidatePeripherals`)**: Called on `.resetting`/`.unauthorized`. The plan does not state whether `handleWillRestoreState` must defensively clear or preserve `reconnectEnabled`/`connectionStates` when `invalidatePeripherals` later fires, nor whether restored intent survives a subsequent state transition. + +## 2. Work-Item Ordering / Dependency Issues + +- Steps 2–3 introduce `DelegateEvent.willRestore` and `handleWillRestoreState` before step 5 flips creation to eager. The code path is unreachable until step 5; ordering is acceptable but the plan should note the temporary dead code. +- Eager-creation (step 5) vs. lazy-auth (step 1) creates a new init path that still must not trigger the permission prompt. The interaction is mentioned but not sequenced. +- `invalidatePeripherals` (called from `handleCentralManagerStateUpdate`) clears `reconnectEnabled` and `connectionStates`. No work item ensures restored reconnection intent is re-seeded after a later reset/unauthorized cycle. + +## 3. Sections to Cut or Simplify + +- Work item 6 ("Connect notification options: none for now") and work item 7 (background-scan guard) are single-line changes with no new API surface. They add noise; fold into the restoration-handler or scanning items. +- Work item 10 (Demo) is explicitly delegated; its inclusion in the main plan list is unnecessary. + +## 4. Questions That Would Change Implementation Order + +- Does `restoreIdentifier` presence also force creation of the central even when authorization is still `.notDetermined`, or only after `.allowedAlways` is observed? (Answer determines whether the eager path must also short-circuit the auth gate inside `ensureInitialized`.) \ No newline at end of file From 5ed79457707fd7c2ffa55bd0d0cc177eed020a6a Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Tue, 14 Jul 2026 18:03:23 -0600 Subject: [PATCH 2/4] [demo] Added background scan/restore --- .../ReliaBLE Demo/Central/CentralView.swift | 31 +++++++++++++++++-- .../Central/CentralViewModel.swift | 3 +- .../ReliaBLE Demo/ReliaBLE_DemoApp.swift | 6 ++-- Demo/ReliaBLE Demo/ReliaBLE-Demo-Info.plist | 6 +++- 4 files changed, 39 insertions(+), 7 deletions(-) diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift index ccfbdca..b633832 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralView.swift @@ -97,15 +97,25 @@ struct CentralView: View { } .buttonStyle(.bordered) } else if case BluetoothState.ready = viewModel.currentState { - TextField("Enter service UUIDs (comma-separated)", text: $viewModel.servicesInput) + TextField("Service UUIDs (comma-separated; required for background)", text: $viewModel.servicesInput) .textFieldStyle(.roundedBorder) - .padding() + .padding(.horizontal) + + Text("Background discovery needs a non-empty service filter. Connect with Auto Reconnect on, then background/force-quit — restored connections reappear on relaunch.") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal) Button("Start Scanning") { viewModel.startScanning() } .buttonStyle(.bordered) } else if case BluetoothState.scanning = viewModel.currentState { + Text("Scanning (continues in background when a service filter is set)") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal) + Button("Stop Scanning") { viewModel.stopScanning() } @@ -181,7 +191,15 @@ struct CentralView: View { reliaBLE: reliaBLE ) } label: { - Text("\(device.name ?? "Unknown")") + HStack { + Text("\(device.name ?? "Unknown")") + Spacer() + if let state = viewModel.connectionStates[device.id] { + Text(state.description) + .font(.caption) + .foregroundStyle(state.color) + } + } } } .onDelete { offsets in @@ -260,6 +278,13 @@ private struct DeviceDetailView: View { Toggle("Auto Reconnect:", isOn: $autoReconnect) .disabled(connectionState.map { !$0.canEditAutoReconnect } ?? false) + + if isActive { + Text("Standing connection: leave Auto Reconnect on, background or force-quit the app, then relaunch — restored state shows here via connectionStateChanges.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } } .padding() } diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift index b58c6a0..0174f79 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/Central/CentralViewModel.swift @@ -32,7 +32,8 @@ import ReliaBLE @Observable class CentralViewModel { var currentState: BluetoothState = .unknown - var servicesInput = "" + /// Defaults to the Demo peripheral service UUID so background scans have a required filter. + var servicesInput = "12345678-90AB-CDEF-1234-567890ABCDEF" var connectionStates: [String: ConnectionState] = [:] private var deviceStore: DeviceStoreActor? diff --git a/Demo/ReliaBLE Demo/ReliaBLE Demo/ReliaBLE_DemoApp.swift b/Demo/ReliaBLE Demo/ReliaBLE Demo/ReliaBLE_DemoApp.swift index 9b9e934..3025419 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE Demo/ReliaBLE_DemoApp.swift +++ b/Demo/ReliaBLE Demo/ReliaBLE Demo/ReliaBLE_DemoApp.swift @@ -60,7 +60,9 @@ struct ReliaBLE_DemoApp: App { config.logWriters = [OSLogWriter(subsystem: "com.five3apps.relia-ble-demo", category: "BLE")] config.logQueue = DispatchQueue(label: "com.five3apps.relia-ble-demo.logging", qos: .utility) config.loggingEnabled = true - + // Stable across launches so CoreBluetooth can restore scans/connections after termination. + config.restoreIdentifier = "com.five3apps.relia-ble-demo.central" + let defaults = UserDefaults.standard var reconnectPolicy = ReconnectPolicy() reconnectPolicy.maxAttempts = defaults.object(forKey: "reconnectPolicy.maxAttempts") as? Int ?? 5 @@ -68,7 +70,7 @@ struct ReliaBLE_DemoApp: App { reconnectPolicy.maxDelay = defaults.object(forKey: "reconnectPolicy.maxDelay") as? Double ?? 30.0 reconnectPolicy.jitter = defaults.object(forKey: "reconnectPolicy.jitter") as? Double ?? 0.2 config.reconnectPolicy = reconnectPolicy - + return ReliaBLEManager(config: config) }() diff --git a/Demo/ReliaBLE Demo/ReliaBLE-Demo-Info.plist b/Demo/ReliaBLE Demo/ReliaBLE-Demo-Info.plist index d6635f6..dc90a19 100644 --- a/Demo/ReliaBLE Demo/ReliaBLE-Demo-Info.plist +++ b/Demo/ReliaBLE Demo/ReliaBLE-Demo-Info.plist @@ -3,6 +3,10 @@ NSBluetoothAlwaysUsageDescription - This app uses Bluetooth to advertise as a peripheral. + This app uses Bluetooth to scan for and connect to nearby peripherals, and to advertise as a peripheral. + UIBackgroundModes + + bluetooth-central + \ No newline at end of file From 1dd08058d92e93b9195634be79f82d76ed7cfb70 Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Tue, 14 Jul 2026 18:56:33 -0600 Subject: [PATCH 3/4] Address review feedback: persisted reconnect intent, restore edge cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Persist per-connect autoReconnect intent to UserDefaults (namespaced by restore identifier) so willRestoreState only re-arms Tier-1 reconnect for peripherals connected with autoReconnect: true; connections made with autoReconnect: false are restored without re-arming the library ladder - Stop emitting restored peripherals on the peripheralDiscoveries feed — restoration carries no advertisement or RSSI, so restored devices surface via discoveredPeripherals and connectionStateChanges only - Ignore (and warn on) an empty restored scan service filter instead of re-issuing a background-useless scan - Warn when a later ensureInitialized supplies a different restoreIdentifier than the one captured at first initialization - Extract centralManagerCreationOptions() and assert restore-key wiring at the unit level (end-to-end factory option fidelity deferred to #42) - Clear the persisted intent mirror in invalidatePeripherals so restore → unauthorized also drops cross-launch reconnect intent - Docs: eager-creation note cross-referenced in Concurrency.md; Background.md updated for persisted intent, discovery-feed behavior, and the deferred GATT delegate re-wire on restore - Tests: persisted-intent restore coverage (armed / not armed), empty restored scan filter, invalidatePeripherals clearing restored state Co-Authored-By: Claude Fable 5 --- Sources/ReliaBLE/BluetoothActor.swift | 112 ++++++++++-- .../Documentation.docc/Topics/Background.md | 23 ++- .../Documentation.docc/Topics/Concurrency.md | 8 + .../ReliaBLETests/ReliaBLEManagerTests.swift | 172 +++++++++++++++++- 4 files changed, 288 insertions(+), 27 deletions(-) diff --git a/Sources/ReliaBLE/BluetoothActor.swift b/Sources/ReliaBLE/BluetoothActor.swift index 9594cb1..260086e 100644 --- a/Sources/ReliaBLE/BluetoothActor.swift +++ b/Sources/ReliaBLE/BluetoothActor.swift @@ -326,6 +326,13 @@ actor BluetoothActor { configure(log: log) self.reconnectPolicy = reconnectPolicy self.restoreIdentifier = restoreIdentifier + } else if let restoreIdentifier, restoreIdentifier != self.restoreIdentifier { + // The actor is process-wide; the first manager's configuration wins for the process + // lifetime. Surface the mismatch instead of silently ignoring the new identifier. + let current = self.restoreIdentifier.map { "\"\($0)\"" } ?? "nil" + log.warn( + "Ignoring restoreIdentifier \"\(restoreIdentifier)\" — Bluetooth actor already initialized with \(current)" + ) } var createdManager = false @@ -360,14 +367,10 @@ actor BluetoothActor { delegateShim = shim // Use CBCentralManagerFactory for consistency between normal and test targets. // `forceMock: true` is load-bearing for the ReliaBLEMock test target — do not remove. - var options: [String: Any]? - if let restoreIdentifier { - options = [CBCentralManagerOptionRestoreIdentifierKey: restoreIdentifier] - } centralManager = CBCentralManagerFactory.instance( delegate: shim, queue: centralManagerQueue, - options: options, + options: centralManagerCreationOptions(), forceMock: true ) @@ -378,6 +381,16 @@ actor BluetoothActor { } } + /// Builds the options dictionary passed to the central-manager factory. + /// + /// Factored out of ``setupCentralManager()`` so unit tests can assert the restore key is + /// included without tearing down the process-lifetime central. End-to-end factory option + /// fidelity is deferred to the mock-harness work in issue #42. + private func centralManagerCreationOptions() -> [String: Any]? { + guard let restoreIdentifier else { return nil } + return [CBCentralManagerOptionRestoreIdentifierKey: restoreIdentifier] + } + /// Drains a single delegate event on the actor, preserving CoreBluetooth's callback order. private func process(_ event: DelegateEvent) { switch event { @@ -576,8 +589,16 @@ actor BluetoothActor { /// Restored `CBPeripheral`s arrive with no peripheral delegate and must be re-associated into /// ``cbPeripherals`` immediately. Connection state is seeded from each peripheral's /// `CBPeripheral.state`; no synchronous reconnect is issued (standing connects are OS-held). + /// Tier-1 reconnect intent is re-armed only for peripherals whose per-connect + /// `autoReconnect: true` intent was persisted before termination — a connection made with + /// `autoReconnect: false` is restored (state seeded, reference registered) without re-arming + /// the library ladder. /// If Bluetooth is later reported off/unauthorized, ``invalidatePeripherals()`` clears this /// state intentionally. + /// + /// Restored peripherals are deliberately **not** emitted on the `peripheralDiscoveries` + /// advertisement feed — restoration carries no advertisement payload or RSSI, so consumers + /// learn about restored devices via `discoveredPeripherals` and `connectionStateChanges` only. private func handleWillRestoreState(_ payload: RestorationPayload) { let restoredPeripherals = payload.state[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral] ?? [] let restoredScanServices = payload.state[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID] @@ -593,6 +614,9 @@ actor BluetoothActor { let now = Date() var didMutatePeripherals = false + // Reconnect intent persisted across launches; only ids in this set are re-armed below. + let persistedIntent = persistedReconnectIntent() + for cbPeripheral in restoredPeripherals { // Restored peripherals arrive with no delegate; re-associate into actor-owned maps // using the same identity rules as discovery. Peripheral-level GATT callbacks are not @@ -640,14 +664,19 @@ actor BluetoothActor { didMutatePeripherals = true // Seed connection state after the live reference is registered. Do not reconnect here. + // Tier-1 intent is re-armed only when it was persisted at connect time (autoReconnect: true). let connectionState: ConnectionState? switch cbPeripheral.state { case .connected: connectionState = .connected - reconnectEnabled.insert(resolvedId) + if persistedIntent.contains(resolvedId) { + reconnectEnabled.insert(resolvedId) + } case .connecting: connectionState = .connecting - reconnectEnabled.insert(resolvedId) + if persistedIntent.contains(resolvedId) { + reconnectEnabled.insert(resolvedId) + } case .disconnecting: connectionState = .disconnecting case .disconnected: @@ -663,13 +692,6 @@ actor BluetoothActor { to: connectionStateChangesContinuations ) } - - // Surface each restored peripheral on the lightweight discovery feed so subscribers - // that only listen to `peripheralDiscoveries` still learn about restored devices. - broadcast( - PeripheralDiscoveryEvent(cbPeripheral: cbPeripheral, advertisement: emptyAdvertisement, rssi: 0), - to: discoveryContinuations - ) } if didMutatePeripherals { @@ -678,12 +700,18 @@ actor BluetoothActor { // Resume a scan that was active at termination. If the central is not yet powered on // (willRestoreState can precede the poweredOn state update), stash the filter and resume - // from handleCentralManagerStateUpdate once powered on. - if let restoredScanServices { + // from handleCentralManagerStateUpdate once powered on. An empty filter is ignored — + // background scans require a non-empty service filter, so re-issuing one is useless. + if let restoredScanServices, !restoredScanServices.isEmpty { resumeRestoredScan( services: restoredScanServices, options: RestoredScanOptions(options: restoredScanOptions) ) + } else if restoredScanServices != nil { + log?.warn( + tags: [.category(.scanning)], + "Ignoring restored scan with an empty service filter — background scanning requires a non-empty filter" + ) } } @@ -826,6 +854,7 @@ actor BluetoothActor { reconnectTasks.removeAll() reconnectAttempts.removeAll() reconnectEnabled.removeAll() + persistReconnectIntent() intentionalDisconnects.removeAll() pendingRestoredScanServices = nil pendingRestoredScanOptions = nil @@ -833,6 +862,34 @@ actor BluetoothActor { log?.debug("Invalidated all peripheral references") } + // MARK: - Persisted Reconnect Intent + + /// `UserDefaults` key for the persisted reconnect-intent set, namespaced by restore identifier. + /// + /// `nil` when no ``restoreIdentifier`` is configured — without state restoration there is no + /// relaunch path that could consume persisted intent, so nothing is written. + private var reconnectIntentDefaultsKey: String? { + restoreIdentifier.map { "com.five3apps.relia-ble.reconnect-intent.\($0)" } + } + + /// Mirrors ``reconnectEnabled`` to `UserDefaults` so per-connect `autoReconnect` intent + /// survives process death. ``handleWillRestoreState(_:)`` re-arms Tier-1 reconnect only for + /// restored peripherals present in this persisted set. + /// + /// Called after every explicit mutation of ``reconnectEnabled`` (connect, disconnect, + /// invalidation). Restoration itself only reads the set. + private func persistReconnectIntent() { + guard let key = reconnectIntentDefaultsKey else { return } + UserDefaults.standard.set(Array(reconnectEnabled).sorted(), forKey: key) + } + + /// Reads the reconnect-intent set persisted by a previous launch (or this one). + private func persistedReconnectIntent() -> Set { + guard let key = reconnectIntentDefaultsKey, + let stored = UserDefaults.standard.stringArray(forKey: key) else { return [] } + return Set(stored) + } + private func refreshPeripherals() { guard let centralManager else { return } @@ -880,6 +937,7 @@ actor BluetoothActor { } else { reconnectEnabled.remove(id) } + persistReconnectIntent() intentionalDisconnects.remove(id) connectionStates[id] = .connecting broadcast(ConnectionStateChange(peripheralId: id, state: .connecting), to: connectionStateChangesContinuations) @@ -912,6 +970,7 @@ actor BluetoothActor { // Reset auto-reconnect since this was an explicit disconnect intentionalDisconnects.insert(id) reconnectEnabled.remove(id) + persistReconnectIntent() reconnectTasks[id]?.cancel() reconnectTasks[id] = nil reconnectAttempts[id] = nil @@ -1195,6 +1254,24 @@ actor BluetoothActor { restoreIdentifier } + /// Test-only hook: keys of the options dictionary ``setupCentralManager()`` would pass to the + /// factory right now. Verifies restore-key wiring at the unit level; end-to-end factory option + /// fidelity is deferred to issue #42. + func testCentralCreationOptionKeys() -> [String] { + centralManagerCreationOptions().map { Array($0.keys) } ?? [] + } + + /// Test-only hook: reconnect intent persisted for the current restore identifier. + func testPersistedReconnectIntent() -> Set { + persistedReconnectIntent() + } + + /// Test-only hook: removes any persisted reconnect intent for the current restore identifier. + func testClearPersistedReconnectIntent() { + guard let key = reconnectIntentDefaultsKey else { return } + UserDefaults.standard.removeObject(forKey: key) + } + /// Test-only hook: overwrites the stored restore identifier (process-lifetime actor may already /// have been initialized by an earlier test without one). func testSetRestoreIdentifier(_ id: String?) { @@ -1204,6 +1281,9 @@ actor BluetoothActor { /// Test-only hook: clears discovery snapshots and connection intent so a subsequent restore can /// exercise the cold-relaunch (append-new) identity branch, while keeping live `CBPeripheral` /// references available for ``testInvokeWillRestoreState(peripheralIds:scanServices:scanOptions:)``. + /// + /// Deliberately does **not** touch persisted reconnect intent — this simulates process death, + /// where in-memory state is lost but `UserDefaults` survives. func testClearDiscoveredSnapshotsPreservingLiveReferences() { discoveredPeripherals.removeAll() connectionStates.removeAll() diff --git a/Sources/ReliaBLE/Documentation.docc/Topics/Background.md b/Sources/ReliaBLE/Documentation.docc/Topics/Background.md index 5ea383e..b08e077 100644 --- a/Sources/ReliaBLE/Documentation.docc/Topics/Background.md +++ b/Sources/ReliaBLE/Documentation.docc/Topics/Background.md @@ -76,11 +76,26 @@ use: states — ``ConnectionState/connected`` for preserved connections, ``ConnectionState/connecting`` for in-progress attempts. +Restored peripherals are **not** emitted on +``ReliaBLEManager/peripheralDiscoveries`` — restoration carries no +advertisement payload or RSSI, so that feed remains reserved for real +advertisements. + The Tier-0 system-managed reconnection (``ReconnectSource/system``) survives -app termination because it runs in the iOS daemon; Tier-1 library-managed -reconnection (``ReconnectSource/library``) does not, but the library re-arms -reconnect intent for restored connections so a post-relaunch drop triggers -the exponential-backoff ladder governed by ``ReconnectPolicy``. +app termination because it runs in the iOS daemon. Tier-1 library-managed +reconnection (``ReconnectSource/library``) does not, so ReliaBLE persists your +per-connect intent: when you call ``ReliaBLEManager/connect(to:autoReconnect:)`` +with `autoReconnect: true` (and a ``ReliaBLEConfig/restoreIdentifier`` is +configured), that intent is stored in `UserDefaults` and re-armed for the +restored connection on relaunch, so a post-relaunch drop still triggers the +exponential-backoff ladder governed by ``ReconnectPolicy``. Connections made +with `autoReconnect: false` are restored — their state and live reference are +rehydrated — but reconnection stays disarmed. + +> Note: Restored `CBPeripheral` objects arrive without a peripheral-level +> delegate. ReliaBLE does not yet use peripheral (GATT) callbacks, so no +> delegate is re-attached during restoration. When GATT support lands, the +> restoration path must also re-wire the peripheral delegate. > Note: ReliaBLE deliberately does **not** pass > `CBConnectPeripheralOptionNotifyOnConnection` or diff --git a/Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md b/Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md index d13a9a8..3405fb7 100644 --- a/Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md +++ b/Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md @@ -34,6 +34,14 @@ CoreBluetooth `BluetoothActor` is an **internal** implementation detail. Consumers must not reference it; interact only through ``ReliaBLEManager``. +> Note: The internal central manager is created lazily and only once Bluetooth +> authorization is `.allowedAlways` — the permission prompt stays under your +> app's control via ``ReliaBLEManager/authorizeBluetooth()``. The one exception: +> when ``ReliaBLEConfig/restoreIdentifier`` is set and authorization was already +> granted, the central is created eagerly at ``ReliaBLEManager`` init so state +> restoration can deliver its `willRestoreState` callback on relaunch. See +> for details. + ### Calling actions All mutating actions are `async` and hop onto the Bluetooth actor for you: diff --git a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift index e9eb337..8efb134 100644 --- a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift +++ b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift @@ -1390,6 +1390,15 @@ struct ReliaBLEManagerTests { await BluetoothActor.shared.testSetRestoreIdentifier(restoreId) #expect(await BluetoothActor.shared.testRestoreIdentifier() == restoreId) + // Unit-level wiring check: with the id set, the options dictionary handed to the factory + // contains the restore key (and without one, no options at all). End-to-end factory option + // fidelity is deferred to #42. + let optionKeys = await BluetoothActor.shared.testCentralCreationOptionKeys() + #expect(optionKeys.contains(CBMCentralManagerOptionRestoreIdentifierKey)) + await BluetoothActor.shared.testSetRestoreIdentifier(nil) + #expect(await BluetoothActor.shared.testCentralCreationOptionKeys().isEmpty) + await BluetoothActor.shared.testSetRestoreIdentifier(restoreId) + await Mock.ensureReady(manager) #expect(await BluetoothActor.shared.hasCentralManager) } @@ -1401,6 +1410,11 @@ struct ReliaBLEManagerTests { let manager = await Mock.makeManager() await Mock.ensureReady(manager) + // Reconnect intent is persisted per restore identifier; pin a test-unique id and start + // from a clean persisted set so the re-arm assertion below is meaningful. + await BluetoothActor.shared.testSetRestoreIdentifier("com.five3apps.relia-ble.tests.restore-broadcasts") + await BluetoothActor.shared.testClearPersistedReconnectIntent() + await manager.startScanning() let peripheral = await Mock.waitForPeripheral( id: Mock.connectionTestPeripheralID, @@ -1426,14 +1440,18 @@ struct ReliaBLEManagerTests { #expect(connected?.state == .connected) } - // Simulate cold relaunch: drop snapshots / intent while keeping live CBPeripheral refs - // so the restore path can rehydrate from the hand-built dictionary. + // connect(autoReconnect: true) persisted Tier-1 intent under the pinned restore id. + #expect(await BluetoothActor.shared.testPersistedReconnectIntent().contains(discovered.id)) + + // Simulate cold relaunch: drop snapshots / in-memory intent while keeping live CBPeripheral + // refs so the restore path can rehydrate from the hand-built dictionary. Persisted intent + // survives, mirroring UserDefaults across process death. await BluetoothActor.shared.testClearDiscoveredSnapshotsPreservingLiveReferences() #expect(await manager.currentConnectionStates[discovered.id] == nil) #expect(!(await BluetoothActor.shared.testIsReconnectEnabled(discovered.id))) - // Subscribe before restore so broadcasts are not missed. - var discovery = manager.peripheralDiscoveries.makeAsyncIterator() + // Subscribe before restore so broadcasts are not missed. Note: no peripheralDiscoveries + // subscription — restored peripherals are deliberately kept off the advertisement feed. var peripherals = manager.discoveredPeripherals.makeAsyncIterator() var connectionChanges = manager.connectionStateChanges.makeAsyncIterator() // Force registration hops. @@ -1454,9 +1472,6 @@ struct ReliaBLEManagerTests { #expect(await BluetoothActor.shared.testIsReconnectEnabled(discovered.id)) // Streams broadcast restored state. - let discoveryEvent = await discovery.next() - #expect(discoveryEvent != nil) - let peripheralsEvent = await peripherals.next() #expect(peripheralsEvent?.contains(where: { $0.id == discovered.id }) == true) @@ -1470,6 +1485,7 @@ struct ReliaBLEManagerTests { try? await manager.disconnect(from: discovered) await manager.stopScanning() + await BluetoothActor.shared.testClearPersistedReconnectIntent() } @Test func willRestoreSeedingReconnectOnlyForConnectedOrConnecting() async throws { @@ -1479,6 +1495,10 @@ struct ReliaBLEManagerTests { let manager = await Mock.makeManager() await Mock.ensureReady(manager) + // Pin a test-unique restore id and start from a clean persisted-intent set. + await BluetoothActor.shared.testSetRestoreIdentifier("com.five3apps.relia-ble.tests.restore-seeding") + await BluetoothActor.shared.testClearPersistedReconnectIntent() + // Discover both peripherals so we have live refs for restore. await manager.startScanning() let connectionPeripheral = await Mock.waitForPeripheral( @@ -1513,12 +1533,150 @@ struct ReliaBLEManagerTests { ) #expect(await manager.currentConnectionStates[connected.id] == .connected) + // Re-armed because connect(autoReconnect: true) persisted intent before "process death". #expect(await BluetoothActor.shared.testIsReconnectEnabled(connected.id)) // Disconnected restored peripherals do not seed connectionStates / reconnectEnabled. #expect(await manager.currentConnectionStates[disconnected.id] == nil) #expect(!(await BluetoothActor.shared.testIsReconnectEnabled(disconnected.id))) try? await manager.disconnect(from: connected) + await BluetoothActor.shared.testClearPersistedReconnectIntent() + } + + @Test func willRestoreDoesNotRearmReconnectWithoutPersistedIntent() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + await BluetoothActor.shared.testSetRestoreIdentifier("com.five3apps.relia-ble.tests.restore-no-intent") + await BluetoothActor.shared.testClearPersistedReconnectIntent() + + await manager.startScanning() + let peripheral = await Mock.waitForPeripheral( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let discovered = try #require(peripheral) + await manager.stopScanning() + + // autoReconnect: false must not persist Tier-1 intent. + try await manager.connect(to: discovered, autoReconnect: false) + _ = await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[discovered.id] == .connected + } + #expect(!(await BluetoothActor.shared.testPersistedReconnectIntent().contains(discovered.id))) + + await BluetoothActor.shared.testClearDiscoveredSnapshotsPreservingLiveReferences() + + await BluetoothActor.shared.testInvokeWillRestoreState( + peripheralIds: [discovered.id], + scanServices: nil + ) + + // The OS-held connection is still surfaced to the app model… + #expect(await manager.currentConnectionStates[discovered.id] == .connected) + // …but Tier-1 reconnect stays disarmed because no intent was persisted. + #expect(!(await BluetoothActor.shared.testIsReconnectEnabled(discovered.id))) + + try? await manager.disconnect(from: discovered) + // Wait for the disconnect to land so the mock peripheral resumes advertising + // before the next test scans for it. + _ = await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[discovered.id] != .connected + } + await BluetoothActor.shared.testClearPersistedReconnectIntent() + } + + @Test func willRestoreIgnoresEmptyScanServiceFilter() async throws { + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + await manager.startScanning() + let peripheral = await Mock.waitForPeripheral( + id: Mock.testPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let discovered = try #require(peripheral) + await manager.stopScanning() + + await BluetoothActor.shared.testClearDiscoveredSnapshotsPreservingLiveReferences() + + // An empty restored filter is background-useless; restoration must neither re-issue the + // scan nor stash it as pending. + await BluetoothActor.shared.testInvokeWillRestoreState( + peripheralIds: [discovered.id], + scanServices: [] + ) + + #expect(!(await BluetoothActor.shared.testIsScanning())) + #expect(await BluetoothActor.shared.testPendingRestoredScanServices() == nil) + } + + @Test func invalidatePeripheralsClearsRestoredStateAndIntent() async throws { + // Pre-condition: no stale connection from a preceding lifecycle test — a still-connected + // mock peripheral does not advertise, so discovery below would time out. + Mock.connectionTestSpec.simulateDisconnection() + try? await Task.sleep(nanoseconds: 100_000_000) + + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + let manager = await Mock.makeManager() + await Mock.ensureReady(manager) + + await BluetoothActor.shared.testSetRestoreIdentifier("com.five3apps.relia-ble.tests.restore-invalidate") + await BluetoothActor.shared.testClearPersistedReconnectIntent() + + await manager.startScanning() + let peripheral = await Mock.waitForPeripheral( + id: Mock.connectionTestPeripheralID, + on: manager, + withinNanoseconds: 3_000_000_000 + ) + let discovered = try #require(peripheral) + await manager.stopScanning() + + try await manager.connect(to: discovered) + _ = await pollUntil(timeout: 3.0) { + await manager.currentConnectionStates[discovered.id] == .connected + } + + await BluetoothActor.shared.testClearDiscoveredSnapshotsPreservingLiveReferences() + + // Restore while powered on: seeds connection state and re-arms from persisted intent. + await BluetoothActor.shared.testInvokeWillRestoreState( + peripheralIds: [discovered.id], + scanServices: nil + ) + #expect(await BluetoothActor.shared.testIsReconnectEnabled(discovered.id)) + + // Power off so a second restore stashes its scan filter as pending. + CBMCentralManagerMock.simulatePowerOff() + #expect(await Mock.waitForState("Powered Off", on: manager)) + + let scanUUID = CBUUID(string: "180D") + await BluetoothActor.shared.testInvokeWillRestoreState( + peripheralIds: [], + scanServices: [scanUUID] + ) + #expect(await BluetoothActor.shared.testPendingRestoredScanServices() == [scanUUID]) + + // The unauthorized/resetting teardown path clears the pending restored scan, connection + // state, in-memory reconnect intent, and the persisted mirror (plan critique seam). + await BluetoothActor.shared.testInvalidatePeripherals() + #expect(await BluetoothActor.shared.testPendingRestoredScanServices() == nil) + #expect(!(await BluetoothActor.shared.testIsReconnectEnabled(discovered.id))) + #expect(await manager.currentConnectionStates[discovered.id] == nil) + #expect(await BluetoothActor.shared.testPersistedReconnectIntent().isEmpty) + + // Restore power for subsequent tests. + CBMCentralManagerMock.simulatePowerOn() + _ = await Mock.waitForState("Ready", on: manager) + await BluetoothActor.shared.testClearPersistedReconnectIntent() } @Test func willRestoreDefersScanUntilPoweredOn() async throws { From fd456193cc42efe4c1dfbd686806f94143b1c049 Mon Sep 17 00:00:00 2001 From: Justin Bergen Date: Wed, 15 Jul 2026 14:03:01 -0600 Subject: [PATCH 4/4] Fix indefinite hang in willRestore broadcast test The willRestoreRepopulatesMapsSeedsConnectionStateAndBroadcasts test could hang forever in Xcode/CI (30-min timeout) while passing on CLI. The connectionStateChanges stream does not replay, and its continuation is registered asynchronously via a detached @BluetoothActor hop. The test's single actor hop was not enough slack to guarantee the subscription landed before the synchronous restore broadcast, so the .connected event was dropped and connectionChanges.next() blocked indefinitely. Add testConnectionStateSubscriberCount()/testPeripheralsSubscriberCount() accessors and poll until both subscriptions have registered before invoking restore. The event is then buffered and next() returns deterministically; the test now passes fast or fails fast within 3s instead of hanging CI. Co-Authored-By: Claude Opus 4.8 --- Sources/ReliaBLE/BluetoothActor.swift | 16 ++++++++++++++++ Tests/ReliaBLETests/ReliaBLEManagerTests.swift | 17 +++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/Sources/ReliaBLE/BluetoothActor.swift b/Sources/ReliaBLE/BluetoothActor.swift index 260086e..8af5943 100644 --- a/Sources/ReliaBLE/BluetoothActor.swift +++ b/Sources/ReliaBLE/BluetoothActor.swift @@ -1244,6 +1244,22 @@ actor BluetoothActor { centralManager?.isScanning == true } + /// Test-only hook: number of registered `connectionStateChanges` subscribers. + /// + /// Stream registration is asynchronous — the factory schedules `register(...)` on a detached + /// `@BluetoothActor` hop (see ``connectionStateChangesStream()``). Tests that must observe a + /// broadcast emitted *right after* subscribing (e.g. the restore path) poll this until their + /// subscription has landed, otherwise a non-replaying broadcast can be missed entirely. + func testConnectionStateSubscriberCount() -> Int { + connectionStateChangesContinuations.count + } + + /// Test-only hook: number of registered `discoveredPeripherals` subscribers. See + /// ``testConnectionStateSubscriberCount()`` for why tests need to observe registration. + func testPeripheralsSubscriberCount() -> Int { + peripheralsContinuations.count + } + /// Test-only hook: service filter stashed when a restored scan was deferred until powered on. func testPendingRestoredScanServices() -> [CBUUID]? { pendingRestoredScanServices diff --git a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift index 8efb134..dd2fd68 100644 --- a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift +++ b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift @@ -1452,10 +1452,23 @@ struct ReliaBLEManagerTests { // Subscribe before restore so broadcasts are not missed. Note: no peripheralDiscoveries // subscription — restored peripherals are deliberately kept off the advertisement feed. + // + // Stream registration is asynchronous: `makeAsyncIterator()` only *schedules* the + // continuation registration on a detached `@BluetoothActor` hop. The connection-state feed + // does not replay, so if the restore broadcast below fires before that hop lands, the + // `.connected` event is dropped and `connectionChanges.next()` blocks forever (this is the + // 30-min CI/Xcode hang). A single actor hop is not enough slack; poll until both + // subscriptions have actually registered before invoking restore. + let baseConnectionSubscribers = await BluetoothActor.shared.testConnectionStateSubscriberCount() + let basePeripheralsSubscribers = await BluetoothActor.shared.testPeripheralsSubscriberCount() var peripherals = manager.discoveredPeripherals.makeAsyncIterator() var connectionChanges = manager.connectionStateChanges.makeAsyncIterator() - // Force registration hops. - _ = await manager.currentConnectionStates + let subscriptionsReady = await pollUntil(timeout: 3.0) { + let connectionReady = await BluetoothActor.shared.testConnectionStateSubscriberCount() > baseConnectionSubscribers + let peripheralsReady = await BluetoothActor.shared.testPeripheralsSubscriberCount() > basePeripheralsSubscribers + return connectionReady && peripheralsReady + } + #expect(subscriptionsReady) let scanUUID = CBUUID(string: "180D") await BluetoothActor.shared.testInvokeWillRestoreState(