Skip to content

Commit 590024f

Browse files
authored
Gate startup on storage readiness for Before-First-Unlock recovery (#710)
1 parent 390cd38 commit 590024f

10 files changed

Lines changed: 278 additions & 258 deletions

File tree

LoopFollow.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@
124124
DD2C2E512D3B8B0C006413A5 /* NightscoutSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E502D3B8B0B006413A5 /* NightscoutSettingsViewModel.swift */; };
125125
DD485F142E454B2600CE8CBF /* SecureMessenger.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD485F132E454B2600CE8CBF /* SecureMessenger.swift */; };
126126
DD4878032C7B297E0048F05C /* StorageValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878022C7B297E0048F05C /* StorageValue.swift */; };
127+
5106EAD100000000000000A2 /* StorageReadiness.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5106EAD100000000000000A1 /* StorageReadiness.swift */; };
128+
5106EAD100000000000000B2 /* StorageLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5106EAD100000000000000B1 /* StorageLoadingView.swift */; };
127129
DD4878052C7B2C970048F05C /* Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878042C7B2C970048F05C /* Storage.swift */; };
128130
DD4878082C7B30BF0048F05C /* RemoteSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */; };
129131
DD48780A2C7B30D40048F05C /* RemoteSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878092C7B30D40048F05C /* RemoteSettingsViewModel.swift */; };
@@ -587,6 +589,8 @@
587589
DD2C2E502D3B8B0B006413A5 /* NightscoutSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSettingsViewModel.swift; sourceTree = "<group>"; };
588590
DD485F132E454B2600CE8CBF /* SecureMessenger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureMessenger.swift; sourceTree = "<group>"; };
589591
DD4878022C7B297E0048F05C /* StorageValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageValue.swift; sourceTree = "<group>"; };
592+
5106EAD100000000000000A1 /* StorageReadiness.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageReadiness.swift; sourceTree = "<group>"; };
593+
5106EAD100000000000000B1 /* StorageLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageLoadingView.swift; sourceTree = "<group>"; };
590594
DD4878042C7B2C970048F05C /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = "<group>"; };
591595
DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSettingsView.swift; sourceTree = "<group>"; };
592596
DD4878092C7B30D40048F05C /* RemoteSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSettingsViewModel.swift; sourceTree = "<group>"; };
@@ -1533,6 +1537,7 @@
15331537
children = (
15341538
FC9788172485969B00A7906C /* AppDelegate.swift */,
15351539
AA1B2C3D4E5F6A7B8C9D0E1F /* LoopFollowApp.swift */,
1540+
5106EAD100000000000000B1 /* StorageLoadingView.swift */,
15361541
BB2C3D4E5F6A7B8C9D0E1F2A /* MainTabView.swift */,
15371542
FC9788272485969C00A7906C /* LaunchScreen.storyboard */,
15381543
);
@@ -1758,6 +1763,7 @@
17581763
DDD10F042C529DA200D76A8E /* ObservableValue.swift */,
17591764
DD4878022C7B297E0048F05C /* StorageValue.swift */,
17601765
DD16AF0C2C98485400FB655A /* SecureStorageValue.swift */,
1766+
5106EAD100000000000000A1 /* StorageReadiness.swift */,
17611767
);
17621768
path = Framework;
17631769
sourceTree = "<group>";
@@ -2436,6 +2442,8 @@
24362442
DD026E5B2EA2C9C300A39CB5 /* InsulinFormatter.swift in Sources */,
24372443
DD5334B02D1447C500CDD6EA /* BLEManager.swift in Sources */,
24382444
DD4878032C7B297E0048F05C /* StorageValue.swift in Sources */,
2445+
5106EAD100000000000000A2 /* StorageReadiness.swift in Sources */,
2446+
5106EAD100000000000000B2 /* StorageLoadingView.swift in Sources */,
24392447
FC1BDD2B24A22650001B652C /* Stats.swift in Sources */,
24402448
DDA9ACAC2D6B317100E6F1A9 /* ContactType.swift in Sources */,
24412449
DDD10F052C529DA200D76A8E /* ObservableValue.swift in Sources */,

LoopFollow/Application/AppDelegate.swift

