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