Skip to content
Merged
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
8 changes: 8 additions & 0 deletions LoopFollow.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -584,6 +586,8 @@
DD2C2E502D3B8B0B006413A5 /* NightscoutSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSettingsViewModel.swift; sourceTree = "<group>"; };
DD485F132E454B2600CE8CBF /* SecureMessenger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureMessenger.swift; sourceTree = "<group>"; };
DD4878022C7B297E0048F05C /* StorageValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageValue.swift; sourceTree = "<group>"; };
5106EAD100000000000000A1 /* StorageReadiness.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageReadiness.swift; sourceTree = "<group>"; };
5106EAD100000000000000B1 /* StorageLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageLoadingView.swift; sourceTree = "<group>"; };
DD4878042C7B2C970048F05C /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = "<group>"; };
DD4878072C7B30BF0048F05C /* RemoteSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSettingsView.swift; sourceTree = "<group>"; };
DD4878092C7B30D40048F05C /* RemoteSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSettingsViewModel.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1515,6 +1519,7 @@
children = (
FC9788172485969B00A7906C /* AppDelegate.swift */,
AA1B2C3D4E5F6A7B8C9D0E1F /* LoopFollowApp.swift */,
5106EAD100000000000000B1 /* StorageLoadingView.swift */,
BB2C3D4E5F6A7B8C9D0E1F2A /* MainTabView.swift */,
FC9788272485969C00A7906C /* LaunchScreen.storyboard */,
);
Expand Down Expand Up @@ -1740,6 +1745,7 @@
DDD10F042C529DA200D76A8E /* ObservableValue.swift */,
DD4878022C7B297E0048F05C /* StorageValue.swift */,
DD16AF0C2C98485400FB655A /* SecureStorageValue.swift */,
5106EAD100000000000000A1 /* StorageReadiness.swift */,
);
path = Framework;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down
96 changes: 52 additions & 44 deletions LoopFollow/Application/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
}

Expand Down Expand Up @@ -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")
}

Expand Down
27 changes: 18 additions & 9 deletions LoopFollow/Application/LoopFollowApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}
}
19 changes: 19 additions & 0 deletions LoopFollow/Application/StorageLoadingView.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
26 changes: 21 additions & 5 deletions LoopFollow/BackgroundRefresh/BT/BLEManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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? {
Expand Down
20 changes: 19 additions & 1 deletion LoopFollow/Storage/Framework/SecureStorageValue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,17 @@
import Combine
import Foundation

class SecureStorageValue<T: NSObject & NSSecureCoding & Equatable>: ObservableObject {
class SecureStorageValue<T: NSObject & NSSecureCoding & Equatable>: 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)
}
Expand All @@ -33,9 +38,22 @@ class SecureStorageValue<T: NSObject & NSSecureCoding & Equatable>: 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
}
}
}
Loading
Loading