Lines changed: 52 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,37 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
1313
LogManager.shared.log(category: .general, message: "App started")
1414
LogManager.shared.cleanupOldLogs()
1515

16+
// Before-First-Unlock detection. isProtectedDataAvailable is false on ANY
17+
// locked launch, so it alone isn't a BFU signal — post-first-unlock
18+
// UserDefaults (class C) reads fine while locked. Only true BFU makes a key
19+
// that should exist read as absent. Suspect only when ALL presence probes
20+
// are absent, so an existing user who updated but hasn't foregrounded this
21+
// build isn't misread on an ordinary locked launch. Probes cover every user
22+
// shape (marker / migrated / consented / NS-configured); presence, not value.
23+
_ = Storage.shared // ensure every StorageValue is registered before recovery
24+
let storageConfirmedReadable = StorageReadiness.markerExists
25+
|| Storage.shared.migrationStep.exists
26+
|| Storage.shared.telemetryConsentDecisionMade.exists
27+
|| Storage.shared.url.exists
28+
let suspectBFU = !UIApplication.shared.isProtectedDataAvailable && !storageConfirmedReadable
29+
StorageReadiness.configure(suspectBFU: suspectBFU)
30+
LogManager.shared.log(category: .general, message: "BFU check: isProtectedDataAvailable=\(UIApplication.shared.isProtectedDataAvailable), storageConfirmedReadable=\(storageConfirmedReadable), suspectBFU=\(suspectBFU)")
31+
32+
if suspectBFU {
33+
// Driven here, not MainViewController: on a BG-only launch (BGAppRefreshTask,
34+
// BLE wake) the home VC may not exist yet. protectedDataDidBecomeAvailable is
35+
// authoritative; willEnterForeground is a fallback.
36+
let nc = NotificationCenter.default
37+
nc.addObserver(self, selector: #selector(protectedDataDidBecomeAvailable), name: UIApplication.protectedDataDidBecomeAvailableNotification, object: nil)
38+
nc.addObserver(self, selector: #selector(handleWillEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
39+
40+
// Race guard: protected data may have become available between the check
41+
// above and the observer registration just now.
42+
if UIApplication.shared.isProtectedDataAvailable {
43+
completeStorageRecovery()
44+
}
45+
}
46+
1647
let options: UNAuthorizationOptions = [.alert, .sound, .badge]
1748
notificationCenter.requestAuthorization(options: options) {
1849
didAllow, _ in
@@ -46,42 +77,19 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
4677

4778
BackgroundRefreshManager.shared.register()
4879

49-
// Telemetry: record this cold launch (used by the rolling
50-
// coldLaunches7d signal). If the running build's SHA differs from
51-
// the one we last sent for, fire an immediate ping — the scheduler
52-
// alone can't notice an app update. Otherwise let the 24h scheduler
53-
// handle cadence: its first run is lastSentAt + 24h, so a relaunch
54-
// a few hours after the previous send simply waits out the
55-
// remainder. See Helpers/Telemetry.swift.
56-
TelemetryClient.shared.recordColdLaunch()
57-
Task.detached {
58-
if TelemetryClient.shared.buildShaChangedSinceLastSend() {
59-
await TelemetryClient.shared.maybeSend()
80+
// Telemetry mutates rolling history (coldLaunches7d), so defer past a BFU
81+
// window — poisoned defaults would discard real history. Runs synchronously
82+
// on a normal launch.
83+
StorageReadiness.whenReady {
84+
// SHA change fires an immediate ping (the scheduler can't notice an app
85+
// update); otherwise the 24h scheduler handles cadence. See Telemetry.swift.
86+
TelemetryClient.shared.recordColdLaunch()
87+
Task.detached {
88+
if TelemetryClient.shared.buildShaChangedSinceLastSend() {
89+
await TelemetryClient.shared.maybeSend()
90+
}
91+
TelemetryClient.shared.scheduleRecurring()
6092
}
61-
TelemetryClient.shared.scheduleRecurring()
62-
}
63-
64-
// Detect Before-First-Unlock launch. If protected data is unavailable here,
65-
// StorageValues were cached from encrypted UserDefaults and need a reload
66-
// once the device is unlocked.
67-
let bfu = !UIApplication.shared.isProtectedDataAvailable
68-
Storage.shared.needsBFUReload = bfu
69-
LogManager.shared.log(category: .general, message: "BFU check: isProtectedDataAvailable=\(!bfu), needsBFUReload=\(bfu)")
70-
71-
// Recovery is driven from AppDelegate (not MainViewController) because under
72-
// the SwiftUI App lifecycle the home tab's UIHostingController is materialized
73-
// lazily — on a BG-only launch (BGAppRefreshTask, BLE wake) MainViewController
74-
// may not exist when the device is unlocked, and would miss willEnterForeground.
75-
// protectedDataDidBecomeAvailable fires the moment file protection lifts and
76-
// is the authoritative signal; willEnterForeground is a fallback.
77-
let nc = NotificationCenter.default
78-
nc.addObserver(self, selector: #selector(protectedDataDidBecomeAvailable), name: UIApplication.protectedDataDidBecomeAvailableNotification, object: nil)
79-
nc.addObserver(self, selector: #selector(handleWillEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
80-
81-
// Race guard: protected data may have become available between the check
82-
// above and the observer registration just now.
83-
if Storage.shared.needsBFUReload, UIApplication.shared.isProtectedDataAvailable {
84-
performBFUReloadIfNeeded()
8593
}
8694

8795
return true
@@ -90,19 +98,18 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
9098
// MARK: - BFU recovery
9199

92100
@objc private func protectedDataDidBecomeAvailable() {
93-
performBFUReloadIfNeeded()
101+
completeStorageRecovery()
94102
}
95103

96104
@objc private func handleWillEnterForeground() {
97-
performBFUReloadIfNeeded()
105+
completeStorageRecovery()
98106
}
99107

100-
private func performBFUReloadIfNeeded() {
101-
guard Storage.shared.needsBFUReload else { return }
102-
Storage.shared.needsBFUReload = false
103-
LogManager.shared.log(category: .general, message: "BFU reload triggered — reloading all StorageValues")
104-
Storage.shared.reloadAll()
105-
LogManager.shared.log(category: .general, message: "BFU reload complete: url='\(Storage.shared.url.value)'")
108+
private func completeStorageRecovery() {
109+
// recover() hydrates every value and opens the gate; true only on the one
110+
// transition that did the work, so the notification fires exactly once.
111+
guard StorageReadiness.recover() else { return }
112+
LogManager.shared.log(category: .general, message: "BFU recovery complete: url='\(Storage.shared.url.value)'")
106113
NotificationCenter.default.post(name: .bfuReloadCompleted, object: nil)
107114
}
108115

@@ -207,7 +214,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
207214

208215
extension Notification.Name {
209216
/// Posted by AppDelegate after a Before-First-Unlock recovery completes
210-
/// (Storage.reloadAll has run with the now-decrypted UserDefaults).
217+
/// (StorageReadiness.recover has hydrated every value from the now-decrypted
218+
/// UserDefaults).
211219
static let bfuReloadCompleted = Notification.Name("LoopFollow.bfuReloadCompleted")
212220
}
213221

LoopFollow/Application/LoopFollowApp.swift

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,27 @@ import SwiftUI
66
@main
77
struct LoopFollowApp: App {
88
@UIApplicationDelegateAdaptor private var appDelegate: AppDelegate
9+
@ObservedObject private var storageReady = StorageReadiness.ready
910

1011
var body: some Scene {
1112
WindowGroup {
12-
MainTabView()
13-
.onOpenURL { url in
14-
guard url.scheme == AppGroupID.urlScheme, url.host == "la-tap" else { return }
15-
#if !targetEnvironment(macCatalyst)
16-
DispatchQueue.main.async {
17-
NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil)
18-
}
19-
#endif
20-
}
13+
// Gate the UI on storage readiness so nothing (bootstrap, telemetry
14+
// consent, onboarding) is built against a poisoned cache. True
15+
// synchronously on a normal launch; only false briefly while a BFU
16+
// background launch is foregrounded mid-hydration.
17+
if storageReady.value {
18+
MainTabView()
19+
.onOpenURL { url in
20+
guard url.scheme == AppGroupID.urlScheme, url.host == "la-tap" else { return }
21+
#if !targetEnvironment(macCatalyst)
22+
DispatchQueue.main.async {
23+
NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil)
24+
}
25+
#endif
26+
}
27+
} else {
28+
StorageLoadingView()
29+
}
2130
}
2231
}
2332
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// LoopFollow
2+
// StorageLoadingView.swift
3+
4+
import SwiftUI
5+
6+
/// Shown at the root until `StorageReadiness.ready` — only while a BFU background
7+
/// launch is foregrounded mid-hydration; a normal launch opens straight to
8+
/// `MainTabView`. Must not read `Storage` (values are poisoned here) — system
9+
/// colors only.
10+
struct StorageLoadingView: View {
11+
var body: some View {
12+
ZStack {
13+
Color(.systemBackground)
14+
.ignoresSafeArea()
15+
ProgressView()
16+
.controlSize(.large)
17+
}
18+
}
19+
}

LoopFollow/BackgroundRefresh/BT/BLEManager.swift

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ class BLEManager: NSObject, ObservableObject {
1212

1313
private var centralManager: CBCentralManager!
1414
private var activeDevice: BluetoothDevice?
15+
private var readinessCancellable: AnyCancellable?
1516

1617
override private init() {
1718
super.init()
@@ -20,13 +21,28 @@ class BLEManager: NSObject, ObservableObject {
2021
delegate: self,
2122
queue: .main
2223
)
23-
if let device = Storage.shared.selectedBLEDevice.value {
24-
devices.append(device)
25-
findAndUpdateDevice(with: device.id.uuidString) { device in
26-
device.rssi = 0
24+
connectSelectedDeviceIfNeeded()
25+
26+
// After BFU, selectedBLEDevice reads nil until hydration — reconnect when
27+
// storage becomes ready. dropFirst skips the current value (init handled the
28+
// already-ready case), so this fires only on the false→true recovery.
29+
readinessCancellable = StorageReadiness.ready.$value
30+
.dropFirst()
31+
.filter { $0 }
32+
.sink { [weak self] _ in
33+
self?.connectSelectedDeviceIfNeeded()
2734
}
28-
connect(device: device)
35+
}
36+
37+
private func connectSelectedDeviceIfNeeded() {
38+
guard activeDevice == nil, let device = Storage.shared.selectedBLEDevice.value else { return }
39+
if !devices.contains(where: { $0.id == device.id }) {
40+
devices.append(device)
41+
}
42+
findAndUpdateDevice(with: device.id.uuidString) { device in
43+
device.rssi = 0
2944
}
45+
connect(device: device)
3046
}
3147

3248
func getSelectedDevice() -> BLEDevice? {

LoopFollow/Storage/Framework/SecureStorageValue.swift

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,17 @@
44
import Combine
55
import Foundation
66

7-
class SecureStorageValue<T: NSObject & NSSecureCoding & Equatable>: ObservableObject {
7+
class SecureStorageValue<T: NSObject & NSSecureCoding & Equatable>: ObservableObject, BFUReloadable {
88
let key: String
99

1010
@Published var value: T {
1111
didSet {
1212
guard value != oldValue else { return }
13+
// Memory-only during a suspect BFU window; see StorageValue's guard.
14+
guard !StorageReadiness.isSuppressingWrites else {
15+
StorageReadiness.noteSuppressedWrite(key: key)
16+
return
17+
}
1318
if let data = try? NSKeyedArchiver.archivedData(withRootObject: value, requiringSecureCoding: true) {
1419
SecureStorageValue.defaults.set(data, forKey: key)
1520
}
@@ -33,9 +38,22 @@ class SecureStorageValue<T: NSObject & NSSecureCoding & Equatable>: ObservableOb
3338
} else {
3439
value = defaultValue
3540
}
41+
42+
StorageReadiness.register(self)
3643
}
3744

3845
func remove() {
3946
SecureStorageValue.defaults.removeObject(forKey: key)
4047
}
48+
49+
/// Re-reads from UserDefaults during BFU recovery. Same class-C backing as
50+
/// StorageValue, so it recovers identically.
51+
func reload() {
52+
if let data = SecureStorageValue.defaults.data(forKey: key),
53+
let decodedValue = try? NSKeyedUnarchiver.unarchivedObject(ofClass: T.self, from: data),
54+
decodedValue != value
55+
{
56+
value = decodedValue
57+
}
58+
}
4159
}

0 commit comments

Comments
 (0)