diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index 690e394194..d99c15159c 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -1259,6 +1259,7 @@ public final class BLEManager: NSObject, ObservableObject { private var bondLoopPausedAt: Date? /// NotificationCenter token for the app-foreground salvage probe (installForegroundSalvageProbe). private var foregroundSalvageObserver: NSObjectProtocol? + private var protectedDataStartupObserver: NSObjectProtocol? /// Multi-WHOOP stale-pin recovery (#52). Consecutive "Encryption/Authentication is insufficient" bond /// refusals on the CURRENTLY PINNED peripheral. A stale registry pin (pointing at a strap that bonds to /// the official app / isn't really here) makes `connect()` drop the strap that DOES bond and loop @@ -1386,8 +1387,6 @@ public final class BLEManager: NSObject, ObservableObject { installForegroundSalvageProbe() } - /// Build the WhoopStore + Collector + Backfiller asynchronously. Safe to call multiple - /// times — bails out early if the collector is already initialised. /// Seed the last-sync display from the ACTIVE strap's own stamp — PR #556's intent, correctly attributed. /// /// Resolved against the registry's active row, exactly as the debug export resolves firmware. NOT from @@ -1422,7 +1421,19 @@ public final class BLEManager: NSObject, ObservableObject { } } + private let startupGate = BLEStartupGate() + + /// Build the store once for overlapping startup callbacks, allowing retries after a failed open. func bootstrapStore() async { + _ = await startupGate.prepare { await self.prepareStoreForStartup() } + } + + private func prepareStoreForStartup() async -> Bool { + await openStore() + return collector != nil && registryStore != nil + } + + private func openStore() async { guard collector == nil else { return } // Surface store-open failures instead of swallowing them with `try?` (#222): a silent failure // here left `backfiller` nil forever and the only visible symptom was the downstream @@ -1461,8 +1472,8 @@ public final class BLEManager: NSObject, ObservableObject { // // Fail-open on an unknown row (nil): unchanged behaviour for anything the registry can't // classify. Only a POSITIVELY non-WHOOP active device is refused, and that same fact seeds the - // connect gate — which closes the launch race where `poweredOn` can reach the WHOOP flow - // before `SourceCoordinator` has wired up and asserted it. + // connect gate. Automatic startup awaits this seed before connecting or discovering services, + // even when `SourceCoordinator` has not wired up yet (#2604). let activeRow = (try? registry.all())?.first(where: { $0.id == activeId }) if let activeRow, !SourceIdentity.isWhoop(activeRow) { setWhoopIsActiveDevice(false) @@ -1976,9 +1987,27 @@ public final class BLEManager: NSObject, ObservableObject { foregroundSalvageObserver = NotificationCenter.default.addObserver( forName: name, object: nil, queue: .main ) { [weak self] _ in - Task { @MainActor in self?.salvageProbeIfBondLoopPaused() } + Task { @MainActor in + guard let self else { return } + if self.collector == nil && !self.intentionalDisconnect { + await self.resumeSystemConnection(reason: "foreground-store-retry") + } + self.salvageProbeIfBondLoopPaused() + } + } + #if os(iOS) + // A background restoration can meet a data-protected database. Retry on unlock without + // first connecting to an unknown active device merely to start the backfill retry timer. + protectedDataStartupObserver = NotificationCenter.default.addObserver( + forName: UIApplication.protectedDataDidBecomeAvailableNotification, object: nil, queue: .main + ) { [weak self] _ in + Task { @MainActor in + guard let self, self.collector == nil, !self.intentionalDisconnect else { return } + await self.resumeSystemConnection(reason: "protected-data-store-retry") + } } #endif + #endif } // MARK: Multi-WHOOP (additive — inert on the single-WHOOP path) @@ -5135,9 +5164,45 @@ public final class BLEManager: NSObject, ObservableObject { } } - /// Issue a direct connect to a restored peripheral, logging that it actually happened and arming the - /// #730 pending-connect probe. Both restore entry points funnel through here so the two paths can be - /// told apart in a strap log. + /// Resume radio-driven startup only after the registry has seeded the active-device gate. + private func resumeSystemConnection(reason: String) async { + await startupGate.resume( + prepare: { await self.prepareStoreForStartup() }, + radioIsPoweredOn: { self.central.state == .poweredOn }, + onRefusal: { refusal in + switch refusal { + case .storeNotReady: + self.log("Startup (\(reason)): store preparation not ready — deferring until a startup retry") + case .radioNotPoweredOn: + self.log("Startup (\(reason)): central not poweredOn yet (state=\(self.central.state.rawValue)) — deferring to poweredOn") + } + }, + isAllowed: { + !self.intentionalDisconnect + && self.whoopConnectAllowed("startup/\(reason)") + }, + action: { + if let p = self.restoredPeripheral { + self.adoptSourceIdentity(for: p) + if p.state == .connected { + self.state.connected = true + // Inherited subscriptions need a real off→on cycle (#613), after the gate opens. + self.restoreNeedsResubscribe = true + self.log("Restored CONNECTED peripheral \(p.identifier) — re-discovering services (\(reason))") + self.discoverPrimaryServices(on: p) + } else { + self.state.connected = false + self.connectRestored(p, reason: reason) + } + } else { + // Radio-driven startup retains the bond-loop give-up; it is not a manual retry (#78). + self.connectFromSystem() + } + } + ) + } + + /// Issue a direct connect to a restored peripheral and arm the pending-connect probe (#730). private func connectRestored(_ p: CBPeripheral, reason: String) { guard whoopConnectAllowed("restored/\(reason)") else { return } log("Connecting to restored peripheral (\(reason)) — peripheral state=\(peripheralStateName(p.state))") @@ -5840,21 +5905,7 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate { state.lastSyncError = nil radioStateErrorShown = false } - // Bootstrap the async store once on first poweredOn (idempotent if already set). - Task { @MainActor in await bootstrapStore() } - if let p = restoredPeripheral { - log("poweredOn with restored peripheral — reconnecting \(p.identifier)") - if p.state != .connected { - connectRestored(p, reason: "poweredOn") - } else { - discoverPrimaryServices(on: p) - } - } else { - // #78 hole-2: poweredOn is SYSTEM-initiated (every Bluetooth toggle / bluetoothd restart - // lands here), so it must not reset a latched bond-loop give-up - it gets ONE bounded - // attempt with the give-up intact (Android onBluetoothRadioOn parity), not a fresh hammer. - connectFromSystem() - } + Task { @MainActor in await resumeSystemConnection(reason: "poweredOn") } } public func centralManager(_ central: CBCentralManager, @@ -5948,6 +5999,36 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate { } public func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + // A restored pending connection can complete while the database is still opening. It must + // pass the same startup gate before publishing/adopting its identity or discovering services. + if restoredPeripheral?.identifier == peripheral.identifier { + Task { @MainActor in + await startupGate.resume( + prepare: { await self.prepareStoreForStartup() }, + radioIsPoweredOn: { self.central.state == .poweredOn }, + onRefusal: { refusal in + switch refusal { + case .storeNotReady: + self.log("Startup (restored-didConnect): store preparation not ready — deferring until a startup retry") + case .radioNotPoweredOn: + self.log("Startup (restored-didConnect): central not poweredOn yet (state=\(self.central.state.rawValue)) — deferring to poweredOn") + } + }, + isAllowed: { + !self.intentionalDisconnect + && self.restoredPeripheral?.identifier == peripheral.identifier + && peripheral.state == .connected + && self.whoopConnectAllowed("startup/restored-didConnect") + }, + action: { self.completeConnect(peripheral) } + ) + } + return + } + completeConnect(peripheral) + } + + private func completeConnect(_ peripheral: CBPeripheral) { cancelScanFallback() cancelPendingConnectProbe() // #730: the connect resolved; no pending-connect log needed failedConnectAttempts = 0 // a successful connect clears the reconnect backoff (#414) @@ -6479,9 +6560,8 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate { } /// State restoration entry point (M3 background collection). - /// Stores the restored peripheral and — if already connected — immediately - /// re-discovers services so `cmdCharacteristic` is re-acquired and - /// notifications are re-routed without user interaction. + /// Stores the restored peripheral, then awaits the registry and active-device gate before + /// re-discovering services or reconnecting without user interaction. public func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) { guard let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral], @@ -6520,32 +6600,9 @@ extension BLEManager: @preconcurrency CBCentralManagerDelegate { // Reset the flag so the post-restore didWriteValueFor issues exactly one getClock. clockRequested = false clockRetries = 0 - // Ensure the store is ready before restored BLE data arrives (idempotent; no-op if already built). - Task { @MainActor in - await bootstrapStore() - // #1881: `didConnect` never fires on this path (see above), so the identity adoption that lives - // there would be skipped for a restored link — the very path a radio toggle and a relaunch take. - // After `bootstrapStore`, because it is what creates `registryStore`. - if let p = self.peripheral ?? self.restoredPeripheral { self.adoptSourceIdentity(for: p) } - } - if p.state == .connected { - state.connected = true - // #613: the inherited notify subscriptions come back reported-active but dead. Force one real - // off→on re-subscribe this session (see `requestNotify`) so live HR/R-R resume AND - // `didUpdateNotificationStateFor` fires → `cmdNotifyConfirmedActive` → `connectSettled` → the - // alarm re-arm. Cleared when `connectSettled` bumps. - restoreNeedsResubscribe = true - log("Restored CONNECTED peripheral \(p.identifier) — re-discovering services") - discoverPrimaryServices(on: p) - } else { - state.connected = false - log("Restored DISCONNECTED peripheral \(p.identifier) — reconnect on poweredOn") - if central.state == .poweredOn { - connectRestored(p, reason: "willRestoreState") - } else { - log("Restore: central not poweredOn yet (state=\(central.state.rawValue)) — deferring to poweredOn") - } - } + // Even an already-connected restored link must await the active-device seed before discovery. + // poweredOn may arrive while the store opens; both callbacks share the same preparation (#2604). + Task { @MainActor in await resumeSystemConnection(reason: "willRestoreState") } } } diff --git a/Strand/BLE/BLEStartupGate.swift b/Strand/BLE/BLEStartupGate.swift new file mode 100644 index 0000000000..cc83355f90 --- /dev/null +++ b/Strand/BLE/BLEStartupGate.swift @@ -0,0 +1,39 @@ +import Foundation + +/// Serializes store initialization before automatic BLE startup can consult the device registry. +@MainActor +final class BLEStartupGate { + enum Refusal: Equatable { + case storeNotReady + case radioNotPoweredOn + } + + private var preparation: Task? + + func prepare(_ operation: @escaping @MainActor () async -> Bool) async -> Bool { + if let preparation { return await preparation.value } + let task = Task { @MainActor in await operation() } + preparation = task + let ready = await task.value + preparation = nil + return ready + } + + func resume(prepare operation: @escaping @MainActor () async -> Bool, + radioIsPoweredOn: @MainActor () -> Bool, + onRefusal: @MainActor (Refusal) -> Void, + isAllowed: @MainActor () -> Bool, + action: @MainActor () -> Void) async { + guard await prepare(operation) else { + onRefusal(.storeNotReady) + return + } + guard radioIsPoweredOn() else { + onRefusal(.radioNotPoweredOn) + return + } + // The active-device policy logs its own refusal; do not duplicate that evidence here. + guard isAllowed() else { return } + action() + } +} diff --git a/StrandTests/BLEStartupGateTests.swift b/StrandTests/BLEStartupGateTests.swift new file mode 100644 index 0000000000..e1b0bda3c6 --- /dev/null +++ b/StrandTests/BLEStartupGateTests.swift @@ -0,0 +1,99 @@ +import XCTest +@testable import Strand + +final class BLEStartupGateTests: XCTestCase { + @MainActor + func testInactiveDeviceSeedPrecedesBothStartupCallbacks() async { + let gate = BLEStartupGate() + var releaseStore: CheckedContinuation? + var opens = 0 + var allowed = true // The launch default, before the persisted ring selection is read. + var gateReads = 0 + var actions: [String] = [] + let prepare: @MainActor () async -> Bool = { + opens += 1 + await withCheckedContinuation { releaseStore = $0 } + allowed = false + return true + } + let check: @MainActor () -> Bool = { gateReads += 1; return allowed } + let poweredOn = Task { @MainActor in + await gate.resume(prepare: prepare, radioIsPoweredOn: { true }, onRefusal: { _ in XCTFail("Unexpected startup refusal") }, isAllowed: check) { actions.append("connect") } + } + while releaseStore == nil { await Task.yield() } + var secondEntered = false + let restored = Task { @MainActor in + secondEntered = true + await gate.resume(prepare: prepare, radioIsPoweredOn: { true }, onRefusal: { _ in XCTFail("Unexpected startup refusal") }, isAllowed: check) { actions.append("discover") } + } + // Let the second callback enter the shared preparation while the first is suspended. + while !secondEntered { await Task.yield() } + XCTAssertEqual(opens, 1) + XCTAssertEqual(gateReads, 0) + XCTAssertTrue(actions.isEmpty) + releaseStore?.resume() + await poweredOn.value + await restored.value + XCTAssertEqual(opens, 1) + XCTAssertEqual(gateReads, 2) + XCTAssertTrue(actions.isEmpty, "An inactive WHOOP must neither reconnect nor discover services") + } + + @MainActor + func testSelectedWhoopResumesAfterStoreIsReady() async { + let gate = BLEStartupGate() + var order: [String] = [] + await gate.resume(prepare: { order.append("store"); return true }, radioIsPoweredOn: { true }, onRefusal: { _ in XCTFail("Unexpected startup refusal") }, isAllowed: { + order.append("gate") + return true + }) { order.append("discover") } + XCTAssertEqual(order, ["store", "gate", "discover"]) + } + + @MainActor + func testFailedStoreDoesNotConsultDefaultGateAndCanRetryOnUnlock() async { + let gate = BLEStartupGate() + var reads = 0 + var actions = 0 + var refusals: [BLEStartupGate.Refusal] = [] + await gate.resume(prepare: { false }, radioIsPoweredOn: { XCTFail("Radio read before store readiness"); return true }, onRefusal: { refusals.append($0) }, isAllowed: { reads += 1; return true }) { actions += 1 } + XCTAssertEqual(reads, 0) + XCTAssertEqual(actions, 0) + XCTAssertEqual(refusals, [.storeNotReady]) + await gate.resume(prepare: { true }, radioIsPoweredOn: { true }, onRefusal: { _ in XCTFail("Unexpected startup refusal") }, isAllowed: { reads += 1; return true }) { actions += 1 } + XCTAssertEqual(reads, 1) + XCTAssertEqual(actions, 1) + } + + @MainActor + func testRadioChangeDuringStoreOpenIsCheckedAfterAwait() async { + let gate = BLEStartupGate() + var releaseStore: CheckedContinuation? + var radioReady = true + var refusals: [BLEStartupGate.Refusal] = [] + var actions = 0 + let startup = Task { @MainActor in + await gate.resume(prepare: { + await withCheckedContinuation { releaseStore = $0 } + return true + }, radioIsPoweredOn: { radioReady }, onRefusal: { refusals.append($0) }, isAllowed: { XCTFail("Policy read after radio changed"); return true }) { actions += 1 } + } + while releaseStore == nil { await Task.yield() } + radioReady = false + releaseStore?.resume() + await startup.value + XCTAssertEqual(actions, 0) + XCTAssertEqual(refusals, [.radioNotPoweredOn]) + } + @MainActor + func testRadioRefusalNamesReasonWithoutReadingDevicePolicy() async { + let gate = BLEStartupGate() + var refusals: [BLEStartupGate.Refusal] = [] + await gate.resume(prepare: { true }, radioIsPoweredOn: { false }, + onRefusal: { refusals.append($0) }, + isAllowed: { XCTFail("Device policy read with radio unavailable"); return true }, + action: { XCTFail("Startup ran with radio unavailable") }) + XCTAssertEqual(refusals, [.radioNotPoweredOn]) + } + +}