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 diff --git a/Sources/ReliaBLE/BluetoothActor.swift b/Sources/ReliaBLE/BluetoothActor.swift index 291e5eb..8af5943 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,29 @@ 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 + } 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 @@ -328,7 +367,12 @@ 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) + centralManager = CBCentralManagerFactory.instance( + delegate: shim, + queue: centralManagerQueue, + options: centralManagerCreationOptions(), + forceMock: true + ) delegateEventTask = Task { [weak self] in for await event in events { @@ -337,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 { @@ -354,6 +408,8 @@ actor BluetoothActor { handleDidDisconnect(payload) case .connectFailed(let payload): handleDidFailToConnect(payload) + case .willRestore(let payload): + handleWillRestoreState(payload) } } @@ -449,6 +505,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 +584,163 @@ 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). + /// 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] + 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 + + // 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 + // 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. + // 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 + if persistedIntent.contains(resolvedId) { + reconnectEnabled.insert(resolvedId) + } + case .connecting: + connectionState = .connecting + if persistedIntent.contains(resolvedId) { + 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 + ) + } + } + + 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. 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" + ) + } + } + + /// 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 +749,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 @@ -631,11 +854,42 @@ actor BluetoothActor { reconnectTasks.removeAll() reconnectAttempts.removeAll() reconnectEnabled.removeAll() + persistReconnectIntent() intentionalDisconnects.removeAll() + pendingRestoredScanServices = nil + pendingRestoredScanOptions = nil broadcast(discoveredPeripherals, to: peripheralsContinuations) 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 } @@ -683,6 +937,7 @@ actor BluetoothActor { } else { reconnectEnabled.remove(id) } + persistReconnectIntent() intentionalDisconnects.remove(id) connectionStates[id] = .connecting broadcast(ConnectionStateChange(peripheralId: id, state: .connecting), to: connectionStateChangesContinuations) @@ -715,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 @@ -935,6 +1191,122 @@ 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: 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 + } + + /// Test-only hook: restore identifier captured on first ``ensureInitialized`` call. + func testRestoreIdentifier() -> String? { + 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?) { + 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:)``. + /// + /// 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() + reconnectEnabled.removeAll() + pendingRestoredScanServices = nil + pendingRestoredScanOptions = nil + } } // MARK: - BluetoothDelegateShim @@ -988,4 +1360,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..b08e077 --- /dev/null +++ b/Sources/ReliaBLE/Documentation.docc/Topics/Background.md @@ -0,0 +1,105 @@ +# 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. + +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, 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 +> `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/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/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..dd2fd68 100644 --- a/Tests/ReliaBLETests/ReliaBLEManagerTests.swift +++ b/Tests/ReliaBLETests/ReliaBLEManagerTests.swift @@ -1346,6 +1346,399 @@ 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) + + // 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) + } + + @Test func willRestoreRepopulatesMapsSeedsConnectionStateAndBroadcasts() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + 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, + 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) + } + + // 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. 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() + 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( + 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 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() + await BluetoothActor.shared.testClearPersistedReconnectIntent() + } + + @Test func willRestoreSeedingReconnectOnlyForConnectedOrConnecting() async throws { + Mock.connectionTestDelegate.connectionResult = .success(()) + defer { Mock.connectionTestDelegate.connectionResult = .success(()) } + + 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( + 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) + // 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 { + 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 +1869,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