Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 109 additions & 52 deletions Strand/BLE/BLEManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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))")
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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") }
}
}

Expand Down
39 changes: 39 additions & 0 deletions Strand/BLE/BLEStartupGate.swift
Original file line number Diff line number Diff line change
@@ -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<Bool, Never>?

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()
}
}
99 changes: 99 additions & 0 deletions StrandTests/BLEStartupGateTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import XCTest
@testable import Strand

final class BLEStartupGateTests: XCTestCase {
@MainActor
func testInactiveDeviceSeedPrecedesBothStartupCallbacks() async {
let gate = BLEStartupGate()
var releaseStore: CheckedContinuation<Void, Never>?
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<Void, Never>?
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])
}

}
Loading