diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 43aef8e87..d529b7bf6 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -121,6 +121,8 @@ DD2C2E512D3B8B0C006413A5 /* NightscoutSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E502D3B8B0B006413A5 /* NightscoutSettingsViewModel.swift */; }; DD485F142E454B2600CE8CBF /* SecureMessenger.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD485F132E454B2600CE8CBF /* SecureMessenger.swift */; }; DD4878032C7B297E0048F05C /* StorageValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878022C7B297E0048F05C /* StorageValue.swift */; }; + 5106EAD100000000000000A2 /* StorageReadiness.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5106EAD100000000000000A1 /* StorageReadiness.swift */; }; + 5106EAD100000000000000B2 /* StorageLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5106EAD100000000000000B1 /* StorageLoadingView.swift */; }; DD4878052C7B2C970048F05C /* Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878042C7B2C970048F05C /* Storage.swift */; }; DD4878082C7B30BF0048F05C /* RemoteSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */; }; DD48780A2C7B30D40048F05C /* RemoteSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4878092C7B30D40048F05C /* RemoteSettingsViewModel.swift */; }; @@ -584,6 +586,8 @@ DD2C2E502D3B8B0B006413A5 /* NightscoutSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSettingsViewModel.swift; sourceTree = ""; }; DD485F132E454B2600CE8CBF /* SecureMessenger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureMessenger.swift; sourceTree = ""; }; DD4878022C7B297E0048F05C /* StorageValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageValue.swift; sourceTree = ""; }; + 5106EAD100000000000000A1 /* StorageReadiness.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageReadiness.swift; sourceTree = ""; }; + 5106EAD100000000000000B1 /* StorageLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageLoadingView.swift; sourceTree = ""; }; DD4878042C7B2C970048F05C /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = ""; }; DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSettingsView.swift; sourceTree = ""; }; DD4878092C7B30D40048F05C /* RemoteSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSettingsViewModel.swift; sourceTree = ""; }; @@ -1515,6 +1519,7 @@ children = ( FC9788172485969B00A7906C /* AppDelegate.swift */, AA1B2C3D4E5F6A7B8C9D0E1F /* LoopFollowApp.swift */, + 5106EAD100000000000000B1 /* StorageLoadingView.swift */, BB2C3D4E5F6A7B8C9D0E1F2A /* MainTabView.swift */, FC9788272485969C00A7906C /* LaunchScreen.storyboard */, ); @@ -1740,6 +1745,7 @@ DDD10F042C529DA200D76A8E /* ObservableValue.swift */, DD4878022C7B297E0048F05C /* StorageValue.swift */, DD16AF0C2C98485400FB655A /* SecureStorageValue.swift */, + 5106EAD100000000000000A1 /* StorageReadiness.swift */, ); path = Framework; sourceTree = ""; @@ -2423,6 +2429,8 @@ DD026E5B2EA2C9C300A39CB5 /* InsulinFormatter.swift in Sources */, DD5334B02D1447C500CDD6EA /* BLEManager.swift in Sources */, DD4878032C7B297E0048F05C /* StorageValue.swift in Sources */, + 5106EAD100000000000000A2 /* StorageReadiness.swift in Sources */, + 5106EAD100000000000000B2 /* StorageLoadingView.swift in Sources */, FC1BDD2B24A22650001B652C /* Stats.swift in Sources */, DDA9ACAC2D6B317100E6F1A9 /* ContactType.swift in Sources */, DDD10F052C529DA200D76A8E /* ObservableValue.swift in Sources */, diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 6c9d8e884..bfacf9a04 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -13,6 +13,37 @@ class AppDelegate: UIResponder, UIApplicationDelegate { LogManager.shared.log(category: .general, message: "App started") LogManager.shared.cleanupOldLogs() + // Before-First-Unlock detection. isProtectedDataAvailable is false on ANY + // locked launch, so it alone isn't a BFU signal — post-first-unlock + // UserDefaults (class C) reads fine while locked. Only true BFU makes a key + // that should exist read as absent. Suspect only when ALL presence probes + // are absent, so an existing user who updated but hasn't foregrounded this + // build isn't misread on an ordinary locked launch. Probes cover every user + // shape (marker / migrated / consented / NS-configured); presence, not value. + _ = Storage.shared // ensure every StorageValue is registered before recovery + let storageConfirmedReadable = StorageReadiness.markerExists + || Storage.shared.migrationStep.exists + || Storage.shared.telemetryConsentDecisionMade.exists + || Storage.shared.url.exists + let suspectBFU = !UIApplication.shared.isProtectedDataAvailable && !storageConfirmedReadable + StorageReadiness.configure(suspectBFU: suspectBFU) + LogManager.shared.log(category: .general, message: "BFU check: isProtectedDataAvailable=\(UIApplication.shared.isProtectedDataAvailable), storageConfirmedReadable=\(storageConfirmedReadable), suspectBFU=\(suspectBFU)") + + if suspectBFU { + // Driven here, not MainViewController: on a BG-only launch (BGAppRefreshTask, + // BLE wake) the home VC may not exist yet. protectedDataDidBecomeAvailable is + // authoritative; willEnterForeground is a fallback. + let nc = NotificationCenter.default + nc.addObserver(self, selector: #selector(protectedDataDidBecomeAvailable), name: UIApplication.protectedDataDidBecomeAvailableNotification, object: nil) + nc.addObserver(self, selector: #selector(handleWillEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil) + + // Race guard: protected data may have become available between the check + // above and the observer registration just now. + if UIApplication.shared.isProtectedDataAvailable { + completeStorageRecovery() + } + } + let options: UNAuthorizationOptions = [.alert, .sound, .badge] notificationCenter.requestAuthorization(options: options) { didAllow, _ in @@ -46,42 +77,19 @@ class AppDelegate: UIResponder, UIApplicationDelegate { BackgroundRefreshManager.shared.register() - // Telemetry: record this cold launch (used by the rolling - // coldLaunches7d signal). If the running build's SHA differs from - // the one we last sent for, fire an immediate ping — the scheduler - // alone can't notice an app update. Otherwise let the 24h scheduler - // handle cadence: its first run is lastSentAt + 24h, so a relaunch - // a few hours after the previous send simply waits out the - // remainder. See Helpers/Telemetry.swift. - TelemetryClient.shared.recordColdLaunch() - Task.detached { - if TelemetryClient.shared.buildShaChangedSinceLastSend() { - await TelemetryClient.shared.maybeSend() + // Telemetry mutates rolling history (coldLaunches7d), so defer past a BFU + // window — poisoned defaults would discard real history. Runs synchronously + // on a normal launch. + StorageReadiness.whenReady { + // SHA change fires an immediate ping (the scheduler can't notice an app + // update); otherwise the 24h scheduler handles cadence. See Telemetry.swift. + TelemetryClient.shared.recordColdLaunch() + Task.detached { + if TelemetryClient.shared.buildShaChangedSinceLastSend() { + await TelemetryClient.shared.maybeSend() + } + TelemetryClient.shared.scheduleRecurring() } - TelemetryClient.shared.scheduleRecurring() - } - - // Detect Before-First-Unlock launch. If protected data is unavailable here, - // StorageValues were cached from encrypted UserDefaults and need a reload - // once the device is unlocked. - let bfu = !UIApplication.shared.isProtectedDataAvailable - Storage.shared.needsBFUReload = bfu - LogManager.shared.log(category: .general, message: "BFU check: isProtectedDataAvailable=\(!bfu), needsBFUReload=\(bfu)") - - // Recovery is driven from AppDelegate (not MainViewController) because under - // the SwiftUI App lifecycle the home tab's UIHostingController is materialized - // lazily — on a BG-only launch (BGAppRefreshTask, BLE wake) MainViewController - // may not exist when the device is unlocked, and would miss willEnterForeground. - // protectedDataDidBecomeAvailable fires the moment file protection lifts and - // is the authoritative signal; willEnterForeground is a fallback. - let nc = NotificationCenter.default - nc.addObserver(self, selector: #selector(protectedDataDidBecomeAvailable), name: UIApplication.protectedDataDidBecomeAvailableNotification, object: nil) - nc.addObserver(self, selector: #selector(handleWillEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil) - - // Race guard: protected data may have become available between the check - // above and the observer registration just now. - if Storage.shared.needsBFUReload, UIApplication.shared.isProtectedDataAvailable { - performBFUReloadIfNeeded() } return true @@ -90,19 +98,18 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // MARK: - BFU recovery @objc private func protectedDataDidBecomeAvailable() { - performBFUReloadIfNeeded() + completeStorageRecovery() } @objc private func handleWillEnterForeground() { - performBFUReloadIfNeeded() + completeStorageRecovery() } - private func performBFUReloadIfNeeded() { - guard Storage.shared.needsBFUReload else { return } - Storage.shared.needsBFUReload = false - LogManager.shared.log(category: .general, message: "BFU reload triggered — reloading all StorageValues") - Storage.shared.reloadAll() - LogManager.shared.log(category: .general, message: "BFU reload complete: url='\(Storage.shared.url.value)'") + private func completeStorageRecovery() { + // recover() hydrates every value and opens the gate; true only on the one + // transition that did the work, so the notification fires exactly once. + guard StorageReadiness.recover() else { return } + LogManager.shared.log(category: .general, message: "BFU recovery complete: url='\(Storage.shared.url.value)'") NotificationCenter.default.post(name: .bfuReloadCompleted, object: nil) } @@ -207,7 +214,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { extension Notification.Name { /// Posted by AppDelegate after a Before-First-Unlock recovery completes - /// (Storage.reloadAll has run with the now-decrypted UserDefaults). + /// (StorageReadiness.recover has hydrated every value from the now-decrypted + /// UserDefaults). static let bfuReloadCompleted = Notification.Name("LoopFollow.bfuReloadCompleted") } diff --git a/LoopFollow/Application/LoopFollowApp.swift b/LoopFollow/Application/LoopFollowApp.swift index ee5e2f0fc..4725a12bf 100644 --- a/LoopFollow/Application/LoopFollowApp.swift +++ b/LoopFollow/Application/LoopFollowApp.swift @@ -6,18 +6,27 @@ import SwiftUI @main struct LoopFollowApp: App { @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate + @ObservedObject private var storageReady = StorageReadiness.ready var body: some Scene { WindowGroup { - MainTabView() - .onOpenURL { url in - guard url.scheme == AppGroupID.urlScheme, url.host == "la-tap" else { return } - #if !targetEnvironment(macCatalyst) - DispatchQueue.main.async { - NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) - } - #endif - } + // Gate the UI on storage readiness so nothing (bootstrap, telemetry + // consent, onboarding) is built against a poisoned cache. True + // synchronously on a normal launch; only false briefly while a BFU + // background launch is foregrounded mid-hydration. + if storageReady.value { + MainTabView() + .onOpenURL { url in + guard url.scheme == AppGroupID.urlScheme, url.host == "la-tap" else { return } + #if !targetEnvironment(macCatalyst) + DispatchQueue.main.async { + NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) + } + #endif + } + } else { + StorageLoadingView() + } } } } diff --git a/LoopFollow/Application/StorageLoadingView.swift b/LoopFollow/Application/StorageLoadingView.swift new file mode 100644 index 000000000..38d48d864 --- /dev/null +++ b/LoopFollow/Application/StorageLoadingView.swift @@ -0,0 +1,19 @@ +// LoopFollow +// StorageLoadingView.swift + +import SwiftUI + +/// Shown at the root until `StorageReadiness.ready` — only while a BFU background +/// launch is foregrounded mid-hydration; a normal launch opens straight to +/// `MainTabView`. Must not read `Storage` (values are poisoned here) — system +/// colors only. +struct StorageLoadingView: View { + var body: some View { + ZStack { + Color(.systemBackground) + .ignoresSafeArea() + ProgressView() + .controlSize(.large) + } + } +} diff --git a/LoopFollow/BackgroundRefresh/BT/BLEManager.swift b/LoopFollow/BackgroundRefresh/BT/BLEManager.swift index c4a39874c..02e964f08 100644 --- a/LoopFollow/BackgroundRefresh/BT/BLEManager.swift +++ b/LoopFollow/BackgroundRefresh/BT/BLEManager.swift @@ -12,6 +12,7 @@ class BLEManager: NSObject, ObservableObject { private var centralManager: CBCentralManager! private var activeDevice: BluetoothDevice? + private var readinessCancellable: AnyCancellable? override private init() { super.init() @@ -20,13 +21,28 @@ class BLEManager: NSObject, ObservableObject { delegate: self, queue: .main ) - if let device = Storage.shared.selectedBLEDevice.value { - devices.append(device) - findAndUpdateDevice(with: device.id.uuidString) { device in - device.rssi = 0 + connectSelectedDeviceIfNeeded() + + // After BFU, selectedBLEDevice reads nil until hydration — reconnect when + // storage becomes ready. dropFirst skips the current value (init handled the + // already-ready case), so this fires only on the false→true recovery. + readinessCancellable = StorageReadiness.ready.$value + .dropFirst() + .filter { $0 } + .sink { [weak self] _ in + self?.connectSelectedDeviceIfNeeded() } - connect(device: device) + } + + private func connectSelectedDeviceIfNeeded() { + guard activeDevice == nil, let device = Storage.shared.selectedBLEDevice.value else { return } + if !devices.contains(where: { $0.id == device.id }) { + devices.append(device) + } + findAndUpdateDevice(with: device.id.uuidString) { device in + device.rssi = 0 } + connect(device: device) } func getSelectedDevice() -> BLEDevice? { diff --git a/LoopFollow/Storage/Framework/SecureStorageValue.swift b/LoopFollow/Storage/Framework/SecureStorageValue.swift index 5ca4b7328..b76e7cc04 100644 --- a/LoopFollow/Storage/Framework/SecureStorageValue.swift +++ b/LoopFollow/Storage/Framework/SecureStorageValue.swift @@ -4,12 +4,17 @@ import Combine import Foundation -class SecureStorageValue: ObservableObject { +class SecureStorageValue: ObservableObject, BFUReloadable { let key: String @Published var value: T { didSet { guard value != oldValue else { return } + // Memory-only during a suspect BFU window; see StorageValue's guard. + guard !StorageReadiness.isSuppressingWrites else { + StorageReadiness.noteSuppressedWrite(key: key) + return + } if let data = try? NSKeyedArchiver.archivedData(withRootObject: value, requiringSecureCoding: true) { SecureStorageValue.defaults.set(data, forKey: key) } @@ -33,9 +38,22 @@ class SecureStorageValue: ObservableOb } else { value = defaultValue } + + StorageReadiness.register(self) } func remove() { SecureStorageValue.defaults.removeObject(forKey: key) } + + /// Re-reads from UserDefaults during BFU recovery. Same class-C backing as + /// StorageValue, so it recovers identically. + func reload() { + if let data = SecureStorageValue.defaults.data(forKey: key), + let decodedValue = try? NSKeyedUnarchiver.unarchivedObject(ofClass: T.self, from: data), + decodedValue != value + { + value = decodedValue + } + } } diff --git a/LoopFollow/Storage/Framework/StorageReadiness.swift b/LoopFollow/Storage/Framework/StorageReadiness.swift new file mode 100644 index 000000000..a518690cb --- /dev/null +++ b/LoopFollow/Storage/Framework/StorageReadiness.swift @@ -0,0 +1,125 @@ +// LoopFollow +// StorageReadiness.swift + +import Combine +import Foundation +import UIKit + +/// A cached value that can re-read itself from disk after a BFU launch. +protocol BFUReloadable: AnyObject { + func reload() +} + +/// Before-First-Unlock (BFU) storage recovery + the app-wide readiness gate. +/// +/// On a launch before the device's first unlock since boot, `UserDefaults` is +/// encrypted, so every `StorageValue`/`SecureStorageValue` initialises to its +/// default. This distinguishes true BFU from an ordinary locked launch (where the +/// store reads fine), gates the UI until storage is trustworthy, hydrates every +/// registered value once readable, and suppresses writes meanwhile so poisoned +/// defaults can't be flushed over real settings on unlock. +/// +/// All mutators run on the main thread (launch + the protected-data/foreground +/// notifications); `register(_:)` runs during `Storage` init. So no locking. +enum StorageReadiness { + /// Published gate; `false` until storage is known-good. The root shows a + /// storage-free loading view until `true`. + static let ready = ObservableValue(default: false) + + /// True for the duration of a suspected true-BFU launch, until hydration runs. + private(set) static var suspectBFU = false + + /// While true, storage values keep writes in memory only (hydration reconciles + /// from disk). Off-main reads see a benign eventually-consistent Bool snapshot. + static var isSuppressingWrites: Bool { suspectBFU && !ready.value } + + /// Debug surface for any writer active during the suspect window — such a write + /// is memory-only and lost if it was needed on disk. None exist today. + static func noteSuppressedWrite(key: String) { + #if DEBUG + LogManager.shared.log(category: .general, message: "Storage write to '\(key)' suppressed during BFU window") + #endif + } + + // MARK: - Sentinel + + private static let markerKey = "storageReadinessMarker" + + /// Present once storage has been confirmed readable. Absent in true BFU (store + /// encrypted) — presence, not a value, so it can't drift. + static var markerExists: Bool { + UserDefaults.standard.object(forKey: markerKey) != nil + } + + // MARK: - Registry + + private struct WeakReloadable { + weak var ref: BFUReloadable? + } + + private static var registry: [WeakReloadable] = [] + + /// Called by every storage value at init, so hydration covers all by construction. + static func register(_ reloadable: BFUReloadable) { + registry.append(WeakReloadable(ref: reloadable)) + } + + // MARK: - Deferred work + + private static var pending: [() -> Void] = [] + + /// Run `block` now if ready, else once ready. For launch work that reads real + /// config or mutates persisted history (telemetry, network). + static func whenReady(_ block: @escaping () -> Void) { + if ready.value { + block() + } else { + pending.append(block) + } + } + + // MARK: - Lifecycle + + /// Call once at launch. A non-suspect launch is marked ready synchronously (the + /// common path); a suspect launch stays gated until `recover()`. + static func configure(suspectBFU: Bool) { + self.suspectBFU = suspectBFU + if !suspectBFU { + markReady() + } + } + + /// Hydrate every registered value and open the gate. Idempotent; returns `true` + /// only on the transition that did the work. + @discardableResult + static func recover() -> Bool { + guard suspectBFU, !ready.value else { return false } + registry.removeAll { $0.ref == nil } // drop dead weak slots + for entry in registry { + entry.ref?.reload() + } + markReady() + return true + } + + private static func markReady() { + guard !ready.value else { return } + + // Clear suspect BEFORE publishing: @Published emits in willSet, so a + // ready.$value subscriber (BLEManager reconnect) runs while ready is still + // false — clearing first lets its persists land. + suspectBFU = false + + // Store is readable here (proven by the probes, or by protected data + // becoming available), and class-C UserDefaults is writable while readable. + if !markerExists { + UserDefaults.standard.set(true, forKey: markerKey) + } + + ready.value = true + + let blocks = pending + pending.removeAll() + blocks.forEach { $0() } + } +} diff --git a/LoopFollow/Storage/Framework/StorageValue.swift b/LoopFollow/Storage/Framework/StorageValue.swift index 86faa34da..fc9de99b1 100644 --- a/LoopFollow/Storage/Framework/StorageValue.swift +++ b/LoopFollow/Storage/Framework/StorageValue.swift @@ -4,13 +4,20 @@ import Combine import Foundation -class StorageValue: ObservableObject { +class StorageValue: ObservableObject, BFUReloadable { let key: String @Published var value: T { didSet { guard value != oldValue else { return } + // Suspect BFU: keep in memory only — a poisoned-default write could be + // flushed over real data on unlock. Hydration reconciles from disk. + guard !StorageReadiness.isSuppressingWrites else { + StorageReadiness.noteSuppressedWrite(key: key) + return + } + if let encodedData = try? JSONEncoder().encode(value) { StorageValue.defaults.set(encodedData, forKey: key) } @@ -35,6 +42,8 @@ class StorageValue: ObservableObject { } else { value = defaultValue } + + StorageReadiness.register(self) } func remove() { diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 4dd7370a8..b5fac9afa 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -249,199 +249,6 @@ class Storage { static let shared = Storage() private init() {} - /// Set to true at launch if isProtectedDataAvailable was false (BFU state). - /// Consumed and cleared on the first foreground after that launch. - var needsBFUReload = false - - /// Re-reads every StorageValue from UserDefaults, firing @Published only where the value - /// actually changed. Call this when foregrounding after a Before-First-Unlock (BFU) background - /// launch, where Storage was initialized while UserDefaults was encrypted and all values were - /// cached as their defaults. - /// - /// `migrationStep` is intentionally excluded: viewDidLoad writes it to the latest step during - /// the BFU launch; if we reloaded it and the flush had somehow not landed yet, migrations would re-run. - /// - /// SecureStorageValue properties (maxBolus, maxCarbs, maxProtein, maxFat, bolusIncrement) are - /// not covered here — SecureStorageValue does not implement reload() and Keychain has the same - /// BFU inaccessibility; that is a separate problem. - func reloadAll() { - remoteType.reload() - deviceToken.reload() - expirationDate.reload() - sharedSecret.reload() - productionEnvironment.reload() - remoteApnsKey.reload() - teamId.reload() - remoteKeyId.reload() - - lfApnsKey.reload() - lfKeyId.reload() - bundleId.reload() - user.reload() - - mealWithBolus.reload() - mealWithFatProtein.reload() - hasSeenFatProteinOrderChange.reload() - - backgroundRefreshType.reload() - selectedBLEDevice.reload() - debugLogLevel.reload() - - contactTrend.reload() - contactDelta.reload() - contactEnabled.reload() - contactBackgroundColor.reload() - contactTextColor.reload() - - sensorScheduleOffset.reload() - alarms.reload() - alarmConfiguration.reload() - - lastOverrideStartNotified.reload() - lastOverrideEndNotified.reload() - lastTempTargetStartNotified.reload() - lastTempTargetEndNotified.reload() - lastRecBolusNotified.reload() - lastCOBNotified.reload() - lastMissedBolusNotified.reload() - - appBadge.reload() - colorBGText.reload() - appearanceMode.reload() - showStats.reload() - useIFCC.reload() - showSmallGraph.reload() - screenlockSwitchState.reload() - showDisplayName.reload() - snoozerEmoji.reload() - forcePortraitMode.reload() - - speakBG.reload() - speakBGAlways.reload() - speakLowBG.reload() - speakProactiveLowBG.reload() - speakFastDropDelta.reload() - speakLowBGLimit.reload() - speakHighBGLimit.reload() - speakHighBG.reload() - speakLanguage.reload() - - lastBgReadingTimeSeconds.reload() - lastDeltaMgdl.reload() - lastTrendCode.reload() - lastIOB.reload() - lastCOB.reload() - projectedBgMgdl.reload() - - lastBasal.reload() - lastPumpReservoirU.reload() - lastAutosens.reload() - lastTdd.reload() - lastTargetLowMgdl.reload() - lastTargetHighMgdl.reload() - lastIsfMgdlPerU.reload() - lastCarbRatio.reload() - lastCarbsToday.reload() - lastProfileName.reload() - iageInsertTime.reload() - lastMinBgMgdl.reload() - lastMaxBgMgdl.reload() - - laEnabled.reload() - laRenewBy.reload() - laRenewalFailed.reload() - laPushToStartToken.reload() - laLastPushToStartAt.reload() - laPushToStartBackoff.reload() - - showDots.reload() - showLines.reload() - showValues.reload() - showAbsorption.reload() - showDIALines.reload() - show30MinLine.reload() - show90MinLine.reload() - showMidnightLines.reload() - smallGraphTreatments.reload() - smallGraphHeight.reload() - predictionToLoad.reload() - predictionDisplayType.reload() - minBasalScale.reload() - minBGScale.reload() - lowLine.reload() - highLine.reload() - downloadDays.reload() - graphTimeZoneEnabled.reload() - graphTimeZoneIdentifier.reload() - - writeCalendarEvent.reload() - calendarIdentifier.reload() - watchLine1.reload() - watchLine2.reload() - - shareUserName.reload() - sharePassword.reload() - shareServer.reload() - - chartScaleX.reload() - - downloadTreatments.reload() - downloadPrediction.reload() - graphOtherTreatments.reload() - graphBasal.reload() - graphBolus.reload() - graphCarbs.reload() - bgUpdateDelay.reload() - - cageInsertTime.reload() - sageInsertTime.reload() - - cachedForVersion.reload() - latestVersion.reload() - latestVersionChecked.reload() - currentVersionBlackListed.reload() - lastBlacklistNotificationShown.reload() - lastVersionUpdateNotificationShown.reload() - lastExpirationNotificationShown.reload() - - hideInfoTable.reload() - token.reload() - units.reload() - infoSort.reload() - infoVisible.reload() - - url.reload() - device.reload() - nsWriteAuth.reload() - nsAdminAuth.reload() - - // migrationStep intentionally excluded — see method comment above. - - persistentNotification.reload() - persistentNotificationLastBGTime.reload() - - lastLoopingChecked.reload() - lastBGChecked.reload() - lastLoopTime.reload() - - homePosition.reload() - alarmsPosition.reload() - snoozerPosition.reload() - nightscoutPosition.reload() - remotePosition.reload() - statisticsPosition.reload() - treatmentsPosition.reload() - - loopAPNSQrCodeURL.reload() - bolusIncrementDetected.reload() - remoteBolusHistory.reload() - remoteMealHistory.reload() - showGMI.reload() - showStdDev.reload() - showTITR.reload() - timeInRangeModeRaw.reload() - } - // MARK: - Tab Position Helpers /// Get the position for a given tab item diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 71e357281..34c4203cd 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -228,9 +228,9 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio // when runMigrationsIfNeeded() is called. This catches migrations deferred by a // background BGAppRefreshTask launch in Before-First-Unlock state. notificationCenter.addObserver(self, selector: #selector(appDidBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil) - // Posted by AppDelegate after Storage.reloadAll has refreshed every StorageValue - // following a BFU launch. If we're alive when this fires, our scheduled tasks - // were set up with BFU defaults (url='') and need to be redone. + // Posted by AppDelegate after BFU recovery. Vestigial with the readiness gate + // (this controller is built only after storage is ready, so it never fires + // while we're alive); retained one release as a safety net. notificationCenter.addObserver(self, selector: #selector(handleBFUReloadCompleted), name: .bfuReloadCompleted, object: nil) #if !targetEnvironment(macCatalyst) @@ -690,8 +690,9 @@ class MainViewController: UIViewController, ChartViewDelegate, UNUserNotificatio } @objc func appCameToForeground() { - // BFU recovery (Storage.reloadAll) is driven by AppDelegate; this controller - // reacts via .bfuReloadCompleted in handleBFUReloadCompleted() above. + // BFU recovery (StorageReadiness.recover) is driven by AppDelegate before this + // controller exists (the readiness gate), so handleBFUReloadCompleted() above + // is a vestigial no-op in the gated flow. // reset screenlock state if needed UIApplication.shared.isIdleTimerDisabled = Storage.shared.screenlockSwitchState.value