diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index d79de7d18..a6fd9f2b9 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -32,7 +32,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } let action = UNNotificationAction(identifier: "OPEN_APP_ACTION", title: "Open App", options: .foreground) - let category = UNNotificationCategory(identifier: "loopfollow.background.alert", actions: [action], intentIdentifiers: [], options: []) + let category = UNNotificationCategory(identifier: BackgroundAlertIdentifier.categoryIdentifier, actions: [action], intentIdentifiers: [], options: []) UNUserNotificationCenter.current().setNotificationCategories([category]) UNUserNotificationCenter.current().delegate = self @@ -47,6 +47,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } BackgroundRefreshManager.shared.register() + + // Detect Before-First-Unlock launch. If protected data is unavailable here, + // StorageValues were cached from encrypted UserDefaults and need a reload + // on the first foreground after the user unlocks. + let bfu = !UIApplication.shared.isProtectedDataAvailable + Storage.shared.needsBFUReload = bfu + LogManager.shared.log(category: .general, message: "BFU check: isProtectedDataAvailable=\(!bfu), needsBFUReload=\(bfu)") + return true } @@ -107,7 +115,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Note: with scene-based lifecycle (iOS 13+), URLs are delivered to // SceneDelegate.scene(_:openURLContexts:) — not here. The scene delegate - // handles loopfollow://la-tap for Live Activity tap navigation. + // handles ://la-tap for Live Activity tap navigation. // MARK: UISceneSession Lifecycle diff --git a/LoopFollow/Application/SceneDelegate.swift b/LoopFollow/Application/SceneDelegate.swift index 3819a7ac6..e702db267 100644 --- a/LoopFollow/Application/SceneDelegate.swift +++ b/LoopFollow/Application/SceneDelegate.swift @@ -35,7 +35,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } func scene(_: UIScene, openURLContexts URLContexts: Set) { - guard URLContexts.contains(where: { $0.url.scheme == "loopfollow" && $0.url.host == "la-tap" }) else { return } + guard URLContexts.contains(where: { $0.url.scheme == AppGroupID.urlScheme && $0.url.host == "la-tap" }) else { return } // scene(_:openURLContexts:) fires after sceneDidBecomeActive when the app // foregrounds from background. Post on the next run loop so the view // hierarchy (including any presented modals) is fully settled. diff --git a/LoopFollow/Controllers/BackgroundAlertManager.swift b/LoopFollow/Controllers/BackgroundAlertManager.swift index d8a80b8f1..0ba3664b1 100644 --- a/LoopFollow/Controllers/BackgroundAlertManager.swift +++ b/LoopFollow/Controllers/BackgroundAlertManager.swift @@ -11,11 +11,24 @@ enum BackgroundAlertDuration: TimeInterval, CaseIterable { case eighteenMinutes = 1080 // 18 minutes in seconds } -/// Enum representing unique identifiers for each background alert. -enum BackgroundAlertIdentifier: String, CaseIterable { - case sixMin = "loopfollow.background.alert.6min" - case twelveMin = "loopfollow.background.alert.12min" - case eighteenMin = "loopfollow.background.alert.18min" +/// Unique identifiers for each background alert, scoped to the current bundle +/// so multiple LoopFollow instances don't interfere with each other's notifications. +enum BackgroundAlertIdentifier: CaseIterable { + case sixMin + case twelveMin + case eighteenMin + + private static let prefix = Bundle.main.bundleIdentifier ?? "loopfollow" + + var rawValue: String { + switch self { + case .sixMin: "\(Self.prefix).background.alert.6min" + case .twelveMin: "\(Self.prefix).background.alert.12min" + case .eighteenMin: "\(Self.prefix).background.alert.18min" + } + } + + static let categoryIdentifier = "\(prefix).background.alert" } class BackgroundAlertManager { @@ -118,7 +131,7 @@ class BackgroundAlertManager { content.title = title content.body = body content.sound = .defaultCritical - content.categoryIdentifier = "loopfollow.background.alert" + content.categoryIdentifier = BackgroundAlertIdentifier.categoryIdentifier return content } diff --git a/LoopFollow/Helpers/BackgroundRefreshManager.swift b/LoopFollow/Helpers/BackgroundRefreshManager.swift index bac7e1c8e..a1168174d 100644 --- a/LoopFollow/Helpers/BackgroundRefreshManager.swift +++ b/LoopFollow/Helpers/BackgroundRefreshManager.swift @@ -8,7 +8,7 @@ class BackgroundRefreshManager { static let shared = BackgroundRefreshManager() private init() {} - private let taskIdentifier = "com.loopfollow.audiorefresh" + private let taskIdentifier = "\(Bundle.main.bundleIdentifier ?? "com.loopfollow").audiorefresh" func register() { BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: nil) { task in diff --git a/LoopFollow/Info.plist b/LoopFollow/Info.plist index 5cc7f4146..9e0f99340 100644 --- a/LoopFollow/Info.plist +++ b/LoopFollow/Info.plist @@ -7,7 +7,7 @@ BGTaskSchedulerPermittedIdentifiers com.$(unique_id).LoopFollow$(app_suffix) - com.loopfollow.audiorefresh + com.$(unique_id).LoopFollow$(app_suffix).audiorefresh CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) @@ -34,7 +34,7 @@ CFBundleURLSchemes - loopfollow + loopfollow$(app_suffix) diff --git a/LoopFollow/LiveActivity/AppGroupID.swift b/LoopFollow/LiveActivity/AppGroupID.swift index 6fc2bb9a6..5eb1187b8 100644 --- a/LoopFollow/LiveActivity/AppGroupID.swift +++ b/LoopFollow/LiveActivity/AppGroupID.swift @@ -20,19 +20,33 @@ enum AppGroupID { /// to force a shared base bundle id (recommended for reliability). private static let baseBundleIDPlistKey = "LFAppGroupBaseBundleID" - static func current() -> String { + /// The base bundle identifier for the main app, with extension suffixes stripped. + /// Usable from both the main app and extensions. + static var baseBundleID: String { if let base = Bundle.main.object(forInfoDictionaryKey: baseBundleIDPlistKey) as? String, !base.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return "group.\(base)" + return base } - let bundleID = Bundle.main.bundleIdentifier ?? "unknown" + return stripLikelyExtensionSuffixes(from: bundleID) + } - // Heuristic: strip common extension suffixes so the extension can land on the main app’s group id. - let base = stripLikelyExtensionSuffixes(from: bundleID) + /// URL scheme derived from the bundle identifier. Works across app and extensions. + /// Default build: "loopfollow", second: "loopfollow2", third: "loopfollow3", etc. + static var urlScheme: String { + let base = baseBundleID + // Extract the suffix after "LoopFollow" in the bundle ID + // e.g. "com.TEAM.LoopFollow2" → "2", "com.TEAM.LoopFollow" → "" + if let range = base.range(of: "LoopFollow", options: .backwards) { + let suffix = base[range.upperBound...] + return "loopfollow\(suffix)" + } + return "loopfollow" + } - return "group.\(base)" + static func current() -> String { + "group.\(baseBundleID)" } private static func stripLikelyExtensionSuffixes(from bundleID: String) -> String { diff --git a/LoopFollow/LiveActivity/LAAppGroupSettings.swift b/LoopFollow/LiveActivity/LAAppGroupSettings.swift index 8fedeb155..b61487f27 100644 --- a/LoopFollow/LiveActivity/LAAppGroupSettings.swift +++ b/LoopFollow/LiveActivity/LAAppGroupSettings.swift @@ -135,6 +135,8 @@ enum LAAppGroupSettings { static let lowLineMgdl = "la.lowLine.mgdl" static let highLineMgdl = "la.highLine.mgdl" static let slots = "la.slots" + static let displayName = "la.displayName" + static let showDisplayName = "la.showDisplayName" } private static var defaults: UserDefaults? { @@ -176,4 +178,19 @@ enum LAAppGroupSettings { } return raw.map { LiveActivitySlotOption(rawValue: $0) ?? .none } } + + // MARK: - Display Name + + static func setDisplayName(_ name: String, show: Bool) { + defaults?.set(name, forKey: Keys.displayName) + defaults?.set(show, forKey: Keys.showDisplayName) + } + + static func displayName() -> String { + defaults?.string(forKey: Keys.displayName) ?? "LoopFollow" + } + + static func showDisplayName() -> Bool { + defaults?.bool(forKey: Keys.showDisplayName) ?? false + } } diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 9faa8a41e..746e5609d 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -310,6 +310,10 @@ final class LiveActivityManager { lowMgdl: Storage.shared.lowLine.value, highMgdl: Storage.shared.highLine.value, ) + LAAppGroupSettings.setDisplayName( + Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "LoopFollow", + show: Storage.shared.showDisplayName.value + ) GlucoseSnapshotStore.shared.save(snapshot) } startIfNeeded() @@ -322,7 +326,7 @@ final class LiveActivityManager { self?.performRefresh(reason: reason) } refreshWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + 5.0, execute: workItem) + DispatchQueue.main.asyncAfter(deadline: .now() + 20.0, execute: workItem) } // MARK: - Renewal @@ -557,6 +561,8 @@ final class LiveActivityManager { // MARK: - Renewal Notifications + private static let renewalNotificationID = "\(Bundle.main.bundleIdentifier ?? "loopfollow").la.renewal.failed" + private func scheduleRenewalFailedNotification() { let content = UNMutableNotificationContent() content.title = "Live Activity Expiring" @@ -564,7 +570,7 @@ final class LiveActivityManager { content.sound = .default let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) let request = UNNotificationRequest( - identifier: "loopfollow.la.renewal.failed", + identifier: LiveActivityManager.renewalNotificationID, content: content, trigger: trigger, ) @@ -577,7 +583,7 @@ final class LiveActivityManager { } private func cancelRenewalFailedNotification() { - let id = "loopfollow.la.renewal.failed" + let id = LiveActivityManager.renewalNotificationID UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [id]) UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [id]) } diff --git a/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift index cb1f84d18..00740e10e 100644 --- a/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift +++ b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift @@ -15,7 +15,7 @@ struct RestartLiveActivityIntent: AppIntent { let apnsKey = Storage.shared.lfApnsKey.value if keyId.isEmpty || apnsKey.isEmpty { - if let url = URL(string: "loopfollow://settings/live-activity") { + if let url = URL(string: "\(AppGroupID.urlScheme)://settings/live-activity") { await MainActor.run { UIApplication.shared.open(url) } } return .result(dialog: "Please enter your APNs credentials in LoopFollow settings to use the Live Activity.") diff --git a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift index 90e74f5b8..b1a416b97 100644 --- a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift +++ b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift @@ -1,5 +1,5 @@ +// LoopFollow // StorageCurrentGlucoseStateProvider.swift -// 2026-03-21 import Foundation @@ -7,7 +7,6 @@ import Foundation /// This is the only file in the pipeline that is allowed to touch Storage.shared /// or Observable.shared — all other layers read exclusively from this provider. struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding { - // MARK: - Core Glucose var glucoseMgdl: Double? { diff --git a/LoopFollow/Storage/Framework/StorageValue.swift b/LoopFollow/Storage/Framework/StorageValue.swift index f27f49b17..86faa34da 100644 --- a/LoopFollow/Storage/Framework/StorageValue.swift +++ b/LoopFollow/Storage/Framework/StorageValue.swift @@ -40,4 +40,16 @@ class StorageValue: ObservableObject { func remove() { StorageValue.defaults.removeObject(forKey: key) } + + /// Re-reads the value from UserDefaults, updating the @Published cache. + /// Call this when the app foregrounds after a Before-First-Unlock background launch, + /// where StorageValue was initialized while UserDefaults was locked (returning defaults). + func reload() { + if let data = StorageValue.defaults.data(forKey: key), + let decodedValue = try? JSONDecoder().decode(T.self, from: data), + decodedValue != value + { + value = decodedValue + } + } } diff --git a/LoopFollow/Storage/Storage+Migrate.swift b/LoopFollow/Storage/Storage+Migrate.swift index aa0868543..70cd30dc1 100644 --- a/LoopFollow/Storage/Storage+Migrate.swift +++ b/LoopFollow/Storage/Storage+Migrate.swift @@ -2,6 +2,7 @@ // Storage+Migrate.swift import Foundation +import UserNotifications extension Storage { func migrateStep5() { @@ -32,6 +33,21 @@ extension Storage { } } + func migrateStep7() { + // Cancel notifications scheduled with old hardcoded identifiers. + // Replaced with bundle-ID-scoped identifiers for multi-instance support. + LogManager.shared.log(category: .general, message: "Running migrateStep7 — cancel legacy notification identifiers") + + let legacyNotificationIDs = [ + "loopfollow.background.alert.6min", + "loopfollow.background.alert.12min", + "loopfollow.background.alert.18min", + "loopfollow.la.renewal.failed", + ] + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: legacyNotificationIDs) + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: legacyNotificationIDs) + } + func migrateStep6() { // APNs credential separation LogManager.shared.log(category: .general, message: "Running migrateStep6 — APNs credential separation") @@ -125,10 +141,21 @@ extension Storage { } func migrateStep1() { - Storage.shared.url.value = ObservableUserDefaults.shared.old_url.value - Storage.shared.device.value = ObservableUserDefaults.shared.old_device.value - Storage.shared.nsWriteAuth.value = ObservableUserDefaults.shared.old_nsWriteAuth.value - Storage.shared.nsAdminAuth.value = ObservableUserDefaults.shared.old_nsAdminAuth.value + // Guard each field with .exists so that if the App Group suite is unreadable + // (e.g. Before-First-Unlock state after a reboot), we skip the write rather + // than overwriting the already-migrated Standard value with an empty default. + if ObservableUserDefaults.shared.old_url.exists { + Storage.shared.url.value = ObservableUserDefaults.shared.old_url.value + } + if ObservableUserDefaults.shared.old_device.exists { + Storage.shared.device.value = ObservableUserDefaults.shared.old_device.value + } + if ObservableUserDefaults.shared.old_nsWriteAuth.exists { + Storage.shared.nsWriteAuth.value = ObservableUserDefaults.shared.old_nsWriteAuth.value + } + if ObservableUserDefaults.shared.old_nsAdminAuth.exists { + Storage.shared.nsAdminAuth.value = ObservableUserDefaults.shared.old_nsAdminAuth.value + } // Helper: 1-to-1 type ----------------------------------------------------------------- func move( diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index efb55b031..6e24b3788 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -216,6 +216,191 @@ 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 6 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() + + showDots.reload() + showLines.reload() + showValues.reload() + showAbsorption.reload() + showDIALines.reload() + show30MinLine.reload() + show90MinLine.reload() + showMidnightLines.reload() + smallGraphTreatments.reload() + smallGraphHeight.reload() + predictionToLoad.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() + + homePosition.reload() + alarmsPosition.reload() + snoozerPosition.reload() + nightscoutPosition.reload() + remotePosition.reload() + statisticsPosition.reload() + treatmentsPosition.reload() + + loopAPNSQrCodeURL.reload() + bolusIncrementDetected.reload() + showGMI.reload() + showStdDev.reload() + showTITR.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 bbf2de63c..1a3b7c03d 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -142,40 +142,12 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele loadDebugData() - // Capture before migrations run: true for existing users, false for fresh installs. - let isExistingUser = Storage.shared.migrationStep.exists - - if Storage.shared.migrationStep.value < 1 { - Storage.shared.migrateStep1() - Storage.shared.migrationStep.value = 1 - } - - if Storage.shared.migrationStep.value < 2 { - Storage.shared.migrateStep2() - Storage.shared.migrationStep.value = 2 - } - - if Storage.shared.migrationStep.value < 3 { - Storage.shared.migrateStep3() - Storage.shared.migrationStep.value = 3 - } - - // TODO: This migration step can be deleted in March 2027. Check the commit for other places to cleanup. - if Storage.shared.migrationStep.value < 4 { - // Existing users need to see the fat/protein order change banner. - // New users never saw the old order, so mark it as already seen. - Storage.shared.hasSeenFatProteinOrderChange.value = !isExistingUser - Storage.shared.migrationStep.value = 4 - } - - if Storage.shared.migrationStep.value < 5 { - Storage.shared.migrateStep5() - Storage.shared.migrationStep.value = 5 - } + // Migrations run in foreground only — see runMigrationsIfNeeded() for details. + runMigrationsIfNeeded() - if Storage.shared.migrationStep.value < 6 { - Storage.shared.migrateStep6() - Storage.shared.migrationStep.value = 6 + if Storage.shared.migrationStep.value < 7 { + Storage.shared.migrateStep7() + Storage.shared.migrationStep.value = 7 } // Synchronize info types to ensure arrays are the correct size @@ -211,6 +183,10 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele let notificationCenter = NotificationCenter.default notificationCenter.addObserver(self, selector: #selector(appMovedToBackground), name: UIApplication.didEnterBackgroundNotification, object: nil) notificationCenter.addObserver(self, selector: #selector(appCameToForeground), name: UIApplication.willEnterForegroundNotification, object: nil) + // didBecomeActive is used (not willEnterForeground) to ensure applicationState == .active + // 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) notificationCenter.addObserver(self, selector: #selector(navigateOnLAForeground), name: .liveActivityDidForeground, object: nil) // Setup the Graph @@ -972,7 +948,79 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } } + // Migrations must only run when UserDefaults is accessible (i.e. after first unlock). + // When the app is launched in the background by BGAppRefreshTask immediately after a + // reboot, the device may be in Before-First-Unlock (BFU) state: UserDefaults files are + // still encrypted, so every read returns the default value (0 / ""). Running migrations + // in that state would overwrite real settings with empty strings. + // + // Strategy: skip migrations if applicationState == .background; call this method again + // from appCameToForeground() so they run on the first foreground after a BFU launch. + func runMigrationsIfNeeded() { + guard UIApplication.shared.applicationState != .background else { return } + + // Capture before migrations run: true for existing users, false for fresh installs. + let isExistingUser = Storage.shared.migrationStep.exists + + if Storage.shared.migrationStep.value < 1 { + Storage.shared.migrateStep1() + Storage.shared.migrationStep.value = 1 + } + if Storage.shared.migrationStep.value < 2 { + Storage.shared.migrateStep2() + Storage.shared.migrationStep.value = 2 + } + if Storage.shared.migrationStep.value < 3 { + Storage.shared.migrateStep3() + Storage.shared.migrationStep.value = 3 + } + // TODO: This migration step can be deleted in March 2027. Check the commit for other places to cleanup. + if Storage.shared.migrationStep.value < 4 { + // Existing users need to see the fat/protein order change banner. + // New users never saw the old order, so mark it as already seen. + Storage.shared.hasSeenFatProteinOrderChange.value = !isExistingUser + Storage.shared.migrationStep.value = 4 + } + if Storage.shared.migrationStep.value < 5 { + Storage.shared.migrateStep5() + Storage.shared.migrationStep.value = 5 + } + if Storage.shared.migrationStep.value < 6 { + Storage.shared.migrateStep6() + Storage.shared.migrationStep.value = 6 + } + } + + @objc func appDidBecomeActive() { + // applicationState == .active is guaranteed here, so the BFU guard in + // runMigrationsIfNeeded() will always pass. Catches the case where viewDidLoad + // ran during a BGAppRefreshTask background launch and deferred migrations. + runMigrationsIfNeeded() + } + @objc func appCameToForeground() { + // If the app was cold-launched in Before-First-Unlock state (e.g. by BGAppRefreshTask + // after a reboot), all StorageValues were cached from encrypted UserDefaults and hold + // their defaults. Reload everything from disk now that the device is unlocked, firing + // Combine observers only for values that actually changed. + LogManager.shared.log(category: .general, message: "appCameToForeground: needsBFUReload=\(Storage.shared.needsBFUReload), url='\(Storage.shared.url.value)'") + if Storage.shared.needsBFUReload { + 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)'") + // Show the loading overlay so the user sees feedback during the 2-5s + // while tasks re-run with the now-correct credentials. + loadingStates = ["bg": false, "profile": false, "deviceStatus": false] + isInitialLoad = true + setupLoadingState() + showLoadingOverlay() + // Tasks were scheduled during BFU viewDidLoad with url="" — they fired, found no + // data source, and rescheduled themselves 60s out. Reset them now so they run + // within their normal 2-5s initial delay using the now-correct credentials. + scheduleAllTasks() + } + // reset screenlock state if needed UIApplication.shared.isIdleTimerDisabled = Storage.shared.screenlockSwitchState.value diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift index d98475b8e..fa75e44e4 100644 --- a/LoopFollowLAExtension/LoopFollowLABundle.swift +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -1,6 +1,3 @@ -// LoopFollow -// LoopFollowLABundle.swift - import SwiftUI import WidgetKit @@ -8,8 +5,5 @@ import WidgetKit struct LoopFollowLABundle: WidgetBundle { var body: some Widget { LoopFollowLiveActivityWidget() - if #available(iOS 18.0, *) { - LoopFollowLiveActivityWidgetWithCarPlay() - } } -} +} \ No newline at end of file diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 5c28eed3b..30f4e4589 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -5,25 +5,25 @@ import ActivityKit import SwiftUI import WidgetKit -/// Builds the shared Dynamic Island content used by both widget variants. +/// Builds the shared Dynamic Island content used by the Live Activity widget. private func makeDynamicIsland(context: ActivityViewContext) -> DynamicIsland { DynamicIsland { DynamicIslandExpandedRegion(.leading) { - Link(destination: URL(string: "loopfollow://la-tap")!) { + Link(destination: URL(string: "\(AppGroupID.urlScheme)://la-tap")!) { DynamicIslandLeadingView(snapshot: context.state.snapshot) .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) } .id(context.state.seq) } DynamicIslandExpandedRegion(.trailing) { - Link(destination: URL(string: "loopfollow://la-tap")!) { + Link(destination: URL(string: "\(AppGroupID.urlScheme)://la-tap")!) { DynamicIslandTrailingView(snapshot: context.state.snapshot) .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay)) } .id(context.state.seq) } DynamicIslandExpandedRegion(.bottom) { - Link(destination: URL(string: "loopfollow://la-tap")!) { + Link(destination: URL(string: "\(AppGroupID.urlScheme)://la-tap")!) { DynamicIslandBottomView(snapshot: context.state.snapshot) .overlay(RenewalOverlayView(show: context.state.snapshot.showRenewalOverlay, showText: true)) } @@ -42,34 +42,37 @@ private func makeDynamicIsland(context: ActivityViewContext some View { if #available(iOS 17.0, *) { - // Use the generic SwiftUI API available in iOS 17+ (no placement enum) contentMargins(Edge.Set.all, 0) } else { self @@ -90,60 +92,72 @@ private extension View { // MARK: - Family-adaptive wrapper (Lock Screen / CarPlay / Watch Smart Stack) /// Reads the activityFamily environment value and routes to the appropriate layout. -/// - `.small` → CarPlay Dashboard & Watch Smart Stack: compact glucose-only view -/// - everything else → full lock screen layout with configurable grid +/// - `.small` → CarPlay Dashboard & Watch Smart Stack +/// - everything else → full lock screen layout @available(iOS 18.0, *) private struct LockScreenFamilyAdaptiveView: View { let state: GlucoseLiveActivityAttributes.ContentState - @Environment(\.activityFamily) var activityFamily + @Environment(\.activityFamily) private var activityFamily var body: some View { if activityFamily == .small { SmallFamilyView(snapshot: state.snapshot) + .activityBackgroundTint(Color.black.opacity(0.25)) } else { LockScreenLiveActivityView(state: state) - .activitySystemActionForegroundColor(.white) .activityBackgroundTint(LAColors.backgroundTint(for: state.snapshot)) - .applyActivityContentMarginsFixIfAvailable() - .widgetURL(URL(string: "loopfollow://la-tap")!) } } } // MARK: - Small family view (CarPlay Dashboard + Watch Smart Stack) - -/// Compact view shown on CarPlay Dashboard (iOS 26+) and Apple Watch Smart Stack (watchOS 11+). -/// Hardcoded to glucose + trend arrow + delta + time since last reading. @available(iOS 18.0, *) private struct SmallFamilyView: View { let snapshot: GlucoseSnapshot + private var unitLabel: String { + switch snapshot.unit { + case .mgdl: return "mg/dL" + case .mmol: return "mmol/L" + } + } + var body: some View { - VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .firstTextBaseline, spacing: 6) { - Text(LAFormat.glucose(snapshot)) - .font(.system(size: 28, weight: .bold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.white) - Text(LAFormat.trendArrow(snapshot)) - .font(.system(size: 22, weight: .semibold, design: .rounded)) - .foregroundStyle(.white.opacity(0.9)) - } - HStack(spacing: 8) { - Text(LAFormat.delta(snapshot)) + HStack(alignment: .center, spacing: 0) { + VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(LAFormat.glucose(snapshot)) + .font(.system(size: 28, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(LAColors.keyline(for: snapshot)) + + Text(LAFormat.trendArrow(snapshot)) + .font(.system(size: 22, weight: .semibold, design: .rounded)) + .foregroundStyle(LAColors.keyline(for: snapshot)) + } + + Text("\(LAFormat.delta(snapshot)) \(unitLabel)") .font(.system(size: 14, weight: .semibold, design: .rounded)) .monospacedDigit() .foregroundStyle(.white.opacity(0.85)) - Text(LAFormat.updated(snapshot)) - .font(.system(size: 14, weight: .regular, design: .rounded)) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + Text(LAFormat.projected(snapshot)) + .font(.system(size: 28, weight: .bold, design: .rounded)) .monospacedDigit() + .foregroundStyle(.white) + + Text(unitLabel) + .font(.system(size: 14, weight: .regular, design: .rounded)) .foregroundStyle(.white.opacity(0.65)) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) .padding(10) - .activityBackgroundTint(LAColors.backgroundTint(for: snapshot)) } } @@ -158,7 +172,6 @@ private struct LockScreenLiveActivityView: View { VStack(spacing: 6) { HStack(spacing: 12) { - // LEFT: Glucose + trend arrow, delta below VStack(alignment: .leading, spacing: 4) { HStack(alignment: .firstTextBaseline, spacing: 4) { Text(LAFormat.glucose(s)) @@ -186,13 +199,11 @@ private struct LockScreenLiveActivityView: View { .frame(minWidth: 168, maxWidth: 190, alignment: .leading) .layoutPriority(2) - // Divider Rectangle() .fill(Color.white.opacity(0.20)) .frame(width: 1) .padding(.vertical, 8) - // RIGHT: configurable 2×2 grid VStack(spacing: 8) { HStack(spacing: 12) { SlotView(option: slotConfig[0], snapshot: s) @@ -206,8 +217,9 @@ private struct LockScreenLiveActivityView: View { .frame(maxWidth: .infinity, alignment: .trailing) } - // Footer: last update time - Text("Last Update: \(LAFormat.updated(s))") + Text(LAAppGroupSettings.showDisplayName() + ? "\(LAAppGroupSettings.displayName()) — \(LAFormat.updated(s))" + : "Last Update: \(LAFormat.updated(s))") .font(.system(size: 11, weight: .regular, design: .rounded)) .monospacedDigit() .foregroundStyle(.white.opacity(0.65)) @@ -219,7 +231,7 @@ private struct LockScreenLiveActivityView: View { .padding(.bottom, 8) .overlay( RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(Color.white.opacity(0.20), lineWidth: 1), + .stroke(Color.white.opacity(0.20), lineWidth: 1) ) .overlay( Group { @@ -227,23 +239,25 @@ private struct LockScreenLiveActivityView: View { ZStack { RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(Color(uiColor: UIColor.systemRed).opacity(0.85)) + Text("Not Looping") .font(.system(size: 20, weight: .heavy, design: .rounded)) .foregroundStyle(.white) .tracking(1.5) } } - }, + } ) .overlay( ZStack { RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(Color.gray.opacity(0.9)) + Text("Tap to update") .font(.system(size: 20, weight: .semibold)) .foregroundStyle(.white) } - .opacity(state.snapshot.showRenewalOverlay ? 1 : 0), + .opacity(state.snapshot.showRenewalOverlay ? 1 : 0) ) } } @@ -284,19 +298,16 @@ private struct MetricBlock: View { .lineLimit(1) .minimumScaleFactor(0.85) } - .frame(width: 60, alignment: .leading) // slightly tighter columns to free space for glucose + .frame(width: 60, alignment: .leading) } } -/// Renders one configurable slot in the lock screen 2×2 grid. -/// Shows nothing (invisible placeholder) when the slot option is `.none`. private struct SlotView: View { let option: LiveActivitySlotOption let snapshot: GlucoseSnapshot var body: some View { if option == .none { - // Invisible spacer — preserves grid alignment Color.clear .frame(width: 60, height: 36) } else { @@ -336,6 +347,7 @@ private struct SlotView: View { private struct DynamicIslandLeadingView: View { let snapshot: GlucoseSnapshot + var body: some View { if snapshot.isNotLooping { Text("⚠️ Not Looping") @@ -350,14 +362,17 @@ private struct DynamicIslandLeadingView: View { .font(.system(size: 28, weight: .bold, design: .rounded)) .monospacedDigit() .foregroundStyle(.white) + HStack(spacing: 5) { Text(LAFormat.trendArrow(snapshot)) .font(.system(size: 13, weight: .semibold, design: .rounded)) .foregroundStyle(.white.opacity(0.9)) + Text(LAFormat.delta(snapshot)) .font(.system(size: 13, weight: .semibold, design: .rounded)) .monospacedDigit() .foregroundStyle(.white.opacity(0.9)) + Text("Proj: \(LAFormat.projected(snapshot))") .font(.system(size: 13, weight: .semibold, design: .rounded)) .monospacedDigit() @@ -370,6 +385,7 @@ private struct DynamicIslandLeadingView: View { private struct DynamicIslandTrailingView: View { let snapshot: GlucoseSnapshot + var body: some View { if snapshot.isNotLooping { EmptyView() @@ -379,6 +395,7 @@ private struct DynamicIslandTrailingView: View { .font(.system(size: 13, weight: .bold, design: .rounded)) .monospacedDigit() .foregroundStyle(.white.opacity(0.95)) + Text("COB \(LAFormat.cob(snapshot))") .font(.system(size: 13, weight: .bold, design: .rounded)) .monospacedDigit() @@ -391,6 +408,7 @@ private struct DynamicIslandTrailingView: View { private struct DynamicIslandBottomView: View { let snapshot: GlucoseSnapshot + var body: some View { if snapshot.isNotLooping { Text("Loop has not reported in 15+ minutes") @@ -410,6 +428,7 @@ private struct DynamicIslandBottomView: View { private struct DynamicIslandCompactTrailingView: View { let snapshot: GlucoseSnapshot + var body: some View { if snapshot.isNotLooping { Text("Not Looping") @@ -428,6 +447,7 @@ private struct DynamicIslandCompactTrailingView: View { private struct DynamicIslandCompactLeadingView: View { let snapshot: GlucoseSnapshot + var body: some View { if snapshot.isNotLooping { Text("⚠️") @@ -443,6 +463,7 @@ private struct DynamicIslandCompactLeadingView: View { private struct DynamicIslandMinimalView: View { let snapshot: GlucoseSnapshot + var body: some View { if snapshot.isNotLooping { Text("⚠️") @@ -459,8 +480,6 @@ private struct DynamicIslandMinimalView: View { // MARK: - Formatting private enum LAFormat { - // MARK: - NumberFormatters (locale-aware) - private static let mgdlFormatter: NumberFormatter = { let nf = NumberFormatter() nf.numberStyle = .decimal @@ -488,8 +507,6 @@ private enum LAFormat { } } - // MARK: Glucose - static func glucose(_ s: GlucoseSnapshot) -> String { formatGlucoseValue(s.glucose, unit: s.unit) } @@ -500,7 +517,6 @@ private enum LAFormat { let v = Int(round(s.delta)) if v == 0 { return "0" } return v > 0 ? "+\(v)" : "\(v)" - case .mmol: let mmol = GlucoseConversion.toMmol(s.delta) let d = (abs(mmol) < 0.05) ? 0.0 : mmol @@ -510,8 +526,6 @@ private enum LAFormat { } } - // MARK: Trend - static func trendArrow(_ s: GlucoseSnapshot) -> String { switch s.trend { case .upFast: "↑↑" @@ -525,8 +539,6 @@ private enum LAFormat { } } - // MARK: Secondary - static func iob(_ s: GlucoseSnapshot) -> String { guard let v = s.iob else { return "—" } return String(format: "%.1f", v) @@ -542,8 +554,6 @@ private enum LAFormat { return formatGlucoseValue(v, unit: s.unit) } - // MARK: Extended InfoType formatters - private static let ageFormatter: DateComponentsFormatter = { let f = DateComponentsFormatter() f.unitsStyle = .positional @@ -552,7 +562,6 @@ private enum LAFormat { return f }() - /// Formats an insert-time epoch into "D:HH" age string. Returns "—" if time is 0. static func age(insertTime: TimeInterval) -> String { guard insertTime > 0 else { return "—" } let secondsAgo = Date().timeIntervalSince1970 - insertTime @@ -630,13 +639,11 @@ private enum LAFormat { s.profileName ?? "—" } - // MARK: Update time - private static let hhmmFormatter: DateFormatter = { let df = DateFormatter() df.locale = .current df.timeZone = .current - df.dateFormat = "HH:mm" // 24h format + df.dateFormat = "HH:mm" return df }() @@ -657,12 +664,11 @@ private enum LAFormat { } } -// MARK: - Threshold-driven colors (Option A, App Group-backed) +// MARK: - Threshold-driven colors private enum LAColors { static func backgroundTint(for snapshot: GlucoseSnapshot) -> Color { let mgdl = snapshot.glucose - let t = LAAppGroupSettings.thresholdsMgdl() let low = t.low let high = t.high @@ -671,12 +677,10 @@ private enum LAColors { let raw = 0.48 + (0.85 - 0.48) * ((low - mgdl) / (low - 54.0)) let opacity = min(max(raw, 0.48), 0.85) return Color(uiColor: UIColor.systemRed).opacity(opacity) - } else if mgdl > high { let raw = 0.44 + (0.85 - 0.44) * ((mgdl - high) / (324.0 - high)) let opacity = min(max(raw, 0.44), 0.85) return Color(uiColor: UIColor.systemOrange).opacity(opacity) - } else { return Color(uiColor: UIColor.systemGreen).opacity(0.36) } @@ -684,7 +688,6 @@ private enum LAColors { static func keyline(for snapshot: GlucoseSnapshot) -> Color { let mgdl = snapshot.glucose - let t = LAAppGroupSettings.thresholdsMgdl() let low = t.low let high = t.high @@ -697,4 +700,4 @@ private enum LAColors { return Color(uiColor: UIColor.systemGreen) } } -} +} \ No newline at end of file diff --git a/docs/LiveActivity.md b/docs/LiveActivity.md deleted file mode 100644 index 979213a96..000000000 --- a/docs/LiveActivity.md +++ /dev/null @@ -1,165 +0,0 @@ -# LoopFollow Live Activity — Architecture & Design Decisions - -**Author:** Philippe Achkar (supported by Claude) -**Date:** 2026-03-07 - ---- - -## What Is the Live Activity? - -The Live Activity displays real-time glucose data on the iPhone lock screen and in the Dynamic Island. It shows: - -- Current glucose value (mg/dL or mmol/L) -- Trend arrow and delta -- IOB, COB, and projected glucose (when available) -- Threshold-driven background color (red (low) / green (in-range) / orange (high)) with user-set thresholds -- A "Not Looping" overlay when Loop has not reported in 15+ minutes - -It updates every 5 minutes, driven by LoopFollow's existing refresh engine. No separate data pipeline exists — the Live Activity is a rendering surface only. - ---- - -## Core Principles - -### 1. Single Source of Truth - -The Live Activity never fetches data directly from Nightscout or Dexcom. It reads exclusively from LoopFollow's internal storage layer (`Storage.shared`, `Observable.shared`). All glucose values, thresholds, IOB, COB, and loop status flow through the same path as the rest of the app. - -This means: -- No duplicated business logic -- No risk of the Live Activity showing different data than the app -- The architecture is reusable for Apple Watch and CarPlay in future phases - -### 2. Source-Agnostic Design - -LoopFollow supports both Nightscout and Dexcom. IOB, COB, or predicted glucose are modeled as optional (`Double?`) in `GlucoseSnapshot` and the UI renders a dash (—) when they are absent. The Live Activity never assumes these values exist. - -### 3. No Hardcoded Identifiers - -The App Group ID is derived dynamically at runtime: group.. No team-specific bundle IDs or App Group IDs are hardcoded anywhere. This ensures the project is safe to fork, clone, and submit as a pull request by any contributor. - ---- - -## Update Architecture — Why APNs Self-Push? - -This is the most important architectural decision in Phase 1. Understanding it will help you maintain and extend this feature correctly. - -### What We Tried First — Direct ``activity.update()`` - -The obvious approach to updating a Live Activity is to call ``activity.update()`` directly from the app. This works reliably when the app is in the foreground. - -The problem appears when the app is in the background. LoopFollow uses a background audio session (`.playback` category, silent WAV file) to stay alive in the background and continue fetching glucose data. We discovered that _liveactivitiesd_ (the iOS system daemon responsible for rendering Live Activities) refuses to process ``activity.update()`` calls from processes that hold an active background audio session. The update call either hangs indefinitely or is silently dropped. The Live Activity freezes on the lock screen while the app continues running normally. - -We attempted several workarounds; none of these approaches were reliable or production-safe: -- Call ``activity.update()`` while audio is playing | Updates hang or are dropped -- Pause the audio player before updating | Insufficient — iOS checks the process-level audio assertion, not just the player state -- Call `AVAudioSession.setActive(false)` before updating | Intermittently worked, but introduced a race condition and broke the audio session unpredictably -- Add a fixed 3-second wait after deactivation | Fragile, caused background task timeout warnings, and still failed intermittently - -### The Solution — APNs Self-Push - -Our solution is for LoopFollow to send an APNs (Apple Push Notification service) push notification to itself. - -Here is how it works: - -1. When a Live Activity is started, ActivityKit provides a **push token** — a unique identifier for that specific Live Activity instance. -2. LoopFollow captures this token via `activity.pushTokenUpdates`. -3. After each BG refresh, LoopFollow generates a signed JWT using its APNs authentication key and posts an HTTP/2 request directly to Apple's APNs servers. -4. Apple's APNs infrastructure delivers the push to `liveactivitiesd` on the device. -5. `liveactivitiesd` updates the Live Activity directly — the app process is **never involved in the rendering path**. - -Because `liveactivitiesd` receives the update via APNs rather than via an inter-process call from LoopFollow, it does not care that LoopFollow holds a background audio session. The update is processed reliably every time. - -### Why This Is Safe and Appropriate - -- This is an officially supported ActivityKit feature. Apple documents push-token-based Live Activity updates as the **recommended** update mechanism. -- The push is sent from the app itself, to itself. No external server or provider infrastructure is required. -- The APNs authentication key is injected at build time via xcconfig and Info.plist. It is never stored in the repository. -- The JWT is generated on-device using CryptoKit (`P256.Signing`) and cached for 55 minutes (APNs tokens are valid for 60 minutes). - ---- - -## File Map - -### Main App Target - -| File | Responsibility | -|---|---| -| `LiveActivityManager.swift` | Orchestration — start, update, end, bind, observe lifecycle | -| `GlucoseSnapshotBuilder.swift` | Pure data transformation — builds `GlucoseSnapshot` from storage | -| `StorageCurrentGlucoseStateProvider.swift` | Thin abstraction over `Storage.shared` and `Observable.shared` | -| `GlucoseSnapshotStore.swift` | App Group persistence — saves/loads latest snapshot | -| `PreferredGlucoseUnit.swift` | Reads user unit preference, converts mg/dL ↔ mmol/L | -| `APNSClient.swift` | Sends APNs self-push with Live Activity content state | -| `APNSJWTGenerator.swift` | Generates ES256-signed JWT for APNs authentication | - -### Shared (App + Extension) - -| File | Responsibility | -|---|---| -| `GlucoseLiveActivityAttributes.swift` | ActivityKit attributes and content state definition | -| `GlucoseSnapshot.swift` | Canonical cross-platform glucose data struct | -| `GlucoseConversion.swift` | Single source of truth for mg/dL ↔ mmol/L conversion | -| `LAAppGroupSettings.swift` | App Group UserDefaults access | -| `AppGroupID.swift` | Derives App Group ID dynamically from bundle identifier | - -### Extension Target - -| File | Responsibility | -|---|---| -| `LoopFollowLiveActivity.swift` | SwiftUI rendering — lock screen card and Dynamic Island | -| `LoopFollowLABundle.swift` | WidgetBundle entry point | - ---- - -## Update Flow - -``` -LoopFollow BG refresh completes - → Storage.shared updated (glucose, delta, trend, IOB, COB, projected) - → Observable.shared updated (isNotLooping) - → BGData calls LiveActivityManager.refreshFromCurrentState(reason: "bg") - → GlucoseSnapshotBuilder.build() reads from StorageCurrentGlucoseStateProvider - → GlucoseSnapshot constructed (unit-converted, threshold-classified) - → GlucoseSnapshotStore persists snapshot to App Group - → activity.update(content) called (direct update for foreground reliability) - → APNSClient.sendLiveActivityUpdate() sends self-push via APNs - → liveactivitiesd receives push - → Lock screen re-renders -``` - ---- - -## APNs Setup — Required for Contributors - -To build and run the Live Activity locally or via CI, you need an APNs authentication key. The key content is injected at build time via `LoopFollowConfigOverride.xcconfig` and is **never stored in the repository**. - -### What you need - -- An Apple Developer account -- An APNs Auth Key (`.p8` file) with the **Apple Push Notifications service (APNs)** capability enabled -- The 10-character Key ID associated with that key - -### Local Build Setup - -1. Generate or download your `.p8` key from [developer.apple.com](https://developer.apple.com) → Certificates, Identifiers & Profiles → Keys. -2. Open the key file in a text editor. Copy the base64 content between the header and footer lines — **exclude** `-----BEGIN PRIVATE KEY-----` and `-----END PRIVATE KEY-----`. Join all lines into a single unbroken string with no spaces or line breaks. -3. Create or edit `LoopFollowConfigOverride.xcconfig` in the project root (this file is gitignored): - -``` -APNS_KEY_ID = -APNS_KEY_CONTENT = -``` - -4. Build and run. The key is read at runtime from `Info.plist` which resolves `$(APNS_KEY_CONTENT)` from the xcconfig. - -### CI / GitHub Actions Setup - -Add two repository secrets under **Settings → Secrets and variables → Actions**: - -| Secret Name | Value | -|---|---| -| `APNS_KEY_ID` | Your 10-character key ID | -| `APNS_KEY` | Full contents of your `.p8` file including PEM headers | - -The build workflow strips the PEM headers automatically and injects the content into `LoopFollowConfigOverride.xcconfig` before building.