From 5b2922d97adfad5cb0cd59d2219f4d0747c8418d Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:38:09 -0400 Subject: [PATCH 01/55] Update BackgroundTaskAudio.swift --- LoopFollow/Helpers/BackgroundTaskAudio.swift | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/LoopFollow/Helpers/BackgroundTaskAudio.swift b/LoopFollow/Helpers/BackgroundTaskAudio.swift index e5ddcf988..8c2a64307 100755 --- a/LoopFollow/Helpers/BackgroundTaskAudio.swift +++ b/LoopFollow/Helpers/BackgroundTaskAudio.swift @@ -37,23 +37,19 @@ class BackgroundTask { LogManager.shared.log(category: .general, message: "[LA] Silent audio session interrupted (began)") case .ended: - // Check shouldResume hint — skip restart if iOS says not to if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt { let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) - guard options.contains(.shouldResume) else { - LogManager.shared.log(category: .general, message: "[LA] Silent audio interruption ended — shouldResume not set, skipping restart") - return + if !options.contains(.shouldResume) { + LogManager.shared.log(category: .general, message: "[LA] Silent audio interruption ended — shouldResume not set, attempting restart anyway") } } LogManager.shared.log(category: .general, message: "[LA] Silent audio interruption ended — scheduling restart in 0.5s") retryCount = 0 - // Brief delay to let the interrupting app (e.g. Clock alarm) fully release the audio - // session before we attempt to reactivate. Without this, setActive(true) races with - // the alarm and fails with AVAudioSession.ErrorCode.cannotInterruptOthers (560557684). DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.playAudio() } + @unknown default: break } From c01cc2322eabb814213f5a14f3dc2bace1482e84 Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:14:34 -0400 Subject: [PATCH 02/55] Update GlucoseLiveActivityAttributes.swift --- .../GlucoseLiveActivityAttributes.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift b/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift index b04768fab..242023552 100644 --- a/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift +++ b/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift @@ -29,6 +29,20 @@ struct GlucoseLiveActivityAttributes: ActivityAttributes { let producedAtInterval = try container.decode(Double.self, forKey: .producedAt) producedAt = Date(timeIntervalSince1970: producedAtInterval) } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(glucose, forKey: .glucose) + try container.encode(trend, forKey: .trend) + try container.encodeIfPresent(delta, forKey: .delta) + try container.encodeIfPresent(iob, forKey: .iob) + try container.encodeIfPresent(cob, forKey: .cob) + try container.encodeIfPresent(predictedGlucose, forKey: .predictedGlucose) + try container.encode(unit, forKey: .unit) + try container.encode(thresholdClassification, forKey: .thresholdClassification) + try container.encode(producedAt.timeIntervalSince1970, forKey: .producedAt) + } + private enum CodingKeys: String, CodingKey { case snapshot, seq, reason, producedAt From 808087c4b4dfa752822e131a254ffd2f10568c3d Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:21:03 -0400 Subject: [PATCH 03/55] Update GlucoseLiveActivityAttributes.swift --- .../LiveActivity/GlucoseLiveActivityAttributes.swift | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift b/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift index 242023552..e1d6b1332 100644 --- a/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift +++ b/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift @@ -32,18 +32,14 @@ struct GlucoseLiveActivityAttributes: ActivityAttributes { func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(glucose, forKey: .glucose) - try container.encode(trend, forKey: .trend) - try container.encodeIfPresent(delta, forKey: .delta) - try container.encodeIfPresent(iob, forKey: .iob) - try container.encodeIfPresent(cob, forKey: .cob) - try container.encodeIfPresent(predictedGlucose, forKey: .predictedGlucose) - try container.encode(unit, forKey: .unit) - try container.encode(thresholdClassification, forKey: .thresholdClassification) + try container.encode(snapshot, forKey: .snapshot) + try container.encode(seq, forKey: .seq) + try container.encode(reason, forKey: .reason) try container.encode(producedAt.timeIntervalSince1970, forKey: .producedAt) } + private enum CodingKeys: String, CodingKey { case snapshot, seq, reason, producedAt } From c2fc15b0544bfc3f605e1bdc124fce5acf4246d2 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Thu, 19 Mar 2026 20:48:09 -0400 Subject: [PATCH 04/55] Restore explanatory comment for 0.5s audio restart delay --- LoopFollow/Helpers/BackgroundTaskAudio.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/LoopFollow/Helpers/BackgroundTaskAudio.swift b/LoopFollow/Helpers/BackgroundTaskAudio.swift index 8c2a64307..acbf15cbc 100755 --- a/LoopFollow/Helpers/BackgroundTaskAudio.swift +++ b/LoopFollow/Helpers/BackgroundTaskAudio.swift @@ -45,6 +45,9 @@ class BackgroundTask { } LogManager.shared.log(category: .general, message: "[LA] Silent audio interruption ended — scheduling restart in 0.5s") retryCount = 0 + // Brief delay to let the interrupting app (e.g. Clock alarm) fully release the audio + // session before we attempt to reactivate. Without this, setActive(true) races with + // the alarm and fails with AVAudioSession.ErrorCode.cannotInterruptOthers (560557684). DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.playAudio() } From 2af22f7dcb364e01fe81ad1e670343ac9ba834aa Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:12:29 -0400 Subject: [PATCH 05/55] Add BGAppRefreshTask support for silent audio recovery Registers com.loopfollow.audiorefresh with BGTaskScheduler so iOS can wake the app every ~15 min to check if the silent audio session is still alive and restart it if not. Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow.xcodeproj/project.pbxproj | 4 + LoopFollow/Application/AppDelegate.swift | 2 + .../Helpers/BackgroundRefreshManager.swift | 86 +++++++++++++++++++ LoopFollow/Info.plist | 1 + .../ViewControllers/MainViewController.swift | 1 + 5 files changed, 94 insertions(+) create mode 100644 LoopFollow/Helpers/BackgroundRefreshManager.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 34c2b838c..305f058b3 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -140,6 +140,7 @@ DD608A082C1F584900F91132 /* DeviceStatusLoop.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A072C1F584900F91132 /* DeviceStatusLoop.swift */; }; DD608A0A2C23593900F91132 /* SMB.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A092C23593900F91132 /* SMB.swift */; }; DD608A0C2C27415C00F91132 /* BackgroundAlertManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A0B2C27415C00F91132 /* BackgroundAlertManager.swift */; }; + 66E3D12E66AA4534A144A54B /* BackgroundRefreshManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */; }; DD6A935E2BFA6FA2003FFB8E /* DeviceStatusOpenAPS.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6A935D2BFA6FA2003FFB8E /* DeviceStatusOpenAPS.swift */; }; DD7B0D442D730A3B0063DCB6 /* CycleHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7B0D432D730A320063DCB6 /* CycleHelper.swift */; }; DD7E19842ACDA50C00DBD158 /* Overrides.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E19832ACDA50C00DBD158 /* Overrides.swift */; }; @@ -581,6 +582,7 @@ DD608A072C1F584900F91132 /* DeviceStatusLoop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatusLoop.swift; sourceTree = ""; }; DD608A092C23593900F91132 /* SMB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMB.swift; sourceTree = ""; }; DD608A0B2C27415C00F91132 /* BackgroundAlertManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundAlertManager.swift; sourceTree = ""; }; + A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshManager.swift; sourceTree = ""; }; DD6A935D2BFA6FA2003FFB8E /* DeviceStatusOpenAPS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatusOpenAPS.swift; sourceTree = ""; }; DD7B0D432D730A320063DCB6 /* CycleHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CycleHelper.swift; sourceTree = ""; }; DD7E19832ACDA50C00DBD158 /* Overrides.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Overrides.swift; sourceTree = ""; }; @@ -1655,6 +1657,7 @@ FCC6886A24898FD800A0279D /* ObservationToken.swift */, FCC6886C2489909D00A0279D /* AnyConvertible.swift */, FCC688592489554800A0279D /* BackgroundTaskAudio.swift */, + A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */, FCFEEC9F2488157B00402A7F /* Chart.swift */, FCC0FAC124922A22003E610E /* DictionaryKeyPath.swift */, FC16A98024996C07003D6245 /* DateTime.swift */, @@ -2256,6 +2259,7 @@ DDFF3D852D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift in Sources */, DDCF9A882D85FD33004DF4DD /* AlarmData.swift in Sources */, DD608A0C2C27415C00F91132 /* BackgroundAlertManager.swift in Sources */, + 66E3D12E66AA4534A144A54B /* BackgroundRefreshManager.swift in Sources */, 379BECB02F65DA4B0069DC62 /* LiveActivitySettingsView.swift in Sources */, DD4878082C7B30BF0048F05C /* RemoteSettingsView.swift in Sources */, DDE75D2B2DE5E613007C1FC1 /* NavigationRow.swift in Sources */, diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 81b01cf50..c2cc460a8 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -45,6 +45,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { DispatchQueue.main.async { UIApplication.shared.registerForRemoteNotifications() } + + BackgroundRefreshManager.shared.register() return true } diff --git a/LoopFollow/Helpers/BackgroundRefreshManager.swift b/LoopFollow/Helpers/BackgroundRefreshManager.swift new file mode 100644 index 000000000..6c07680d3 --- /dev/null +++ b/LoopFollow/Helpers/BackgroundRefreshManager.swift @@ -0,0 +1,86 @@ +// LoopFollow +// BackgroundRefreshManager.swift + +import BackgroundTasks +import UIKit + +class BackgroundRefreshManager { + static let shared = BackgroundRefreshManager() + private init() {} + + private let taskIdentifier = "com.loopfollow.audiorefresh" + + func register() { + BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: nil) { task in + guard let refreshTask = task as? BGAppRefreshTask else { return } + self.handleRefreshTask(refreshTask) + } + } + + private func handleRefreshTask(_ task: BGAppRefreshTask) { + LogManager.shared.log(category: .general, message: "BGAppRefreshTask fired") + + task.expirationHandler = { + LogManager.shared.log(category: .general, message: "BGAppRefreshTask expired") + task.setTaskCompleted(success: false) + self.scheduleRefresh() + } + + DispatchQueue.main.async { + if let mainVC = self.getMainViewController() { + if !mainVC.backgroundTask.player.isPlaying { + LogManager.shared.log(category: .general, message: "audio dead, attempting restart") + mainVC.backgroundTask.startBackgroundTask() + } else { + LogManager.shared.log(category: .general, message: "audio alive, no action needed", isDebug: true) + } + } + self.scheduleRefresh() + task.setTaskCompleted(success: true) + } + } + + func scheduleRefresh() { + let request = BGAppRefreshTaskRequest(identifier: taskIdentifier) + request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) + do { + try BGTaskScheduler.shared.submit(request) + } catch { + LogManager.shared.log(category: .general, message: "Failed to schedule BGAppRefreshTask: \(error)") + } + } + + private func getMainViewController() -> MainViewController? { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first, + let rootVC = window.rootViewController + else { + return nil + } + + if let mainVC = rootVC as? MainViewController { + return mainVC + } + + if let navVC = rootVC as? UINavigationController, + let mainVC = navVC.viewControllers.first as? MainViewController + { + return mainVC + } + + if let tabVC = rootVC as? UITabBarController { + for vc in tabVC.viewControllers ?? [] { + if let mainVC = vc as? MainViewController { + return mainVC + } + if let navVC = vc as? UINavigationController, + let mainVC = navVC.viewControllers.first as? MainViewController + { + return mainVC + } + } + } + + return nil + } +} diff --git a/LoopFollow/Info.plist b/LoopFollow/Info.plist index 28385ac6e..974fb0418 100644 --- a/LoopFollow/Info.plist +++ b/LoopFollow/Info.plist @@ -7,6 +7,7 @@ BGTaskSchedulerPermittedIdentifiers com.$(unique_id).LoopFollow$(app_suffix) + com.loopfollow.audiorefresh CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 4cbe41454..ce396ab97 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -959,6 +959,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele if Storage.shared.backgroundRefreshType.value == .silentTune { backgroundTask.startBackgroundTask() + BackgroundRefreshManager.shared.scheduleRefresh() } if Storage.shared.backgroundRefreshType.value != .none { From 41f9150667a88f575dbd3d0ecebe2e6d3a162830 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:01:36 -0400 Subject: [PATCH 06/55] Fix BGAppRefreshTask: add fetch background mode, fix duplicate observer - Add 'fetch' to UIBackgroundModes so BGTaskScheduler.submit() doesn't throw notPermitted on every background transition - Call stopBackgroundTask() before startBackgroundTask() in the refresh handler to prevent accumulating duplicate AVAudioSession observers Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/Helpers/BackgroundRefreshManager.swift | 1 + LoopFollow/Info.plist | 1 + 2 files changed, 2 insertions(+) diff --git a/LoopFollow/Helpers/BackgroundRefreshManager.swift b/LoopFollow/Helpers/BackgroundRefreshManager.swift index 6c07680d3..ceea6752f 100644 --- a/LoopFollow/Helpers/BackgroundRefreshManager.swift +++ b/LoopFollow/Helpers/BackgroundRefreshManager.swift @@ -30,6 +30,7 @@ class BackgroundRefreshManager { if let mainVC = self.getMainViewController() { if !mainVC.backgroundTask.player.isPlaying { LogManager.shared.log(category: .general, message: "audio dead, attempting restart") + mainVC.backgroundTask.stopBackgroundTask() mainVC.backgroundTask.startBackgroundTask() } else { LogManager.shared.log(category: .general, message: "audio alive, no action needed", isDebug: true) diff --git a/LoopFollow/Info.plist b/LoopFollow/Info.plist index 974fb0418..5cc7f4146 100644 --- a/LoopFollow/Info.plist +++ b/LoopFollow/Info.plist @@ -88,6 +88,7 @@ UIBackgroundModes audio + fetch processing bluetooth-central remote-notification From 6a7d7061e098b8ebecf18ee6f01771f9b7c5b7b9 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Fri, 20 Mar 2026 13:10:28 -0400 Subject: [PATCH 07/55] Fix duplicate audio observer; add restart confirmation log - startBackgroundTask() now removes the old observer before adding, making it idempotent and preventing duplicate interrupt callbacks - Add 'audio restart initiated' log after restart so success is visible without debug mode - Temporarily make 'Silent audio playing' log always visible for testing Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/Helpers/BackgroundRefreshManager.swift | 1 + LoopFollow/Helpers/BackgroundTaskAudio.swift | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/LoopFollow/Helpers/BackgroundRefreshManager.swift b/LoopFollow/Helpers/BackgroundRefreshManager.swift index ceea6752f..f73ab33c6 100644 --- a/LoopFollow/Helpers/BackgroundRefreshManager.swift +++ b/LoopFollow/Helpers/BackgroundRefreshManager.swift @@ -32,6 +32,7 @@ class BackgroundRefreshManager { LogManager.shared.log(category: .general, message: "audio dead, attempting restart") mainVC.backgroundTask.stopBackgroundTask() mainVC.backgroundTask.startBackgroundTask() + LogManager.shared.log(category: .general, message: "audio restart initiated") } else { LogManager.shared.log(category: .general, message: "audio alive, no action needed", isDebug: true) } diff --git a/LoopFollow/Helpers/BackgroundTaskAudio.swift b/LoopFollow/Helpers/BackgroundTaskAudio.swift index acbf15cbc..c5de42fd9 100755 --- a/LoopFollow/Helpers/BackgroundTaskAudio.swift +++ b/LoopFollow/Helpers/BackgroundTaskAudio.swift @@ -14,6 +14,7 @@ class BackgroundTask { // MARK: - Methods func startBackgroundTask() { + NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(interruptedAudio), name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance()) retryCount = 0 playAudio() @@ -72,7 +73,7 @@ class BackgroundTask { player.prepareToPlay() player.play() retryCount = 0 - LogManager.shared.log(category: .general, message: "Silent audio playing (\(attemptDesc))", isDebug: true) + LogManager.shared.log(category: .general, message: "Silent audio playing (\(attemptDesc))") } catch { LogManager.shared.log(category: .general, message: "playAudio failed (\(attemptDesc)), error: \(error)") if retryCount < maxRetries { From 0c2993d9e7c0feb411cdfa6f00eed677e92931ee Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 21 Mar 2026 09:59:37 -0400 Subject: [PATCH 08/55] Delete LiveActivitySlotConfig.swift Forgotten stub. --- .../LiveActivity/LiveActivitySlotConfig.swift | 45 ------------------- 1 file changed, 45 deletions(-) delete mode 100644 LoopFollow/LiveActivity/LiveActivitySlotConfig.swift diff --git a/LoopFollow/LiveActivity/LiveActivitySlotConfig.swift b/LoopFollow/LiveActivity/LiveActivitySlotConfig.swift deleted file mode 100644 index 10d8b13c3..000000000 --- a/LoopFollow/LiveActivity/LiveActivitySlotConfig.swift +++ /dev/null @@ -1,45 +0,0 @@ -// LoopFollow -// LiveActivitySlotConfig.swift - -// MARK: - Information Display Settings audit - -// -// LoopFollow exposes 20 items in Information Display Settings (InfoType.swift). -// The table below maps each item to its availability as a Live Activity grid slot. -// -// AVAILABLE NOW — value present in GlucoseSnapshot: -// Display name | InfoType case | Snapshot field | Optional (nil for Dexcom-only) -// ───────────────────────────────────────────────────────────────────────────────── -// IOB | .iob | snapshot.iob | YES -// COB | .cob | snapshot.cob | YES -// Projected BG | (none) | snapshot.projected | YES -// Delta | (none) | snapshot.delta | NO (always available) -// -// Note: "Updated" (InfoType.updated) is intentionally excluded — it is displayed -// in the card footer and is not a configurable slot. -// -// NOT YET AVAILABLE — requires adding fields to GlucoseSnapshot, GlucoseSnapshotBuilder, -// and the APNs payload before they can be offered as slot options: -// Display name | InfoType case | Source in app -// ───────────────────────────────────────────────────────────────────────────────── -// Basal | .basal | DeviceStatus basal rate -// Override | .override | DeviceStatus override name -// Battery | .battery | DeviceStatus CGM/device battery % -// Pump | .pump | DeviceStatus pump name / status -// Pump Battery | .pumpBattery | DeviceStatus pump battery % -// SAGE | .sage | DeviceStatus sensor age (hours) -// CAGE | .cage | DeviceStatus cannula age (hours) -// Rec. Bolus | .recBolus | DeviceStatus recommended bolus -// Min/Max | .minMax | Computed from recent BG history -// Carbs today | .carbsToday | Computed from COB history -// Autosens | .autosens | DeviceStatusOpenAPS autosens ratio -// Profile | .profile | DeviceStatus profile name -// Target | .target | DeviceStatus BG target -// ISF | .isf | DeviceStatus insulin sensitivity factor -// CR | .carbRatio | DeviceStatus carb ratio -// TDD | .tdd | DeviceStatus total daily dose -// IAGE | .iage | DeviceStatus insulin/pod age (hours) -// -// The LiveActivitySlotOption enum, LiveActivitySlotDefaults struct, and -// LAAppGroupSettings.setSlots() / slots() storage are defined in -// LAAppGroupSettings.swift (shared between app and extension targets). From 839a806d0a6b01b7be472e682cad238e33eca4f4 Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 21 Mar 2026 10:18:37 -0400 Subject: [PATCH 09/55] Update GlucoseSnapshotBuilder.swift --- .../LiveActivity/GlucoseSnapshotBuilder.swift | 167 ++++++++++++------ 1 file changed, 112 insertions(+), 55 deletions(-) diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift index dd845b116..03b1926af 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift @@ -1,12 +1,15 @@ -// LoopFollow // GlucoseSnapshotBuilder.swift +// 2026-03-21 import Foundation -/// Provides the *latest* glucose-relevant values from LoopFollow’s single source of truth. -/// This is intentionally provider-agnostic (Nightscout vs Dexcom doesn’t matter). +/// Provides the latest glucose-relevant values from LoopFollow's single source of truth. +/// Intentionally provider-agnostic (Nightscout vs Dexcom doesn't matter). protocol CurrentGlucoseStateProviding { - /// Canonical glucose value in mg/dL (recommended internal canonical form). + + // MARK: - Core Glucose + + /// Canonical glucose value in mg/dL. var glucoseMgdl: Double? { get } /// Canonical delta in mg/dL. @@ -15,18 +18,92 @@ protocol CurrentGlucoseStateProviding { /// Canonical projected glucose in mg/dL. var projectedMgdl: Double? { get } - /// Timestamp of the last reading/update. + /// Timestamp of the last reading. var updatedAt: Date? { get } - /// Trend string / code from LoopFollow (we map to GlucoseSnapshot.Trend). + /// Trend string from LoopFollow (mapped to GlucoseSnapshot.Trend by the builder). var trendCode: String? { get } - /// Secondary metrics (typically already unitless) + // MARK: - Secondary Metrics + var iob: Double? { get } var cob: Double? { get } + + // MARK: - Extended Metrics + + /// Active override name (nil if no active override). + var override: String? { get } + + /// Recommended bolus in units. + var recBolus: Double? { get } + + /// CGM/uploader device battery %. + var battery: Double? { get } + + /// Pump battery %. + var pumpBattery: Double? { get } + + /// Formatted current basal rate string (empty if not available). + var basalRate: String { get } + + /// Pump reservoir in units (nil if >50U or unknown). + var pumpReservoirU: Double? { get } + + /// Autosensitivity ratio, e.g. 0.9 = 90%. + var autosens: Double? { get } + + /// Total daily dose in units. + var tdd: Double? { get } + + /// BG target low in mg/dL. + var targetLowMgdl: Double? { get } + + /// BG target high in mg/dL. + var targetHighMgdl: Double? { get } + + /// Insulin Sensitivity Factor in mg/dL per unit. + var isfMgdlPerU: Double? { get } + + /// Carb ratio in g per unit. + var carbRatio: Double? { get } + + /// Total carbs entered today in grams. + var carbsToday: Double? { get } + + /// Active profile name. + var profileName: String? { get } + + /// Sensor insert time as Unix epoch seconds UTC (0 = not set). + var sageInsertTime: TimeInterval { get } + + /// Cannula insert time as Unix epoch seconds UTC (0 = not set). + var cageInsertTime: TimeInterval { get } + + /// Insulin/pod insert time as Unix epoch seconds UTC (0 = not set). + var iageInsertTime: TimeInterval { get } + + /// Min predicted BG in mg/dL. + var minBgMgdl: Double? { get } + + /// Max predicted BG in mg/dL. + var maxBgMgdl: Double? { get } + + // MARK: - Loop Status + + /// True when LoopFollow detects the loop has not reported in 15+ minutes. + var isNotLooping: Bool { get } + + // MARK: - Renewal + + /// True when the Live Activity is within renewalWarning seconds of its deadline. + var showRenewalOverlay: Bool { get } } -/// Builds a GlucoseSnapshot in the user’s preferred unit, without embedding provider logic. +// MARK: - Builder + +/// Pure transformation layer. Reads exclusively from the provider — no direct +/// Storage.shared or Observable.shared access. This makes it testable and reusable +/// across Live Activity, Watch, and CarPlay. enum GlucoseSnapshotBuilder { static func build(from provider: CurrentGlucoseStateProviding) -> GlucoseSnapshot? { guard @@ -34,8 +111,6 @@ enum GlucoseSnapshotBuilder { glucoseMgdl > 0, let updatedAt = provider.updatedAt else { - // Debug-only signal: we’re missing core state. - // (If you prefer no logs here, remove this line.) LogManager.shared.log( category: .general, message: "GlucoseSnapshotBuilder: missing/invalid core values glucoseMgdl=\(provider.glucoseMgdl?.description ?? "nil") updatedAt=\(provider.updatedAt?.description ?? "nil")", @@ -45,23 +120,11 @@ enum GlucoseSnapshotBuilder { } let preferredUnit = PreferredGlucoseUnit.snapshotUnit() - let deltaMgdl = provider.deltaMgdl ?? 0.0 - let trend = mapTrend(provider.trendCode) - // Not Looping — read from Observable, set by evaluateNotLooping() in DeviceStatus.swift - let isNotLooping = Observable.shared.isNotLooping.value - - // Renewal overlay — show renewalWarning seconds before the renewal deadline - // so the user knows the LA is about to be replaced. - let renewBy = Storage.shared.laRenewBy.value - let now = Date().timeIntervalSince1970 - let showRenewalOverlay = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning - - if showRenewalOverlay { - let timeLeft = max(renewBy - now, 0) - LogManager.shared.log(category: .general, message: "[LA] renewal overlay ON — \(Int(timeLeft))s until deadline") + if provider.showRenewalOverlay { + LogManager.shared.log(category: .general, message: "[LA] renewal overlay ON") } LogManager.shared.log( @@ -70,7 +133,6 @@ enum GlucoseSnapshotBuilder { isDebug: true ) - let profileNameRaw = Storage.shared.lastProfileName.value return GlucoseSnapshot( glucose: glucoseMgdl, delta: deltaMgdl, @@ -79,44 +141,41 @@ enum GlucoseSnapshotBuilder { iob: provider.iob, cob: provider.cob, projected: provider.projectedMgdl, - override: Observable.shared.override.value, - recBolus: Observable.shared.deviceRecBolus.value, - battery: Observable.shared.deviceBatteryLevel.value, - pumpBattery: Observable.shared.pumpBatteryLevel.value, - basalRate: Storage.shared.lastBasal.value, - pumpReservoirU: Storage.shared.lastPumpReservoirU.value, - autosens: Storage.shared.lastAutosens.value, - tdd: Storage.shared.lastTdd.value, - targetLowMgdl: Storage.shared.lastTargetLowMgdl.value, - targetHighMgdl: Storage.shared.lastTargetHighMgdl.value, - isfMgdlPerU: Storage.shared.lastIsfMgdlPerU.value, - carbRatio: Storage.shared.lastCarbRatio.value, - carbsToday: Storage.shared.lastCarbsToday.value, - profileName: profileNameRaw.isEmpty ? nil : profileNameRaw, - sageInsertTime: Storage.shared.sageInsertTime.value, - cageInsertTime: Storage.shared.cageInsertTime.value, - iageInsertTime: Storage.shared.iageInsertTime.value, - minBgMgdl: Storage.shared.lastMinBgMgdl.value, - maxBgMgdl: Storage.shared.lastMaxBgMgdl.value, + override: provider.override, + recBolus: provider.recBolus, + battery: provider.battery, + pumpBattery: provider.pumpBattery, + basalRate: provider.basalRate, + pumpReservoirU: provider.pumpReservoirU, + autosens: provider.autosens, + tdd: provider.tdd, + targetLowMgdl: provider.targetLowMgdl, + targetHighMgdl: provider.targetHighMgdl, + isfMgdlPerU: provider.isfMgdlPerU, + carbRatio: provider.carbRatio, + carbsToday: provider.carbsToday, + profileName: provider.profileName, + sageInsertTime: provider.sageInsertTime, + cageInsertTime: provider.cageInsertTime, + iageInsertTime: provider.iageInsertTime, + minBgMgdl: provider.minBgMgdl, + maxBgMgdl: provider.maxBgMgdl, unit: preferredUnit, - isNotLooping: isNotLooping, - showRenewalOverlay: showRenewalOverlay + isNotLooping: provider.isNotLooping, + showRenewalOverlay: provider.showRenewalOverlay ) } + // MARK: - Trend Mapping + private static func mapTrend(_ code: String?) -> GlucoseSnapshot.Trend { guard let raw = code? - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased(), + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased(), !raw.isEmpty else { return .unknown } - // Common Nightscout strings: - // "Flat", "FortyFiveUp", "SingleUp", "DoubleUp", "FortyFiveDown", "SingleDown", "DoubleDown" - // Common variants: - // "rising", "falling", "rapidRise", "rapidFall" - if raw.contains("doubleup") || raw.contains("rapidrise") || raw == "up2" || raw == "upfast" { return .upFast } @@ -126,11 +185,9 @@ enum GlucoseSnapshotBuilder { if raw.contains("singleup") || raw == "up" || raw == "up1" || raw == "rising" { return .up } - if raw.contains("flat") || raw == "steady" || raw == "none" { return .flat } - if raw.contains("doubledown") || raw.contains("rapidfall") || raw == "down2" || raw == "downfast" { return .downFast } From b33f05b8b7f8c2312bb2cadc9845532e6f16434a Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 21 Mar 2026 10:19:46 -0400 Subject: [PATCH 10/55] Update StorageCurrentGlucoseStateProvider.swift --- .../StorageCurrentGlucoseStateProvider.swift | 113 ++++++++++++++++-- 1 file changed, 103 insertions(+), 10 deletions(-) diff --git a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift index b5a5cf7ea..90e74f5b8 100644 --- a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift +++ b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift @@ -1,19 +1,17 @@ -// LoopFollow // StorageCurrentGlucoseStateProvider.swift +// 2026-03-21 import Foundation -/// Reads the latest glucose state from LoopFollow’s existing single source of truth. -/// Provider remains source-agnostic (Nightscout vs Dexcom). +/// Reads the latest glucose state from LoopFollow's Storage and Observable layers. +/// 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 { - var glucoseMgdl: Double? { - guard - let bg = Observable.shared.bg.value, - bg > 0 - else { - return nil - } + // MARK: - Core Glucose + + var glucoseMgdl: Double? { + guard let bg = Observable.shared.bg.value, bg > 0 else { return nil } return Double(bg) } @@ -34,6 +32,8 @@ struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding { Storage.shared.lastTrendCode.value } + // MARK: - Secondary Metrics + var iob: Double? { Storage.shared.lastIOB.value } @@ -41,4 +41,97 @@ struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding { var cob: Double? { Storage.shared.lastCOB.value } + + // MARK: - Extended Metrics + + var override: String? { + Observable.shared.override.value + } + + var recBolus: Double? { + Observable.shared.deviceRecBolus.value + } + + var battery: Double? { + Observable.shared.deviceBatteryLevel.value + } + + var pumpBattery: Double? { + Observable.shared.pumpBatteryLevel.value + } + + var basalRate: String { + Storage.shared.lastBasal.value + } + + var pumpReservoirU: Double? { + Storage.shared.lastPumpReservoirU.value + } + + var autosens: Double? { + Storage.shared.lastAutosens.value + } + + var tdd: Double? { + Storage.shared.lastTdd.value + } + + var targetLowMgdl: Double? { + Storage.shared.lastTargetLowMgdl.value + } + + var targetHighMgdl: Double? { + Storage.shared.lastTargetHighMgdl.value + } + + var isfMgdlPerU: Double? { + Storage.shared.lastIsfMgdlPerU.value + } + + var carbRatio: Double? { + Storage.shared.lastCarbRatio.value + } + + var carbsToday: Double? { + Storage.shared.lastCarbsToday.value + } + + var profileName: String? { + let raw = Storage.shared.lastProfileName.value + return raw.isEmpty ? nil : raw + } + + var sageInsertTime: TimeInterval { + Storage.shared.sageInsertTime.value + } + + var cageInsertTime: TimeInterval { + Storage.shared.cageInsertTime.value + } + + var iageInsertTime: TimeInterval { + Storage.shared.iageInsertTime.value + } + + var minBgMgdl: Double? { + Storage.shared.lastMinBgMgdl.value + } + + var maxBgMgdl: Double? { + Storage.shared.lastMaxBgMgdl.value + } + + // MARK: - Loop Status + + var isNotLooping: Bool { + Observable.shared.isNotLooping.value + } + + // MARK: - Renewal + + var showRenewalOverlay: Bool { + let renewBy = Storage.shared.laRenewBy.value + let now = Date().timeIntervalSince1970 + return renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning + } } From ba1851073105e5261eb0fe9f5958f876a43b4cb7 Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 21 Mar 2026 10:28:14 -0400 Subject: [PATCH 11/55] Update LiveActivityManager.swift --- .../LiveActivity/LiveActivityManager.swift | 112 +++++++++++------- 1 file changed, 72 insertions(+), 40 deletions(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 00d230e40..1838021ac 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -174,39 +174,45 @@ final class LiveActivityManager { LogManager.shared.log(category: .general, message: "Live Activity not authorized") return } - + if let existing = Activity.activities.first { bind(to: existing, logReason: "reuse") Storage.shared.laRenewalFailed.value = false return } - + do { let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow") - - let seedSnapshot = GlucoseSnapshotStore.shared.load() ?? GlucoseSnapshot( - glucose: 0, - delta: 0, - trend: .unknown, - updatedAt: Date(), - iob: nil, - cob: nil, - projected: nil, - unit: .mgdl, - isNotLooping: false - ) - + + // Prefer a freshly built snapshot so all extended fields are populated. + // Fall back to the persisted store (covers cold-start with real data), + // then to a zero seed (true first-ever launch with no data yet). + let provider = StorageCurrentGlucoseStateProvider() + let seedSnapshot = GlucoseSnapshotBuilder.build(from: provider) + ?? GlucoseSnapshotStore.shared.load() + ?? GlucoseSnapshot( + glucose: 0, + delta: 0, + trend: .unknown, + updatedAt: Date(), + iob: nil, + cob: nil, + projected: nil, + unit: .mgdl, + isNotLooping: false + ) + let initialState = GlucoseLiveActivityAttributes.ContentState( snapshot: seedSnapshot, seq: 0, reason: "start", producedAt: Date() ) - + let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold) let content = ActivityContent(state: initialState, staleDate: renewDeadline) let activity = try Activity.request(attributes: attributes, content: content, pushType: .token) - + bind(to: activity, logReason: "start-new") Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970 Storage.shared.laRenewalFailed.value = false @@ -216,6 +222,7 @@ final class LiveActivityManager { } } + /// Called from applicationWillTerminate. Ends the LA synchronously (blocking /// up to 3 s) so it clears from the lock screen before the process exits. /// Does not clear laEnabled — the user's preference is preserved for relaunch. @@ -326,30 +333,57 @@ final class LiveActivityManager { /// Returns true if renewal was performed (caller should return early). private func renewIfNeeded(snapshot: GlucoseSnapshot) -> Bool { guard let oldActivity = current else { return false } - + let renewBy = Storage.shared.laRenewBy.value guard renewBy > 0, Date().timeIntervalSince1970 >= renewBy else { return false } - + let overdueBy = Date().timeIntervalSince1970 - renewBy LogManager.shared.log(category: .general, message: "[LA] renewal deadline passed by \(Int(overdueBy))s, requesting new LA") - + let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold) let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow") - - // Strip the overlay flag — the new LA has a fresh deadline so it should - // open clean, without the warning visible from the first frame. - let freshSnapshot = GlucoseSnapshot( - glucose: snapshot.glucose, - delta: snapshot.delta, - trend: snapshot.trend, - updatedAt: snapshot.updatedAt, - iob: snapshot.iob, - cob: snapshot.cob, - projected: snapshot.projected, - unit: snapshot.unit, - isNotLooping: snapshot.isNotLooping, + + // Build a fresh snapshot from the provider so all extended fields are populated. + // Strip the renewal overlay — the new LA has a clean deadline and should open + // without the warning visible on its first frame. + // Fall back to the passed-in snapshot if the provider build fails (e.g. stale data). + let provider = StorageCurrentGlucoseStateProvider() + let builtSnapshot = GlucoseSnapshotBuilder.build(from: provider) ?? snapshot + Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970 + + // Re-build with overlay suppressed now that the deadline has been reset. + let freshSnapshot = GlucoseSnapshotBuilder.build(from: provider) ?? GlucoseSnapshot( + glucose: builtSnapshot.glucose, + delta: builtSnapshot.delta, + trend: builtSnapshot.trend, + updatedAt: builtSnapshot.updatedAt, + iob: builtSnapshot.iob, + cob: builtSnapshot.cob, + projected: builtSnapshot.projected, + override: builtSnapshot.override, + recBolus: builtSnapshot.recBolus, + battery: builtSnapshot.battery, + pumpBattery: builtSnapshot.pumpBattery, + basalRate: builtSnapshot.basalRate, + pumpReservoirU: builtSnapshot.pumpReservoirU, + autosens: builtSnapshot.autosens, + tdd: builtSnapshot.tdd, + targetLowMgdl: builtSnapshot.targetLowMgdl, + targetHighMgdl: builtSnapshot.targetHighMgdl, + isfMgdlPerU: builtSnapshot.isfMgdlPerU, + carbRatio: builtSnapshot.carbRatio, + carbsToday: builtSnapshot.carbsToday, + profileName: builtSnapshot.profileName, + sageInsertTime: builtSnapshot.sageInsertTime, + cageInsertTime: builtSnapshot.cageInsertTime, + iageInsertTime: builtSnapshot.iageInsertTime, + minBgMgdl: builtSnapshot.minBgMgdl, + maxBgMgdl: builtSnapshot.maxBgMgdl, + unit: builtSnapshot.unit, + isNotLooping: builtSnapshot.isNotLooping, showRenewalOverlay: false ) + let state = GlucoseLiveActivityAttributes.ContentState( snapshot: freshSnapshot, seq: seq, @@ -357,15 +391,14 @@ final class LiveActivityManager { producedAt: Date() ) let content = ActivityContent(state: state, staleDate: renewDeadline) - + do { let newActivity = try Activity.request(attributes: attributes, content: content, pushType: .token) - - // New LA is live — now it's safe to remove the old card. + Task { await oldActivity.end(nil, dismissalPolicy: .immediate) } - + updateTask?.cancel() updateTask = nil tokenObservationTask?.cancel() @@ -373,11 +406,9 @@ final class LiveActivityManager { stateObserverTask?.cancel() stateObserverTask = nil pushToken = nil - + bind(to: newActivity, logReason: "renew") - Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970 Storage.shared.laRenewalFailed.value = false - // Update the store so the next duplicate check has the correct baseline. GlucoseSnapshotStore.shared.save(freshSnapshot) LogManager.shared.log(category: .general, message: "[LA] Live Activity renewed successfully id=\(newActivity.id)") return true @@ -388,6 +419,7 @@ final class LiveActivityManager { } } + private func performRefresh(reason: String) { let provider = StorageCurrentGlucoseStateProvider() guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { From 0f7fa118fdd6f331a6ea0c82e550a4b1ceddb34c Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 21 Mar 2026 10:29:53 -0400 Subject: [PATCH 12/55] Update GlucoseSnapshot.swift --- LoopFollow/LiveActivity/GlucoseSnapshot.swift | 219 +----------------- 1 file changed, 2 insertions(+), 217 deletions(-) diff --git a/LoopFollow/LiveActivity/GlucoseSnapshot.swift b/LoopFollow/LiveActivity/GlucoseSnapshot.swift index 4e914ab7e..8852326aa 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshot.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshot.swift @@ -7,6 +7,7 @@ import Foundation /// Live Activity, future Watch complication, and CarPlay. /// struct GlucoseSnapshot: Codable, Equatable, Hashable { + // MARK: - Units enum Unit: String, Codable, Hashable { @@ -65,220 +66,4 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { /// Total daily dose in units (nil if not available) let tdd: Double? - /// BG target low in mg/dL (nil if not available) - let targetLowMgdl: Double? - - /// BG target high in mg/dL (nil if not available) - let targetHighMgdl: Double? - - /// Insulin Sensitivity Factor in mg/dL per unit (nil if not available) - let isfMgdlPerU: Double? - - /// Carb ratio in g per unit (nil if not available) - let carbRatio: Double? - - /// Total carbs entered today in grams (nil if not available) - let carbsToday: Double? - - /// Active profile name (nil if not available) - let profileName: String? - - /// Sensor insert time as Unix epoch seconds UTC (0 = not set) - let sageInsertTime: TimeInterval - - /// Cannula insert time as Unix epoch seconds UTC (0 = not set) - let cageInsertTime: TimeInterval - - /// Insulin/pod insert time as Unix epoch seconds UTC (0 = not set) - let iageInsertTime: TimeInterval - - /// Min predicted BG in mg/dL (nil if not available) - let minBgMgdl: Double? - - /// Max predicted BG in mg/dL (nil if not available) - let maxBgMgdl: Double? - - // MARK: - Unit Context - - /// User's preferred display unit. Values are always stored in mg/dL; - /// this tells the display layer which unit to render. - let unit: Unit - - // MARK: - Loop Status - - /// True when LoopFollow detects the loop has not reported in 15+ minutes (Nightscout only). - let isNotLooping: Bool - - // MARK: - Renewal - - /// True when the Live Activity is within 30 minutes of its renewal deadline. - /// The extension renders a "Tap to update" overlay so the user knows renewal is imminent. - let showRenewalOverlay: Bool - - init( - glucose: Double, - delta: Double, - trend: Trend, - updatedAt: Date, - iob: Double?, - cob: Double?, - projected: Double?, - override: String? = nil, - recBolus: Double? = nil, - battery: Double? = nil, - pumpBattery: Double? = nil, - basalRate: String = "", - pumpReservoirU: Double? = nil, - autosens: Double? = nil, - tdd: Double? = nil, - targetLowMgdl: Double? = nil, - targetHighMgdl: Double? = nil, - isfMgdlPerU: Double? = nil, - carbRatio: Double? = nil, - carbsToday: Double? = nil, - profileName: String? = nil, - sageInsertTime: TimeInterval = 0, - cageInsertTime: TimeInterval = 0, - iageInsertTime: TimeInterval = 0, - minBgMgdl: Double? = nil, - maxBgMgdl: Double? = nil, - unit: Unit, - isNotLooping: Bool, - showRenewalOverlay: Bool = false - ) { - self.glucose = glucose - self.delta = delta - self.trend = trend - self.updatedAt = updatedAt - self.iob = iob - self.cob = cob - self.projected = projected - self.override = override - self.recBolus = recBolus - self.battery = battery - self.pumpBattery = pumpBattery - self.basalRate = basalRate - self.pumpReservoirU = pumpReservoirU - self.autosens = autosens - self.tdd = tdd - self.targetLowMgdl = targetLowMgdl - self.targetHighMgdl = targetHighMgdl - self.isfMgdlPerU = isfMgdlPerU - self.carbRatio = carbRatio - self.carbsToday = carbsToday - self.profileName = profileName - self.sageInsertTime = sageInsertTime - self.cageInsertTime = cageInsertTime - self.iageInsertTime = iageInsertTime - self.minBgMgdl = minBgMgdl - self.maxBgMgdl = maxBgMgdl - self.unit = unit - self.isNotLooping = isNotLooping - self.showRenewalOverlay = showRenewalOverlay - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(glucose, forKey: .glucose) - try container.encode(delta, forKey: .delta) - try container.encode(trend, forKey: .trend) - try container.encode(updatedAt.timeIntervalSince1970, forKey: .updatedAt) - try container.encodeIfPresent(iob, forKey: .iob) - try container.encodeIfPresent(cob, forKey: .cob) - try container.encodeIfPresent(projected, forKey: .projected) - try container.encodeIfPresent(override, forKey: .override) - try container.encodeIfPresent(recBolus, forKey: .recBolus) - try container.encodeIfPresent(battery, forKey: .battery) - try container.encodeIfPresent(pumpBattery, forKey: .pumpBattery) - try container.encode(basalRate, forKey: .basalRate) - try container.encodeIfPresent(pumpReservoirU, forKey: .pumpReservoirU) - try container.encodeIfPresent(autosens, forKey: .autosens) - try container.encodeIfPresent(tdd, forKey: .tdd) - try container.encodeIfPresent(targetLowMgdl, forKey: .targetLowMgdl) - try container.encodeIfPresent(targetHighMgdl, forKey: .targetHighMgdl) - try container.encodeIfPresent(isfMgdlPerU, forKey: .isfMgdlPerU) - try container.encodeIfPresent(carbRatio, forKey: .carbRatio) - try container.encodeIfPresent(carbsToday, forKey: .carbsToday) - try container.encodeIfPresent(profileName, forKey: .profileName) - try container.encode(sageInsertTime, forKey: .sageInsertTime) - try container.encode(cageInsertTime, forKey: .cageInsertTime) - try container.encode(iageInsertTime, forKey: .iageInsertTime) - try container.encodeIfPresent(minBgMgdl, forKey: .minBgMgdl) - try container.encodeIfPresent(maxBgMgdl, forKey: .maxBgMgdl) - try container.encode(unit, forKey: .unit) - try container.encode(isNotLooping, forKey: .isNotLooping) - try container.encode(showRenewalOverlay, forKey: .showRenewalOverlay) - } - - private enum CodingKeys: String, CodingKey { - case glucose, delta, trend, updatedAt - case iob, cob, projected - case override, recBolus, battery, pumpBattery, basalRate, pumpReservoirU - case autosens, tdd, targetLowMgdl, targetHighMgdl, isfMgdlPerU, carbRatio, carbsToday - case profileName, sageInsertTime, cageInsertTime, iageInsertTime, minBgMgdl, maxBgMgdl - case unit, isNotLooping, showRenewalOverlay - } - - // MARK: - Codable - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - glucose = try container.decode(Double.self, forKey: .glucose) - delta = try container.decode(Double.self, forKey: .delta) - trend = try container.decode(Trend.self, forKey: .trend) - updatedAt = try Date(timeIntervalSince1970: container.decode(Double.self, forKey: .updatedAt)) - iob = try container.decodeIfPresent(Double.self, forKey: .iob) - cob = try container.decodeIfPresent(Double.self, forKey: .cob) - projected = try container.decodeIfPresent(Double.self, forKey: .projected) - override = try container.decodeIfPresent(String.self, forKey: .override) - recBolus = try container.decodeIfPresent(Double.self, forKey: .recBolus) - battery = try container.decodeIfPresent(Double.self, forKey: .battery) - pumpBattery = try container.decodeIfPresent(Double.self, forKey: .pumpBattery) - basalRate = try container.decodeIfPresent(String.self, forKey: .basalRate) ?? "" - pumpReservoirU = try container.decodeIfPresent(Double.self, forKey: .pumpReservoirU) - autosens = try container.decodeIfPresent(Double.self, forKey: .autosens) - tdd = try container.decodeIfPresent(Double.self, forKey: .tdd) - targetLowMgdl = try container.decodeIfPresent(Double.self, forKey: .targetLowMgdl) - targetHighMgdl = try container.decodeIfPresent(Double.self, forKey: .targetHighMgdl) - isfMgdlPerU = try container.decodeIfPresent(Double.self, forKey: .isfMgdlPerU) - carbRatio = try container.decodeIfPresent(Double.self, forKey: .carbRatio) - carbsToday = try container.decodeIfPresent(Double.self, forKey: .carbsToday) - profileName = try container.decodeIfPresent(String.self, forKey: .profileName) - sageInsertTime = try container.decodeIfPresent(Double.self, forKey: .sageInsertTime) ?? 0 - cageInsertTime = try container.decodeIfPresent(Double.self, forKey: .cageInsertTime) ?? 0 - iageInsertTime = try container.decodeIfPresent(Double.self, forKey: .iageInsertTime) ?? 0 - minBgMgdl = try container.decodeIfPresent(Double.self, forKey: .minBgMgdl) - maxBgMgdl = try container.decodeIfPresent(Double.self, forKey: .maxBgMgdl) - unit = try container.decode(Unit.self, forKey: .unit) - isNotLooping = try container.decodeIfPresent(Bool.self, forKey: .isNotLooping) ?? false - showRenewalOverlay = try container.decodeIfPresent(Bool.self, forKey: .showRenewalOverlay) ?? false - } - - // MARK: - Derived Convenience - - /// Age of reading in seconds. - var age: TimeInterval { - Date().timeIntervalSince(updatedAt) - } -} - -// MARK: - Trend - -extension GlucoseSnapshot { - enum Trend: String, Codable, Hashable { - case up - case upSlight - case upFast - case flat - case down - case downSlight - case downFast - case unknown - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - let raw = try container.decode(String.self) - self = Trend(rawValue: raw) ?? .unknown - } - } -} + /// BG target low in​​​​​​​​​​​​​​​​ From b454e467d384c7d4d09cd9bb48de7662ec416b15 Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 21 Mar 2026 10:31:01 -0400 Subject: [PATCH 13/55] Update GlucoseSnapshot.swift --- LoopFollow/LiveActivity/GlucoseSnapshot.swift | 257 +++++++++++++++++- 1 file changed, 256 insertions(+), 1 deletion(-) diff --git a/LoopFollow/LiveActivity/GlucoseSnapshot.swift b/LoopFollow/LiveActivity/GlucoseSnapshot.swift index 8852326aa..fad01e6a9 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshot.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshot.swift @@ -66,4 +66,259 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { /// Total daily dose in units (nil if not available) let tdd: Double? - /// BG target low in​​​​​​​​​​​​​​​​ + /// BG target low in mg/dL (nil if not available) + let targetLowMgdl: Double? + + /// BG target high in mg/dL (nil if not available) + let targetHighMgdl: Double? + + /// Insulin Sensitivity Factor in mg/dL per unit (nil if not available) + let isfMgdlPerU: Double? + + /// Carb ratio in g per unit (nil if not available) + let carbRatio: Double? + + /// Total carbs entered today in grams (nil if not available) + let carbsToday: Double? + + /// Active profile name (nil if not available) + let profileName: String? + + /// Sensor insert time as Unix epoch seconds UTC (0 = not set) + let sageInsertTime: TimeInterval + + /// Cannula insert time as Unix epoch seconds UTC (0 = not set) + let cageInsertTime: TimeInterval + + /// Insulin/pod insert time as Unix epoch seconds UTC (0 = not set) + let iageInsertTime: TimeInterval + + /// Min predicted BG in mg/dL (nil if not available) + let minBgMgdl: Double? + + /// Max predicted BG in mg/dL (nil if not available) + let maxBgMgdl: Double? + + // MARK: - Unit Context + + /// User's preferred display unit. Values are always stored in mg/dL; + /// this tells the display layer which unit to render. + let unit: Unit + + // MARK: - Loop Status + + /// True when LoopFollow detects the loop has not reported in 15+ minutes (Nightscout only). + let isNotLooping: Bool + + // MARK: - Renewal + + /// True when the Live Activity is within renewalWarning seconds of its renewal deadline. + /// The extension renders a "Tap to update" overlay so the user knows renewal is imminent. + let showRenewalOverlay: Bool + + // MARK: - Init + + init( + glucose: Double, + delta: Double, + trend: Trend, + updatedAt: Date, + iob: Double?, + cob: Double?, + projected: Double?, + override: String? = nil, + recBolus: Double? = nil, + battery: Double? = nil, + pumpBattery: Double? = nil, + basalRate: String = "", + pumpReservoirU: Double? = nil, + autosens: Double? = nil, + tdd: Double? = nil, + targetLowMgdl: Double? = nil, + targetHighMgdl: Double? = nil, + isfMgdlPerU: Double? = nil, + carbRatio: Double? = nil, + carbsToday: Double? = nil, + profileName: String? = nil, + sageInsertTime: TimeInterval = 0, + cageInsertTime: TimeInterval = 0, + iageInsertTime: TimeInterval = 0, + minBgMgdl: Double? = nil, + maxBgMgdl: Double? = nil, + unit: Unit, + isNotLooping: Bool, + showRenewalOverlay: Bool = false + ) { + self.glucose = glucose + self.delta = delta + self.trend = trend + self.updatedAt = updatedAt + self.iob = iob + self.cob = cob + self.projected = projected + self.override = override + self.recBolus = recBolus + self.battery = battery + self.pumpBattery = pumpBattery + self.basalRate = basalRate + self.pumpReservoirU = pumpReservoirU + self.autosens = autosens + self.tdd = tdd + self.targetLowMgdl = targetLowMgdl + self.targetHighMgdl = targetHighMgdl + self.isfMgdlPerU = isfMgdlPerU + self.carbRatio = carbRatio + self.carbsToday = carbsToday + self.profileName = profileName + self.sageInsertTime = sageInsertTime + self.cageInsertTime = cageInsertTime + self.iageInsertTime = iageInsertTime + self.minBgMgdl = minBgMgdl + self.maxBgMgdl = maxBgMgdl + self.unit = unit + self.isNotLooping = isNotLooping + self.showRenewalOverlay = showRenewalOverlay + } + + // MARK: - Derived Convenience + + /// Age of reading in seconds. + var age: TimeInterval { + Date().timeIntervalSince(updatedAt) + } + + /// Returns a copy of this snapshot with `showRenewalOverlay` set to the given value. + /// All other fields are preserved exactly. Use this instead of manually copying + /// every field when only the overlay flag needs to change. + func withRenewalOverlay(_ value: Bool) -> GlucoseSnapshot { + GlucoseSnapshot( + glucose: glucose, + delta: delta, + trend: trend, + updatedAt: updatedAt, + iob: iob, + cob: cob, + projected: projected, + override: override, + recBolus: recBolus, + battery: battery, + pumpBattery: pumpBattery, + basalRate: basalRate, + pumpReservoirU: pumpReservoirU, + autosens: autosens, + tdd: tdd, + targetLowMgdl: targetLowMgdl, + targetHighMgdl: targetHighMgdl, + isfMgdlPerU: isfMgdlPerU, + carbRatio: carbRatio, + carbsToday: carbsToday, + profileName: profileName, + sageInsertTime: sageInsertTime, + cageInsertTime: cageInsertTime, + iageInsertTime: iageInsertTime, + minBgMgdl: minBgMgdl, + maxBgMgdl: maxBgMgdl, + unit: unit, + isNotLooping: isNotLooping, + showRenewalOverlay: value + ) + } + + // MARK: - Codable + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(glucose, forKey: .glucose) + try container.encode(delta, forKey: .delta) + try container.encode(trend, forKey: .trend) + try container.encode(updatedAt.timeIntervalSince1970, forKey: .updatedAt) + try container.encodeIfPresent(iob, forKey: .iob) + try container.encodeIfPresent(cob, forKey: .cob) + try container.encodeIfPresent(projected, forKey: .projected) + try container.encodeIfPresent(override, forKey: .override) + try container.encodeIfPresent(recBolus, forKey: .recBolus) + try container.encodeIfPresent(battery, forKey: .battery) + try container.encodeIfPresent(pumpBattery, forKey: .pumpBattery) + try container.encode(basalRate, forKey: .basalRate) + try container.encodeIfPresent(pumpReservoirU, forKey: .pumpReservoirU) + try container.encodeIfPresent(autosens, forKey: .autosens) + try container.encodeIfPresent(tdd, forKey: .tdd) + try container.encodeIfPresent(targetLowMgdl, forKey: .targetLowMgdl) + try container.encodeIfPresent(targetHighMgdl, forKey: .targetHighMgdl) + try container.encodeIfPresent(isfMgdlPerU, forKey: .isfMgdlPerU) + try container.encodeIfPresent(carbRatio, forKey: .carbRatio) + try container.encodeIfPresent(carbsToday, forKey: .carbsToday) + try container.encodeIfPresent(profileName, forKey: .profileName) + try container.encode(sageInsertTime, forKey: .sageInsertTime) + try container.encode(cageInsertTime, forKey: .cageInsertTime) + try container.encode(iageInsertTime, forKey: .iageInsertTime) + try container.encodeIfPresent(minBgMgdl, forKey: .minBgMgdl) + try container.encodeIfPresent(maxBgMgdl, forKey: .maxBgMgdl) + try container.encode(unit, forKey: .unit) + try container.encode(isNotLooping, forKey: .isNotLooping) + try container.encode(showRenewalOverlay, forKey: .showRenewalOverlay) + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + glucose = try container.decode(Double.self, forKey: .glucose) + delta = try container.decode(Double.self, forKey: .delta) + trend = try container.decode(Trend.self, forKey: .trend) + updatedAt = try Date(timeIntervalSince1970: container.decode(Double.self, forKey: .updatedAt)) + iob = try container.decodeIfPresent(Double.self, forKey: .iob) + cob = try container.decodeIfPresent(Double.self, forKey: .cob) + projected = try container.decodeIfPresent(Double.self, forKey: .projected) + override = try container.decodeIfPresent(String.self, forKey: .override) + recBolus = try container.decodeIfPresent(Double.self, forKey: .recBolus) + battery = try container.decodeIfPresent(Double.self, forKey: .battery) + pumpBattery = try container.decodeIfPresent(Double.self, forKey: .pumpBattery) + basalRate = try container.decodeIfPresent(String.self, forKey: .basalRate) ?? "" + pumpReservoirU = try container.decodeIfPresent(Double.self, forKey: .pumpReservoirU) + autosens = try container.decodeIfPresent(Double.self, forKey: .autosens) + tdd = try container.decodeIfPresent(Double.self, forKey: .tdd) + targetLowMgdl = try container.decodeIfPresent(Double.self, forKey: .targetLowMgdl) + targetHighMgdl = try container.decodeIfPresent(Double.self, forKey: .targetHighMgdl) + isfMgdlPerU = try container.decodeIfPresent(Double.self, forKey: .isfMgdlPerU) + carbRatio = try container.decodeIfPresent(Double.self, forKey: .carbRatio) + carbsToday = try container.decodeIfPresent(Double.self, forKey: .carbsToday) + profileName = try container.decodeIfPresent(String.self, forKey: .profileName) + sageInsertTime = try container.decodeIfPresent(Double.self, forKey: .sageInsertTime) ?? 0 + cageInsertTime = try container.decodeIfPresent(Double.self, forKey: .cageInsertTime) ?? 0 + iageInsertTime = try container.decodeIfPresent(Double.self, forKey: .iageInsertTime) ?? 0 + minBgMgdl = try container.decodeIfPresent(Double.self, forKey: .minBgMgdl) + maxBgMgdl = try container.decodeIfPresent(Double.self, forKey: .maxBgMgdl) + unit = try container.decode(Unit.self, forKey: .unit) + isNotLooping = try container.decodeIfPresent(Bool.self, forKey: .isNotLooping) ?? false + showRenewalOverlay = try container.decodeIfPresent(Bool.self, forKey: .showRenewalOverlay) ?? false + } + + private enum CodingKeys: String, CodingKey { + case glucose, delta, trend, updatedAt + case iob, cob, projected + case override, recBolus, battery, pumpBattery, basalRate, pumpReservoirU + case autosens, tdd, targetLowMgdl, targetHighMgdl, isfMgdlPerU, carbRatio, carbsToday + case profileName, sageInsertTime, cageInsertTime, iageInsertTime, minBgMgdl, maxBgMgdl + case unit, isNotLooping, showRenewalOverlay + } +} + +// MARK: - Trend + +extension GlucoseSnapshot { + enum Trend: String, Codable, Hashable { + case up + case upSlight + case upFast + case flat + case down + case downSlight + case downFast + case unknown + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let raw = try container.decode(String.self) + self = Trend(rawValue: raw) ?? .unknown + } + } +} From bf1b42e7b801bd2faa19975ea9b445e6163b5597 Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 21 Mar 2026 10:33:04 -0400 Subject: [PATCH 14/55] Update LiveActivityManager.swift --- .../LiveActivity/LiveActivityManager.swift | 50 ++++--------------- 1 file changed, 10 insertions(+), 40 deletions(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 1838021ac..14cc3700d 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -340,49 +340,18 @@ final class LiveActivityManager { let overdueBy = Date().timeIntervalSince1970 - renewBy LogManager.shared.log(category: .general, message: "[LA] renewal deadline passed by \(Int(overdueBy))s, requesting new LA") + // Reset the deadline before building the snapshot so the provider's + // showRenewalOverlay computation returns false for the new activity. let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold) + Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970 + let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow") - // Build a fresh snapshot from the provider so all extended fields are populated. - // Strip the renewal overlay — the new LA has a clean deadline and should open - // without the warning visible on its first frame. - // Fall back to the passed-in snapshot if the provider build fails (e.g. stale data). + // Build fresh from the provider now that the deadline has been pushed forward. + // Fall back to the passed-in snapshot with the overlay stripped if the build fails. let provider = StorageCurrentGlucoseStateProvider() - let builtSnapshot = GlucoseSnapshotBuilder.build(from: provider) ?? snapshot - Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970 - - // Re-build with overlay suppressed now that the deadline has been reset. - let freshSnapshot = GlucoseSnapshotBuilder.build(from: provider) ?? GlucoseSnapshot( - glucose: builtSnapshot.glucose, - delta: builtSnapshot.delta, - trend: builtSnapshot.trend, - updatedAt: builtSnapshot.updatedAt, - iob: builtSnapshot.iob, - cob: builtSnapshot.cob, - projected: builtSnapshot.projected, - override: builtSnapshot.override, - recBolus: builtSnapshot.recBolus, - battery: builtSnapshot.battery, - pumpBattery: builtSnapshot.pumpBattery, - basalRate: builtSnapshot.basalRate, - pumpReservoirU: builtSnapshot.pumpReservoirU, - autosens: builtSnapshot.autosens, - tdd: builtSnapshot.tdd, - targetLowMgdl: builtSnapshot.targetLowMgdl, - targetHighMgdl: builtSnapshot.targetHighMgdl, - isfMgdlPerU: builtSnapshot.isfMgdlPerU, - carbRatio: builtSnapshot.carbRatio, - carbsToday: builtSnapshot.carbsToday, - profileName: builtSnapshot.profileName, - sageInsertTime: builtSnapshot.sageInsertTime, - cageInsertTime: builtSnapshot.cageInsertTime, - iageInsertTime: builtSnapshot.iageInsertTime, - minBgMgdl: builtSnapshot.minBgMgdl, - maxBgMgdl: builtSnapshot.maxBgMgdl, - unit: builtSnapshot.unit, - isNotLooping: builtSnapshot.isNotLooping, - showRenewalOverlay: false - ) + let freshSnapshot = GlucoseSnapshotBuilder.build(from: provider) + ?? snapshot.withRenewalOverlay(false) let state = GlucoseLiveActivityAttributes.ContentState( snapshot: freshSnapshot, @@ -413,13 +382,14 @@ final class LiveActivityManager { LogManager.shared.log(category: .general, message: "[LA] Live Activity renewed successfully id=\(newActivity.id)") return true } catch { + // Renewal failed — roll back the deadline so the next refresh retries. + Storage.shared.laRenewBy.value = renewBy Storage.shared.laRenewalFailed.value = true LogManager.shared.log(category: .general, message: "[LA] renewal failed, keeping existing LA: \(error)") return false } } - private func performRefresh(reason: String) { let provider = StorageCurrentGlucoseStateProvider() guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { From a47e1da914340cc3bbae8bb25471b6d7721d244f Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 21 Mar 2026 10:39:16 -0400 Subject: [PATCH 15/55] Update LiveActivityManager.swift --- LoopFollow/LiveActivity/LiveActivityManager.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 14cc3700d..5488dc7b6 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -150,8 +150,11 @@ final class LiveActivityManager { refreshFromCurrentState(reason: "audio-session-failed") } - static let renewalThreshold: TimeInterval = 7.5 * 3600 - static let renewalWarning: TimeInterval = 20 * 60 + // static let renewalThreshold: TimeInterval = 7.5 * 3600 + // static let renewalWarning: TimeInterval = 20 * 60 + + static let renewalThreshold: TimeInterval = 15 * 60 + static let renewalWarning: TimeInterval = 10 * 60 private(set) var current: Activity? private var stateObserverTask: Task? From a19cbee149fcb39a919cf380f7df904f596232da Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 21 Mar 2026 10:49:17 -0400 Subject: [PATCH 16/55] Update GlucoseLiveActivityAttributes.swift --- .../LiveActivity/GlucoseLiveActivityAttributes.swift | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift b/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift index 2f80de3d3..af3df390d 100644 --- a/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift +++ b/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift @@ -30,16 +30,6 @@ struct GlucoseLiveActivityAttributes: ActivityAttributes { producedAt = Date(timeIntervalSince1970: producedAtInterval) } - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(snapshot, forKey: .snapshot) - try container.encode(seq, forKey: .seq) - try container.encode(reason, forKey: .reason) - try container.encode(producedAt.timeIntervalSince1970, forKey: .producedAt) - } - - - func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(snapshot, forKey: .snapshot) From b62e14f2513dc70e0fe7f60b49551dfe1d5677b4 Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 21 Mar 2026 12:10:37 -0400 Subject: [PATCH 17/55] Update LiveActivityManager.swift --- LoopFollow/LiveActivity/LiveActivityManager.swift | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 5488dc7b6..14cc3700d 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -150,11 +150,8 @@ final class LiveActivityManager { refreshFromCurrentState(reason: "audio-session-failed") } - // static let renewalThreshold: TimeInterval = 7.5 * 3600 - // static let renewalWarning: TimeInterval = 20 * 60 - - static let renewalThreshold: TimeInterval = 15 * 60 - static let renewalWarning: TimeInterval = 10 * 60 + static let renewalThreshold: TimeInterval = 7.5 * 3600 + static let renewalWarning: TimeInterval = 20 * 60 private(set) var current: Activity? private var stateObserverTask: Task? From 1867c82e84f7cc450f4b09a3ff639474a0a5ef7c Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 21 Mar 2026 13:17:24 -0400 Subject: [PATCH 18/55] Add LA expiry notification; fix OS-dismissed vs user-dismissed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - When renewIfNeeded fails in the background (app can't start a new LA because it's not visible), schedule a local notification on the first failure: "Live Activity Expiring — Open LoopFollow to restart." Subsequent failures in the same cycle are suppressed. Notification is cancelled if renewal later succeeds or forceRestart is called. - In attachStateObserver, distinguish iOS force-dismiss (laRenewalFailed == true) from user swipe (laRenewalFailed == false). OS-dismissed LAs no longer set dismissedByUser, so opening the app triggers auto-restart as expected. Co-Authored-By: Claude Sonnet 4.6 --- .../LiveActivity/LiveActivityManager.swift | 50 +++++++++++++++++-- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 14cc3700d..93d63a1de 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -8,6 +8,7 @@ import Foundation import os import UIKit +import UserNotifications /// Live Activity manager for LoopFollow. @@ -284,6 +285,7 @@ final class LiveActivityManager { dismissedByUser = false Storage.shared.laRenewBy.value = 0 Storage.shared.laRenewalFailed.value = false + cancelRenewalFailedNotification() current = nil updateTask?.cancel(); updateTask = nil tokenObservationTask?.cancel(); tokenObservationTask = nil @@ -378,14 +380,19 @@ final class LiveActivityManager { bind(to: newActivity, logReason: "renew") Storage.shared.laRenewalFailed.value = false + cancelRenewalFailedNotification() GlucoseSnapshotStore.shared.save(freshSnapshot) LogManager.shared.log(category: .general, message: "[LA] Live Activity renewed successfully id=\(newActivity.id)") return true } catch { // Renewal failed — roll back the deadline so the next refresh retries. Storage.shared.laRenewBy.value = renewBy + let isFirstFailure = !Storage.shared.laRenewalFailed.value Storage.shared.laRenewalFailed.value = true LogManager.shared.log(category: .general, message: "[LA] renewal failed, keeping existing LA: \(error)") + if isFirstFailure { + scheduleRenewalFailedNotification() + } return false } } @@ -550,6 +557,33 @@ final class LiveActivityManager { // Activity will restart on next BG refresh via refreshFromCurrentState() } + // MARK: - Renewal Notifications + + private func scheduleRenewalFailedNotification() { + let content = UNMutableNotificationContent() + content.title = "Live Activity Expiring" + content.body = "Live Activity will expire soon. Open LoopFollow to restart." + content.sound = .default + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) + let request = UNNotificationRequest( + identifier: "loopfollow.la.renewal.failed", + content: content, + trigger: trigger + ) + UNUserNotificationCenter.current().add(request) { error in + if let error = error { + LogManager.shared.log(category: .general, message: "[LA] failed to schedule renewal notification: \(error)") + } + } + LogManager.shared.log(category: .general, message: "[LA] renewal failed notification scheduled") + } + + private func cancelRenewalFailedNotification() { + let id = "loopfollow.la.renewal.failed" + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [id]) + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [id]) + } + private func attachStateObserver(to activity: Activity) { stateObserverTask?.cancel() stateObserverTask = Task { @@ -562,11 +596,17 @@ final class LiveActivityManager { LogManager.shared.log(category: .general, message: "Live Activity cleared id=\(activity.id)", isDebug: true) } if state == .dismissed { - // User manually swiped away the LA. Block auto-restart until - // the user explicitly restarts via button or App Intent. - // laEnabled is left true — the user's preference is preserved. - dismissedByUser = true - LogManager.shared.log(category: .general, message: "Live Activity dismissed by user — auto-restart blocked until explicit restart") + if Storage.shared.laRenewalFailed.value { + // iOS force-dismissed after 8-hour limit with a failed renewal. + // Allow auto-restart when the user opens the app. + LogManager.shared.log(category: .general, message: "Live Activity dismissed by iOS after expiry — auto-restart enabled") + } else { + // User manually swiped away the LA. Block auto-restart until + // the user explicitly restarts via button or App Intent. + // laEnabled is left true — the user's preference is preserved. + dismissedByUser = true + LogManager.shared.log(category: .general, message: "Live Activity dismissed by user — auto-restart blocked until explicit restart") + } } } } From 8446fe7b595771eb9eabe5531f22184d82733fa8 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 21 Mar 2026 14:43:30 -0400 Subject: [PATCH 19/55] Remove dead pendingLATapNavigation code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Force-quitting an app kills its Live Activities, so cold-launch via LA tap only occurs when iOS terminates the app — in which case scene(_:openURLContexts:) already handles navigation correctly via DispatchQueue.main.async. The flag was never set and never needed. Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/Application/SceneDelegate.swift | 8 -------- 1 file changed, 8 deletions(-) diff --git a/LoopFollow/Application/SceneDelegate.swift b/LoopFollow/Application/SceneDelegate.swift index a8fbb236f..2439f3668 100644 --- a/LoopFollow/Application/SceneDelegate.swift +++ b/LoopFollow/Application/SceneDelegate.swift @@ -32,16 +32,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { func sceneDidBecomeActive(_: UIScene) { // Called when the scene has moved from an inactive state to an active state. // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. - if pendingLATapNavigation { - pendingLATapNavigation = false - NotificationCenter.default.post(name: .liveActivityDidForeground, object: nil) - } } - /// Set when loopfollow://la-tap arrives before the scene is fully active. - /// Consumed in sceneDidBecomeActive once the view hierarchy is restored. - private var pendingLATapNavigation = false - func scene(_: UIScene, openURLContexts URLContexts: Set) { guard URLContexts.contains(where: { $0.url.scheme == "loopfollow" && $0.url.host == "la-tap" }) else { return } // scene(_:openURLContexts:) fires after sceneDidBecomeActive when the app From a026e11af99a9de155764d7ded92bfedaea9de7a Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 21 Mar 2026 18:42:40 -0400 Subject: [PATCH 20/55] Code quality pass: log categories, SwiftFormat, dead code cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BackgroundRefreshManager: all logs → .taskScheduler - AppDelegate: APNs registration/notification logs → .apns - APNSClient: all logs → .apns - BackgroundTaskAudio: restore isDebug:true on silent audio log; fix double blank line - LiveActivityManager: fix trailing whitespace; remove double blank line; SwiftFormat - GlucoseSnapshotBuilder: fix file header (date → standard LoopFollow header) - LoopFollowLiveActivity: remove dead commented-out activityID property - SwiftFormat applied across all reviewed LiveActivity/, Storage/, extension files Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/Application/AppDelegate.swift | 24 ++--- LoopFollow/Application/SceneDelegate.swift | 4 +- .../Helpers/BackgroundRefreshManager.swift | 12 +-- LoopFollow/Helpers/BackgroundTaskAudio.swift | 7 +- LoopFollow/LiveActivity/APNSClient.swift | 36 ++++--- .../GlucoseLiveActivityAttributes.swift | 4 +- LoopFollow/LiveActivity/GlucoseSnapshot.swift | 5 +- .../LiveActivity/GlucoseSnapshotBuilder.swift | 13 ++- .../LiveActivity/GlucoseSnapshotStore.swift | 2 +- .../LiveActivity/LAAppGroupSettings.swift | 94 +++++++++---------- .../LiveActivity/LiveActivityManager.swift | 79 ++++++++-------- .../LiveActivity/PreferredGlucoseUnit.swift | 4 +- .../RestartLiveActivityIntent.swift | 2 +- LoopFollow/Storage/Storage.swift | 18 ++-- .../LoopFollowLiveActivity.swift | 69 +++++++------- 15 files changed, 188 insertions(+), 185 deletions(-) diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index c2cc460a8..d79de7d18 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -6,7 +6,7 @@ import EventKit import UIKit import UserNotifications -@UIApplicationMain +@main class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? let notificationCenter = UNUserNotificationCenter.current() @@ -58,23 +58,23 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // MARK: - Remote Notifications - // Called when successfully registered for remote notifications + /// Called when successfully registered for remote notifications func application(_: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined() Observable.shared.loopFollowDeviceToken.value = tokenString - LogManager.shared.log(category: .general, message: "Successfully registered for remote notifications with token: \(tokenString)") + LogManager.shared.log(category: .apns, message: "Successfully registered for remote notifications with token: \(tokenString)") } - // Called when failed to register for remote notifications + /// Called when failed to register for remote notifications func application(_: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { - LogManager.shared.log(category: .general, message: "Failed to register for remote notifications: \(error.localizedDescription)") + LogManager.shared.log(category: .apns, message: "Failed to register for remote notifications: \(error.localizedDescription)") } - // Called when a remote notification is received + /// Called when a remote notification is received func application(_: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { - LogManager.shared.log(category: .general, message: "Received remote notification: \(userInfo)") + LogManager.shared.log(category: .apns, message: "Received remote notification: \(userInfo)") // Check if this is a response notification from Loop or Trio if let aps = userInfo["aps"] as? [String: Any] { @@ -82,7 +82,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { if let alert = aps["alert"] as? [String: Any] { let title = alert["title"] as? String ?? "" let body = alert["body"] as? String ?? "" - LogManager.shared.log(category: .general, message: "Notification - Title: \(title), Body: \(body)") + LogManager.shared.log(category: .apns, message: "Notification - Title: \(title), Body: \(body)") } // Handle silent notification (content-available) @@ -90,11 +90,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // This is a silent push, nothing implemented but logging for now if let commandStatus = userInfo["command_status"] as? String { - LogManager.shared.log(category: .general, message: "Command status: \(commandStatus)") + LogManager.shared.log(category: .apns, message: "Command status: \(commandStatus)") } if let commandType = userInfo["command_type"] as? String { - LogManager.shared.log(category: .general, message: "Command type: \(commandType)") + LogManager.shared.log(category: .apns, message: "Command type: \(commandType)") } } } @@ -122,7 +122,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options _: UIScene.ConnectionOptions) -> UISceneConfiguration { // Called when a new scene session is being created. // Use this method to select a configuration to create the new scene with. - return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) } func application(_: UIApplication, didDiscardSceneSessions _: Set) { @@ -178,7 +178,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func userNotificationCenter(_: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { if response.actionIdentifier == "OPEN_APP_ACTION" { - if let window = window { + if let window { window.rootViewController?.dismiss(animated: true, completion: nil) window.rootViewController?.present(MainViewController(), animated: true, completion: nil) } diff --git a/LoopFollow/Application/SceneDelegate.swift b/LoopFollow/Application/SceneDelegate.swift index 2439f3668..3819a7ac6 100644 --- a/LoopFollow/Application/SceneDelegate.swift +++ b/LoopFollow/Application/SceneDelegate.swift @@ -63,7 +63,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { (UIApplication.shared.delegate as? AppDelegate)?.saveContext() } - // Handle the UIApplicationShortcutItem when the user taps on the Home Screen Quick Action. This function toggles the "Speak BG" setting in UserDefaultsRepository, speaks the current state (on/off) using AVSpeechSynthesizer, and updates the Quick Action appearance. + /// Handle the UIApplicationShortcutItem when the user taps on the Home Screen Quick Action. This function toggles the "Speak BG" setting in UserDefaultsRepository, speaks the current state (on/off) using AVSpeechSynthesizer, and updates the Quick Action appearance. func handleShortcutItem(_ shortcutItem: UIApplicationShortcutItem) { if let bundleIdentifier = Bundle.main.bundleIdentifier { let expectedType = bundleIdentifier + ".toggleSpeakBG" @@ -76,7 +76,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } } - // The following method is called when the user taps on the Home Screen Quick Action + /// The following method is called when the user taps on the Home Screen Quick Action func windowScene(_: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler _: @escaping (Bool) -> Void) { handleShortcutItem(shortcutItem) } diff --git a/LoopFollow/Helpers/BackgroundRefreshManager.swift b/LoopFollow/Helpers/BackgroundRefreshManager.swift index f73ab33c6..1faa7f07c 100644 --- a/LoopFollow/Helpers/BackgroundRefreshManager.swift +++ b/LoopFollow/Helpers/BackgroundRefreshManager.swift @@ -18,10 +18,10 @@ class BackgroundRefreshManager { } private func handleRefreshTask(_ task: BGAppRefreshTask) { - LogManager.shared.log(category: .general, message: "BGAppRefreshTask fired") + LogManager.shared.log(category: .taskScheduler, message: "BGAppRefreshTask fired") task.expirationHandler = { - LogManager.shared.log(category: .general, message: "BGAppRefreshTask expired") + LogManager.shared.log(category: .taskScheduler, message: "BGAppRefreshTask expired") task.setTaskCompleted(success: false) self.scheduleRefresh() } @@ -29,12 +29,12 @@ class BackgroundRefreshManager { DispatchQueue.main.async { if let mainVC = self.getMainViewController() { if !mainVC.backgroundTask.player.isPlaying { - LogManager.shared.log(category: .general, message: "audio dead, attempting restart") + LogManager.shared.log(category: .taskScheduler, message: "audio dead, attempting restart") mainVC.backgroundTask.stopBackgroundTask() mainVC.backgroundTask.startBackgroundTask() - LogManager.shared.log(category: .general, message: "audio restart initiated") + LogManager.shared.log(category: .taskScheduler, message: "audio restart initiated") } else { - LogManager.shared.log(category: .general, message: "audio alive, no action needed", isDebug: true) + LogManager.shared.log(category: .taskScheduler, message: "audio alive, no action needed", isDebug: true) } } self.scheduleRefresh() @@ -48,7 +48,7 @@ class BackgroundRefreshManager { do { try BGTaskScheduler.shared.submit(request) } catch { - LogManager.shared.log(category: .general, message: "Failed to schedule BGAppRefreshTask: \(error)") + LogManager.shared.log(category: .taskScheduler, message: "Failed to schedule BGAppRefreshTask: \(error)") } } diff --git a/LoopFollow/Helpers/BackgroundTaskAudio.swift b/LoopFollow/Helpers/BackgroundTaskAudio.swift index c5de42fd9..25aa6b3c8 100755 --- a/LoopFollow/Helpers/BackgroundTaskAudio.swift +++ b/LoopFollow/Helpers/BackgroundTaskAudio.swift @@ -26,7 +26,7 @@ class BackgroundTask { LogManager.shared.log(category: .general, message: "Silent audio stopped", isDebug: true) } - @objc fileprivate func interruptedAudio(_ notification: Notification) { + @objc private func interruptedAudio(_ notification: Notification) { guard notification.name == AVAudioSession.interruptionNotification, let userInfo = notification.userInfo, let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, @@ -53,13 +53,12 @@ class BackgroundTask { self?.playAudio() } - @unknown default: break } } - fileprivate func playAudio() { + private func playAudio() { let attemptDesc = retryCount == 0 ? "initial attempt" : "retry \(retryCount)/\(maxRetries)" do { let bundle = Bundle.main.path(forResource: "blank", ofType: "wav") @@ -73,7 +72,7 @@ class BackgroundTask { player.prepareToPlay() player.play() retryCount = 0 - LogManager.shared.log(category: .general, message: "Silent audio playing (\(attemptDesc))") + LogManager.shared.log(category: .general, message: "Silent audio playing (\(attemptDesc))", isDebug: true) } catch { LogManager.shared.log(category: .general, message: "playAudio failed (\(attemptDesc)), error: \(error)") if retryCount < maxRetries { diff --git a/LoopFollow/LiveActivity/APNSClient.swift b/LoopFollow/LiveActivity/APNSClient.swift index 94cee2a85..8755b1b27 100644 --- a/LoopFollow/LiveActivity/APNSClient.swift +++ b/LoopFollow/LiveActivity/APNSClient.swift @@ -21,25 +21,33 @@ class APNSClient { : "https://api.sandbox.push.apple.com" } - private var lfKeyId: String { Storage.shared.lfKeyId.value } - private var lfTeamId: String { BuildDetails.default.teamID ?? "" } - private var lfApnsKey: String { Storage.shared.lfApnsKey.value } + private var lfKeyId: String { + Storage.shared.lfKeyId.value + } + + private var lfTeamId: String { + BuildDetails.default.teamID ?? "" + } + + private var lfApnsKey: String { + Storage.shared.lfApnsKey.value + } // MARK: - Send Live Activity Update func sendLiveActivityUpdate( pushToken: String, - state: GlucoseLiveActivityAttributes.ContentState + state: GlucoseLiveActivityAttributes.ContentState, ) async { guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: lfKeyId, teamId: lfTeamId, apnsKey: lfApnsKey) else { - LogManager.shared.log(category: .general, message: "APNs failed to generate JWT for Live Activity push") + LogManager.shared.log(category: .apns, message: "APNs failed to generate JWT for Live Activity push") return } let payload = buildPayload(state: state) guard let url = URL(string: "\(apnsHost)/3/device/\(pushToken)") else { - LogManager.shared.log(category: .general, message: "APNs invalid URL", isDebug: true) + LogManager.shared.log(category: .apns, message: "APNs invalid URL", isDebug: true) return } @@ -58,38 +66,38 @@ class APNSClient { if let httpResponse = response as? HTTPURLResponse { switch httpResponse.statusCode { case 200: - LogManager.shared.log(category: .general, message: "APNs push sent successfully", isDebug: true) + LogManager.shared.log(category: .apns, message: "APNs push sent successfully", isDebug: true) case 400: let responseBody = String(data: data, encoding: .utf8) ?? "empty" - LogManager.shared.log(category: .general, message: "APNs bad request (400) — malformed payload: \(responseBody)") + LogManager.shared.log(category: .apns, message: "APNs bad request (400) — malformed payload: \(responseBody)") case 403: // JWT rejected — force regenerate on next push JWTManager.shared.invalidateCache() - LogManager.shared.log(category: .general, message: "APNs JWT rejected (403) — token cache cleared, will regenerate") + LogManager.shared.log(category: .apns, message: "APNs JWT rejected (403) — token cache cleared, will regenerate") case 404, 410: // Activity token not found or expired — end and restart on next refresh let reason = httpResponse.statusCode == 410 ? "expired (410)" : "not found (404)" - LogManager.shared.log(category: .general, message: "APNs token \(reason) — restarting Live Activity") + LogManager.shared.log(category: .apns, message: "APNs token \(reason) — restarting Live Activity") LiveActivityManager.shared.handleExpiredToken() case 429: - LogManager.shared.log(category: .general, message: "APNs rate limited (429) — will retry on next refresh") + LogManager.shared.log(category: .apns, message: "APNs rate limited (429) — will retry on next refresh") case 500 ... 599: let responseBody = String(data: data, encoding: .utf8) ?? "empty" - LogManager.shared.log(category: .general, message: "APNs server error (\(httpResponse.statusCode)) — will retry on next refresh: \(responseBody)") + LogManager.shared.log(category: .apns, message: "APNs server error (\(httpResponse.statusCode)) — will retry on next refresh: \(responseBody)") default: let responseBody = String(data: data, encoding: .utf8) ?? "empty" - LogManager.shared.log(category: .general, message: "APNs push failed status=\(httpResponse.statusCode) body=\(responseBody)") + LogManager.shared.log(category: .apns, message: "APNs push failed status=\(httpResponse.statusCode) body=\(responseBody)") } } } catch { - LogManager.shared.log(category: .general, message: "APNs error: \(error.localizedDescription)") + LogManager.shared.log(category: .apns, message: "APNs error: \(error.localizedDescription)") } } diff --git a/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift b/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift index af3df390d..6d6ddb9a9 100644 --- a/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift +++ b/LoopFollow/LiveActivity/GlucoseLiveActivityAttributes.swift @@ -8,7 +8,7 @@ import ActivityKit import Foundation struct GlucoseLiveActivityAttributes: ActivityAttributes { - public struct ContentState: Codable, Hashable { + struct ContentState: Codable, Hashable { let snapshot: GlucoseSnapshot let seq: Int let reason: String @@ -29,7 +29,7 @@ struct GlucoseLiveActivityAttributes: ActivityAttributes { let producedAtInterval = try container.decode(Double.self, forKey: .producedAt) producedAt = Date(timeIntervalSince1970: producedAtInterval) } - + func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(snapshot, forKey: .snapshot) diff --git a/LoopFollow/LiveActivity/GlucoseSnapshot.swift b/LoopFollow/LiveActivity/GlucoseSnapshot.swift index fad01e6a9..8860391c2 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshot.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshot.swift @@ -7,7 +7,6 @@ import Foundation /// Live Activity, future Watch complication, and CarPlay. /// struct GlucoseSnapshot: Codable, Equatable, Hashable { - // MARK: - Units enum Unit: String, Codable, Hashable { @@ -147,7 +146,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { maxBgMgdl: Double? = nil, unit: Unit, isNotLooping: Bool, - showRenewalOverlay: Bool = false + showRenewalOverlay: Bool = false, ) { self.glucose = glucose self.delta = delta @@ -220,7 +219,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { maxBgMgdl: maxBgMgdl, unit: unit, isNotLooping: isNotLooping, - showRenewalOverlay: value + showRenewalOverlay: value, ) } diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift index 03b1926af..40ff076af 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift @@ -1,12 +1,11 @@ +// LoopFollow // GlucoseSnapshotBuilder.swift -// 2026-03-21 import Foundation /// Provides the latest glucose-relevant values from LoopFollow's single source of truth. /// Intentionally provider-agnostic (Nightscout vs Dexcom doesn't matter). protocol CurrentGlucoseStateProviding { - // MARK: - Core Glucose /// Canonical glucose value in mg/dL. @@ -114,7 +113,7 @@ enum GlucoseSnapshotBuilder { LogManager.shared.log( category: .general, message: "GlucoseSnapshotBuilder: missing/invalid core values glucoseMgdl=\(provider.glucoseMgdl?.description ?? "nil") updatedAt=\(provider.updatedAt?.description ?? "nil")", - isDebug: true + isDebug: true, ) return nil } @@ -130,7 +129,7 @@ enum GlucoseSnapshotBuilder { LogManager.shared.log( category: .general, message: "LA snapshot built: updatedAt=\(updatedAt) interval=\(updatedAt.timeIntervalSince1970)", - isDebug: true + isDebug: true, ) return GlucoseSnapshot( @@ -162,7 +161,7 @@ enum GlucoseSnapshotBuilder { maxBgMgdl: provider.maxBgMgdl, unit: preferredUnit, isNotLooping: provider.isNotLooping, - showRenewalOverlay: provider.showRenewalOverlay + showRenewalOverlay: provider.showRenewalOverlay, ) } @@ -171,8 +170,8 @@ enum GlucoseSnapshotBuilder { private static func mapTrend(_ code: String?) -> GlucoseSnapshot.Trend { guard let raw = code? - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased(), + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased(), !raw.isEmpty else { return .unknown } diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift b/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift index b45a7a0b9..7951e122a 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift @@ -67,7 +67,7 @@ final class GlucoseSnapshotStore { throw NSError( domain: "GlucoseSnapshotStore", code: 1, - userInfo: [NSLocalizedDescriptionKey: "App Group containerURL is nil for id=\(groupID)"] + userInfo: [NSLocalizedDescriptionKey: "App Group containerURL is nil for id=\(groupID)"], ) } return containerURL.appendingPathComponent(fileName, isDirectory: false) diff --git a/LoopFollow/LiveActivity/LAAppGroupSettings.swift b/LoopFollow/LiveActivity/LAAppGroupSettings.swift index 4e1d7b126..8fedeb155 100644 --- a/LoopFollow/LiveActivity/LAAppGroupSettings.swift +++ b/LoopFollow/LiveActivity/LAAppGroupSettings.swift @@ -44,56 +44,56 @@ enum LiveActivitySlotOption: String, CaseIterable, Codable { /// Human-readable label shown in the slot picker in Settings. var displayName: String { switch self { - case .none: return "Empty" - case .delta: return "Delta" - case .projectedBG: return "Projected BG" - case .minMax: return "Min/Max" - case .iob: return "IOB" - case .cob: return "COB" - case .recBolus: return "Rec. Bolus" - case .autosens: return "Autosens" - case .tdd: return "TDD" - case .basal: return "Basal" - case .pump: return "Pump" - case .pumpBattery: return "Pump Battery" - case .battery: return "Battery" - case .target: return "Target" - case .isf: return "ISF" - case .carbRatio: return "CR" - case .sage: return "SAGE" - case .cage: return "CAGE" - case .iage: return "IAGE" - case .carbsToday: return "Carbs today" - case .override: return "Override" - case .profile: return "Profile" + case .none: "Empty" + case .delta: "Delta" + case .projectedBG: "Projected BG" + case .minMax: "Min/Max" + case .iob: "IOB" + case .cob: "COB" + case .recBolus: "Rec. Bolus" + case .autosens: "Autosens" + case .tdd: "TDD" + case .basal: "Basal" + case .pump: "Pump" + case .pumpBattery: "Pump Battery" + case .battery: "Battery" + case .target: "Target" + case .isf: "ISF" + case .carbRatio: "CR" + case .sage: "SAGE" + case .cage: "CAGE" + case .iage: "IAGE" + case .carbsToday: "Carbs today" + case .override: "Override" + case .profile: "Profile" } } /// Short label used inside the MetricBlock on the Live Activity card. var gridLabel: String { switch self { - case .none: return "" - case .delta: return "Delta" - case .projectedBG: return "Proj" - case .minMax: return "Min/Max" - case .iob: return "IOB" - case .cob: return "COB" - case .recBolus: return "Rec." - case .autosens: return "Sens" - case .tdd: return "TDD" - case .basal: return "Basal" - case .pump: return "Pump" - case .pumpBattery: return "Pump%" - case .battery: return "Bat." - case .target: return "Target" - case .isf: return "ISF" - case .carbRatio: return "CR" - case .sage: return "SAGE" - case .cage: return "CAGE" - case .iage: return "IAGE" - case .carbsToday: return "Carbs" - case .override: return "Ovrd" - case .profile: return "Prof" + case .none: "" + case .delta: "Delta" + case .projectedBG: "Proj" + case .minMax: "Min/Max" + case .iob: "IOB" + case .cob: "COB" + case .recBolus: "Rec." + case .autosens: "Sens" + case .tdd: "TDD" + case .basal: "Basal" + case .pump: "Pump" + case .pumpBattery: "Pump%" + case .battery: "Bat." + case .target: "Target" + case .isf: "ISF" + case .carbRatio: "CR" + case .sage: "SAGE" + case .cage: "CAGE" + case .iage: "IAGE" + case .carbsToday: "Carbs" + case .override: "Ovrd" + case .profile: "Prof" } } @@ -101,8 +101,8 @@ enum LiveActivitySlotOption: String, CaseIterable, Codable { /// no Loop data). The widget renders "—" in those cases. var isOptional: Bool { switch self { - case .none, .delta: return false - default: return true + case .none, .delta: false + default: true } } } @@ -162,7 +162,7 @@ enum LAAppGroupSettings { /// - Parameter slots: Array of exactly 4 `LiveActivitySlotOption` values; /// extra elements are ignored, missing elements are filled with `.none`. static func setSlots(_ slots: [LiveActivitySlotOption]) { - let raw = slots.prefix(4).map { $0.rawValue } + let raw = slots.prefix(4).map(\.rawValue) defaults?.set(raw, forKey: Keys.slots) } diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 93d63a1de..a86ee28b8 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -10,7 +10,7 @@ import os import UIKit import UserNotifications -/// Live Activity manager for LoopFollow. +// Live Activity manager for LoopFollow. final class LiveActivityManager { static let shared = LiveActivityManager() @@ -19,25 +19,25 @@ final class LiveActivityManager { self, selector: #selector(handleForeground), name: UIApplication.willEnterForegroundNotification, - object: nil + object: nil, ) NotificationCenter.default.addObserver( self, selector: #selector(handleDidBecomeActive), name: UIApplication.didBecomeActiveNotification, - object: nil + object: nil, ) NotificationCenter.default.addObserver( self, selector: #selector(handleWillResignActive), name: UIApplication.willResignActiveNotification, - object: nil + object: nil, ) NotificationCenter.default.addObserver( self, selector: #selector(handleBackgroundAudioFailed), name: .backgroundAudioFailed, - object: nil + object: nil, ) } @@ -56,7 +56,7 @@ final class LiveActivityManager { LAAppGroupSettings.setThresholds( lowMgdl: Storage.shared.lowLine.value, - highMgdl: Storage.shared.highLine.value + highMgdl: Storage.shared.highLine.value, ) GlucoseSnapshotStore.shared.save(snapshot) @@ -66,12 +66,12 @@ final class LiveActivityManager { snapshot: snapshot, seq: nextSeq, reason: "resign-active", - producedAt: Date() + producedAt: Date(), ) let content = ActivityContent( state: state, staleDate: Date(timeIntervalSince1970: Storage.shared.laRenewBy.value), - relevanceScore: 100.0 + relevanceScore: 100.0, ) Task { @@ -175,16 +175,16 @@ final class LiveActivityManager { LogManager.shared.log(category: .general, message: "Live Activity not authorized") return } - + if let existing = Activity.activities.first { bind(to: existing, logReason: "reuse") Storage.shared.laRenewalFailed.value = false return } - + do { let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow") - + // Prefer a freshly built snapshot so all extended fields are populated. // Fall back to the persisted store (covers cold-start with real data), // then to a zero seed (true first-ever launch with no data yet). @@ -200,20 +200,20 @@ final class LiveActivityManager { cob: nil, projected: nil, unit: .mgdl, - isNotLooping: false + isNotLooping: false, ) - + let initialState = GlucoseLiveActivityAttributes.ContentState( snapshot: seedSnapshot, seq: 0, reason: "start", - producedAt: Date() + producedAt: Date(), ) - + let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold) let content = ActivityContent(state: initialState, staleDate: renewDeadline) let activity = try Activity.request(attributes: attributes, content: content, pushType: .token) - + bind(to: activity, logReason: "start-new") Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970 Storage.shared.laRenewalFailed.value = false @@ -223,7 +223,6 @@ final class LiveActivityManager { } } - /// Called from applicationWillTerminate. Ends the LA synchronously (blocking /// up to 3 s) so it clears from the lock screen before the process exits. /// Does not clear laEnabled — the user's preference is preserved for relaunch. @@ -257,11 +256,11 @@ final class LiveActivityManager { cob: nil, projected: nil, unit: .mgdl, - isNotLooping: false + isNotLooping: false, ), seq: seq, reason: "end", - producedAt: Date() + producedAt: Date(), ) let content = ActivityContent(state: finalState, staleDate: nil) @@ -309,7 +308,7 @@ final class LiveActivityManager { if let snapshot = GlucoseSnapshotBuilder.build(from: provider) { LAAppGroupSettings.setThresholds( lowMgdl: Storage.shared.lowLine.value, - highMgdl: Storage.shared.highLine.value + highMgdl: Storage.shared.highLine.value, ) GlucoseSnapshotStore.shared.save(snapshot) } @@ -335,41 +334,41 @@ final class LiveActivityManager { /// Returns true if renewal was performed (caller should return early). private func renewIfNeeded(snapshot: GlucoseSnapshot) -> Bool { guard let oldActivity = current else { return false } - + let renewBy = Storage.shared.laRenewBy.value guard renewBy > 0, Date().timeIntervalSince1970 >= renewBy else { return false } - + let overdueBy = Date().timeIntervalSince1970 - renewBy LogManager.shared.log(category: .general, message: "[LA] renewal deadline passed by \(Int(overdueBy))s, requesting new LA") - + // Reset the deadline before building the snapshot so the provider's // showRenewalOverlay computation returns false for the new activity. let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold) Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970 - + let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow") - + // Build fresh from the provider now that the deadline has been pushed forward. // Fall back to the passed-in snapshot with the overlay stripped if the build fails. let provider = StorageCurrentGlucoseStateProvider() let freshSnapshot = GlucoseSnapshotBuilder.build(from: provider) ?? snapshot.withRenewalOverlay(false) - + let state = GlucoseLiveActivityAttributes.ContentState( snapshot: freshSnapshot, seq: seq, reason: "renew", - producedAt: Date() + producedAt: Date(), ) let content = ActivityContent(state: state, staleDate: renewDeadline) - + do { let newActivity = try Activity.request(attributes: attributes, content: content, pushType: .token) - + Task { await oldActivity.end(nil, dismissalPolicy: .immediate) } - + updateTask?.cancel() updateTask = nil tokenObservationTask?.cancel() @@ -377,7 +376,7 @@ final class LiveActivityManager { stateObserverTask?.cancel() stateObserverTask = nil pushToken = nil - + bind(to: newActivity, logReason: "renew") Storage.shared.laRenewalFailed.value = false cancelRenewalFailedNotification() @@ -424,7 +423,7 @@ final class LiveActivityManager { } LAAppGroupSettings.setThresholds( lowMgdl: Storage.shared.lowLine.value, - highMgdl: Storage.shared.highLine.value + highMgdl: Storage.shared.highLine.value, ) GlucoseSnapshotStore.shared.save(snapshot) guard ActivityAuthorizationInfo().areActivitiesEnabled else { @@ -469,21 +468,21 @@ final class LiveActivityManager { snapshot: snapshot, seq: nextSeq, reason: reason, - producedAt: Date() + producedAt: Date(), ) updateTask = Task { [weak self] in guard let self else { return } if activity.activityState == .ended || activity.activityState == .dismissed { - if self.current?.id == activityID { self.current = nil } + if current?.id == activityID { current = nil } return } let content = ActivityContent( state: state, staleDate: Date(timeIntervalSince1970: Storage.shared.laRenewBy.value), - relevanceScore: 100.0 + relevanceScore: 100.0, ) if Task.isCancelled { return } @@ -504,15 +503,15 @@ final class LiveActivityManager { if Task.isCancelled { return } - guard self.current?.id == activityID else { + guard current?.id == activityID else { LogManager.shared.log(category: .general, message: "Live Activity update — activity ID mismatch, discarding") return } - self.lastUpdateTime = Date() + lastUpdateTime = Date() LogManager.shared.log(category: .general, message: "[LA] updated id=\(activityID) seq=\(nextSeq) reason=\(reason)", isDebug: true) - if let token = self.pushToken { + if let token = pushToken { await APNSClient.shared.sendLiveActivityUpdate(pushToken: token, state: state) } } @@ -568,10 +567,10 @@ final class LiveActivityManager { let request = UNNotificationRequest( identifier: "loopfollow.la.renewal.failed", content: content, - trigger: trigger + trigger: trigger, ) UNUserNotificationCenter.current().add(request) { error in - if let error = error { + if let error { LogManager.shared.log(category: .general, message: "[LA] failed to schedule renewal notification: \(error)") } } diff --git a/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift b/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift index eb26b9b54..3ce52f948 100644 --- a/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift +++ b/LoopFollow/LiveActivity/PreferredGlucoseUnit.swift @@ -14,9 +14,9 @@ enum PreferredGlucoseUnit { static func snapshotUnit() -> GlucoseSnapshot.Unit { switch hkUnit() { case .millimolesPerLiter: - return .mmol + .mmol default: - return .mgdl + .mgdl } } } diff --git a/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift index da0487ec4..cb1f84d18 100644 --- a/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift +++ b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift @@ -33,7 +33,7 @@ struct LoopFollowAppShortcuts: AppShortcutsProvider { intent: RestartLiveActivityIntent(), phrases: ["Restart Live Activity in \(.applicationName)"], shortTitle: "Restart Live Activity", - systemImageName: "dot.radiowaves.left.and.right" + systemImageName: "dot.radiowaves.left.and.right", ) } } diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index fd402c592..efb55b031 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -179,8 +179,8 @@ class Storage { var token = StorageValue(key: "token", defaultValue: "") var units = StorageValue(key: "units", defaultValue: "mg/dL") - var infoSort = StorageValue<[Int]>(key: "infoSort", defaultValue: InfoType.allCases.map { $0.sortOrder }) - var infoVisible = StorageValue<[Bool]>(key: "infoVisible", defaultValue: InfoType.allCases.map { $0.defaultVisible }) + var infoSort = StorageValue<[Int]>(key: "infoSort", defaultValue: InfoType.allCases.map(\.sortOrder)) + var infoVisible = StorageValue<[Bool]>(key: "infoVisible", defaultValue: InfoType.allCases.map(\.defaultVisible)) var url = StorageValue(key: "url", defaultValue: "") var device = StorageValue(key: "device", defaultValue: "") @@ -221,13 +221,13 @@ class Storage { /// Get the position for a given tab item func position(for item: TabItem) -> TabPosition { switch item { - case .home: return homePosition.value - case .alarms: return alarmsPosition.value - case .remote: return remotePosition.value - case .nightscout: return nightscoutPosition.value - case .snoozer: return snoozerPosition.value - case .stats: return statisticsPosition.value - case .treatments: return treatmentsPosition.value + case .home: homePosition.value + case .alarms: alarmsPosition.value + case .remote: remotePosition.value + case .nightscout: nightscoutPosition.value + case .snoozer: snoozerPosition.value + case .stats: statisticsPosition.value + case .treatments: treatmentsPosition.value } } diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 753402e05..5c28eed3b 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -80,7 +80,7 @@ private extension View { func applyActivityContentMarginsFixIfAvailable() -> some View { if #available(iOS 17.0, *) { // Use the generic SwiftUI API available in iOS 17+ (no placement enum) - self.contentMargins(Edge.Set.all, 0) + contentMargins(Edge.Set.all, 0) } else { self } @@ -151,7 +151,6 @@ private struct SmallFamilyView: View { private struct LockScreenLiveActivityView: View { let state: GlucoseLiveActivityAttributes.ContentState - /* let activityID: String */ var body: some View { let s = state.snapshot @@ -220,7 +219,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 { @@ -234,7 +233,7 @@ private struct LockScreenLiveActivityView: View { .tracking(1.5) } } - } + }, ) .overlay( ZStack { @@ -244,7 +243,7 @@ private struct LockScreenLiveActivityView: View { .font(.system(size: 20, weight: .semibold)) .foregroundStyle(.white) } - .opacity(state.snapshot.showRenewalOverlay ? 1 : 0) + .opacity(state.snapshot.showRenewalOverlay ? 1 : 0), ) } } @@ -307,28 +306,28 @@ private struct SlotView: View { private func value(for option: LiveActivitySlotOption) -> String { switch option { - case .none: return "" - case .delta: return LAFormat.delta(snapshot) - case .projectedBG: return LAFormat.projected(snapshot) - case .minMax: return LAFormat.minMax(snapshot) - case .iob: return LAFormat.iob(snapshot) - case .cob: return LAFormat.cob(snapshot) - case .recBolus: return LAFormat.recBolus(snapshot) - case .autosens: return LAFormat.autosens(snapshot) - case .tdd: return LAFormat.tdd(snapshot) - case .basal: return LAFormat.basal(snapshot) - case .pump: return LAFormat.pump(snapshot) - case .pumpBattery: return LAFormat.pumpBattery(snapshot) - case .battery: return LAFormat.battery(snapshot) - case .target: return LAFormat.target(snapshot) - case .isf: return LAFormat.isf(snapshot) - case .carbRatio: return LAFormat.carbRatio(snapshot) - case .sage: return LAFormat.age(insertTime: snapshot.sageInsertTime) - case .cage: return LAFormat.age(insertTime: snapshot.cageInsertTime) - case .iage: return LAFormat.age(insertTime: snapshot.iageInsertTime) - case .carbsToday: return LAFormat.carbsToday(snapshot) - case .override: return LAFormat.override(snapshot) - case .profile: return LAFormat.profileName(snapshot) + case .none: "" + case .delta: LAFormat.delta(snapshot) + case .projectedBG: LAFormat.projected(snapshot) + case .minMax: LAFormat.minMax(snapshot) + case .iob: LAFormat.iob(snapshot) + case .cob: LAFormat.cob(snapshot) + case .recBolus: LAFormat.recBolus(snapshot) + case .autosens: LAFormat.autosens(snapshot) + case .tdd: LAFormat.tdd(snapshot) + case .basal: LAFormat.basal(snapshot) + case .pump: LAFormat.pump(snapshot) + case .pumpBattery: LAFormat.pumpBattery(snapshot) + case .battery: LAFormat.battery(snapshot) + case .target: LAFormat.target(snapshot) + case .isf: LAFormat.isf(snapshot) + case .carbRatio: LAFormat.carbRatio(snapshot) + case .sage: LAFormat.age(insertTime: snapshot.sageInsertTime) + case .cage: LAFormat.age(insertTime: snapshot.cageInsertTime) + case .iage: LAFormat.age(insertTime: snapshot.iageInsertTime) + case .carbsToday: LAFormat.carbsToday(snapshot) + case .override: LAFormat.override(snapshot) + case .profile: LAFormat.profileName(snapshot) } } } @@ -515,14 +514,14 @@ private enum LAFormat { static func trendArrow(_ s: GlucoseSnapshot) -> String { switch s.trend { - case .upFast: return "↑↑" - case .up: return "↑" - case .upSlight: return "↗" - case .flat: return "→" - case .downSlight: return "↘︎" - case .down: return "↓" - case .downFast: return "↓↓" - case .unknown: return "–" + case .upFast: "↑↑" + case .up: "↑" + case .upSlight: "↗" + case .flat: "→" + case .downSlight: "↘︎" + case .down: "↓" + case .downFast: "↓↓" + case .unknown: "–" } } From 9db626ecf84dc9b7436f7a51b8b164ab16f96185 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 21 Mar 2026 18:47:38 -0400 Subject: [PATCH 21/55] Round prediction value before Int conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevents truncation toward zero (e.g. 179.9 → 179); now correctly rounds to nearest integer. Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift index 89c4163cd..daeea40f7 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift @@ -66,7 +66,7 @@ extension MainViewController { if let predictdata = lastLoopRecord["predicted"] as? [String: AnyObject] { let prediction = predictdata["values"] as! [Double] - PredictionLabel.text = Localizer.toDisplayUnits(String(Int(prediction.last!))) + PredictionLabel.text = Localizer.toDisplayUnits(String(Int(round(prediction.last!)))) PredictionLabel.textColor = UIColor.systemPurple if Storage.shared.downloadPrediction.value, previousLastLoopTime < lastLoopTime { predictionData.removeAll() From f59bd2e2b6be1f9caec756c11ef28bf0c5880408 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 22 Mar 2026 08:38:15 -0400 Subject: [PATCH 22/55] Fix double setTaskCompleted race; fix renewal deadline write ordering BackgroundRefreshManager: guard against double setTaskCompleted if the expiration handler fires while the main-queue block is in-flight. Apple documents calling setTaskCompleted more than once as a programming error. LiveActivityManager.renewIfNeeded: write laRenewBy to Storage only after Activity.request succeeds, eliminating the narrow window where a crash between the write and the request could leave the deadline permanently stuck in the future. No rollback needed on failure. The fresh snapshot is built via withRenewalOverlay(false) directly rather than re-running the builder, since the caller already has a current snapshot. Co-Authored-By: Claude Sonnet 4.6 --- .../Helpers/BackgroundRefreshManager.swift | 8 +++++++ .../LiveActivity/LiveActivityManager.swift | 21 +++++++++---------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/LoopFollow/Helpers/BackgroundRefreshManager.swift b/LoopFollow/Helpers/BackgroundRefreshManager.swift index 1faa7f07c..bac7e1c8e 100644 --- a/LoopFollow/Helpers/BackgroundRefreshManager.swift +++ b/LoopFollow/Helpers/BackgroundRefreshManager.swift @@ -20,13 +20,21 @@ class BackgroundRefreshManager { private func handleRefreshTask(_ task: BGAppRefreshTask) { LogManager.shared.log(category: .taskScheduler, message: "BGAppRefreshTask fired") + // Guard against double setTaskCompleted if expiration fires while the + // main-queue block is in-flight (Apple documents this as a programming error). + var completed = false + task.expirationHandler = { + guard !completed else { return } + completed = true LogManager.shared.log(category: .taskScheduler, message: "BGAppRefreshTask expired") task.setTaskCompleted(success: false) self.scheduleRefresh() } DispatchQueue.main.async { + guard !completed else { return } + completed = true if let mainVC = self.getMainViewController() { if !mainVC.backgroundTask.player.isPlaying { LogManager.shared.log(category: .taskScheduler, message: "audio dead, attempting restart") diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index a86ee28b8..9faa8a41e 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -341,18 +341,15 @@ final class LiveActivityManager { let overdueBy = Date().timeIntervalSince1970 - renewBy LogManager.shared.log(category: .general, message: "[LA] renewal deadline passed by \(Int(overdueBy))s, requesting new LA") - // Reset the deadline before building the snapshot so the provider's - // showRenewalOverlay computation returns false for the new activity. let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold) - Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970 - let attributes = GlucoseLiveActivityAttributes(title: "LoopFollow") - // Build fresh from the provider now that the deadline has been pushed forward. - // Fall back to the passed-in snapshot with the overlay stripped if the build fails. - let provider = StorageCurrentGlucoseStateProvider() - let freshSnapshot = GlucoseSnapshotBuilder.build(from: provider) - ?? snapshot.withRenewalOverlay(false) + // Build the fresh snapshot with showRenewalOverlay: false — the new LA has a + // fresh deadline so no overlay is needed from the first frame. We pass the + // deadline as staleDate to ActivityContent below, not to Storage yet; Storage + // is only updated after Activity.request succeeds so a crash between the two + // can't leave the deadline permanently stuck in the future. + let freshSnapshot = snapshot.withRenewalOverlay(false) let state = GlucoseLiveActivityAttributes.ContentState( snapshot: freshSnapshot, @@ -377,6 +374,9 @@ final class LiveActivityManager { stateObserverTask = nil pushToken = nil + // Write deadline only on success — avoids a stuck future deadline if we crash + // between the write and the Activity.request call. + Storage.shared.laRenewBy.value = renewDeadline.timeIntervalSince1970 bind(to: newActivity, logReason: "renew") Storage.shared.laRenewalFailed.value = false cancelRenewalFailedNotification() @@ -384,8 +384,7 @@ final class LiveActivityManager { LogManager.shared.log(category: .general, message: "[LA] Live Activity renewed successfully id=\(newActivity.id)") return true } catch { - // Renewal failed — roll back the deadline so the next refresh retries. - Storage.shared.laRenewBy.value = renewBy + // Renewal failed — deadline was never written, so no rollback needed. let isFirstFailure = !Storage.shared.laRenewalFailed.value Storage.shared.laRenewalFailed.value = true LogManager.shared.log(category: .general, message: "[LA] renewal failed, keeping existing LA: \(error)") From b28f9c28a72eeda86d09f43db5783d81dbd640a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 22 Mar 2026 16:09:47 +0100 Subject: [PATCH 23/55] Scope all identifiers to bundle ID for multi-instance support Derive BGTask IDs, notification IDs, URL schemes, and notification categories from Bundle.main.bundleIdentifier so that LoopFollow, LoopFollow_Second, and LoopFollow_Third each get isolated identifiers and don't interfere with each other's background tasks, notifications, or Live Activities. Also show the configured display name in the Live Activity footer (next to the update time) when the existing "Show Display Name" toggle is enabled, so users can identify which instance a LA belongs to. --- LoopFollow/Application/AppDelegate.swift | 4 +-- LoopFollow/Application/SceneDelegate.swift | 2 +- .../Controllers/BackgroundAlertManager.swift | 25 +++++++++++++----- .../Helpers/BackgroundRefreshManager.swift | 2 +- LoopFollow/Info.plist | 4 +-- LoopFollow/LiveActivity/AppGroupID.swift | 26 ++++++++++++++----- .../LiveActivity/LAAppGroupSettings.swift | 17 ++++++++++++ .../LiveActivity/LiveActivityManager.swift | 10 +++++-- .../RestartLiveActivityIntent.swift | 2 +- .../LoopFollowLiveActivity.swift | 16 +++++++----- 10 files changed, 80 insertions(+), 28 deletions(-) diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index d79de7d18..62be95fd4 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 @@ -107,7 +107,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..bab2af0e1 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() @@ -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/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 5c28eed3b..1abbb6c81 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -9,21 +9,21 @@ import WidgetKit 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)) } @@ -51,7 +51,7 @@ struct LoopFollowLiveActivityWidget: Widget { .activitySystemActionForegroundColor(.white) .activityBackgroundTint(LAColors.backgroundTint(for: context.state.snapshot)) .applyActivityContentMarginsFixIfAvailable() - .widgetURL(URL(string: "loopfollow://la-tap")!) + .widgetURL(URL(string: "\(AppGroupID.urlScheme)://la-tap")!) } dynamicIsland: { context in makeDynamicIsland(context: context) } @@ -106,7 +106,7 @@ private struct LockScreenFamilyAdaptiveView: View { .activitySystemActionForegroundColor(.white) .activityBackgroundTint(LAColors.backgroundTint(for: state.snapshot)) .applyActivityContentMarginsFixIfAvailable() - .widgetURL(URL(string: "loopfollow://la-tap")!) + .widgetURL(URL(string: "\(AppGroupID.urlScheme)://la-tap")!) } } } @@ -206,8 +206,10 @@ private struct LockScreenLiveActivityView: View { .frame(maxWidth: .infinity, alignment: .trailing) } - // Footer: last update time - Text("Last Update: \(LAFormat.updated(s))") + // Footer: last update time, optionally prefixed with display name + 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)) From 8e2e31f128a870e8a906dca5858a0f43eecad4f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 22 Mar 2026 16:11:09 +0100 Subject: [PATCH 24/55] Linting --- .../LiveActivity/StorageCurrentGlucoseStateProvider.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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? { From f2f87b5bf1c09939a9fe217e60203f4bfd5bd99b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 22 Mar 2026 16:22:13 +0100 Subject: [PATCH 25/55] Add migration step 7: cancel legacy notification identifiers Users upgrading from the old hardcoded identifiers would have orphaned pending notifications that the new bundle-ID-scoped code can't cancel. This one-time migration cleans them up on first launch. --- LoopFollow/Storage/Storage+Migrate.swift | 16 ++++++++++++++++ .../ViewControllers/MainViewController.swift | 5 +++++ 2 files changed, 21 insertions(+) diff --git a/LoopFollow/Storage/Storage+Migrate.swift b/LoopFollow/Storage/Storage+Migrate.swift index aa0868543..97cbb8b18 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") diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index bbf2de63c..61925da91 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -178,6 +178,11 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele 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 synchronizeInfoTypes() From 4cb7bb4451a1d2118fbead05d44e49bef7b7e5f4 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:09:06 -0400 Subject: [PATCH 26/55] Increase LA refresh debounce from 5s to 20s to coalesce double push The `bg` and `loopingResumed` refresh triggers fire ~10s apart. With a 5s debounce, `loopingResumed` arrives after the debounce has already executed, causing two APNs pushes per BG cycle instead of one. Widening the window to 20s ensures both events are coalesced into a single push containing the most up-to-date post-loop-cycle state (fresh IOB, predicted BG, etc.). Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/LiveActivity/LiveActivityManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 9faa8a41e..c2a7857c4 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -322,7 +322,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 From 269f2bd2e8f8020114abd3ceb62389a7dcbcc4f6 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 22 Mar 2026 18:32:02 -0400 Subject: [PATCH 27/55] Guard migrations against background launch to prevent BFU settings wipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When BGAppRefreshTask fires after a reboot (before the user has unlocked the device), UserDefaults files are still encrypted (Before First Unlock state). Reading migrationStep returns 0, causing all migrations to re-run. migrateStep1 reads old_url from the also-locked App Group suite, gets "", and writes "" to url — wiping Nightscout and other settings. Fix: skip the entire migration block when the app is in background state. Migrations will run correctly on the next foreground open. This is safe since no migration is time-critical and all steps are guarded by version checks. Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/ViewControllers/MainViewController.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index bbf2de63c..dc50ebe9a 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -142,6 +142,13 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele loadDebugData() + // Migrations must only run in the foreground. When the app is launched in the + // background (e.g. BGAppRefreshTask after a reboot), the device may be in a + // Before-First-Unlock state where UserDefaults files are still encrypted. + // Reading in that state returns default values (0 / ""), causing migrations to + // re-run and overwrite stored settings with empty strings. + 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 From e52fee81847723320150330ed6bbed0a216f051e Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:21:27 -0400 Subject: [PATCH 28/55] Fix BFU migration guard: wrap only migrations, not all of viewDidLoad MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix used guard+return which skipped the entire viewDidLoad when the app launched in background (BGAppRefreshTask). viewDidLoad only runs once per VC lifecycle, so the UI was never initialized when the user later foregrounded the app — causing a blank screen. Fix: wrap only the migration block in an if-check, so UI setup always runs. Migrations are still skipped in background to avoid BFU corruption. Co-Authored-By: Claude Sonnet 4.6 --- .../ViewControllers/MainViewController.swift | 60 +++++++++---------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index dc50ebe9a..f29c729b2 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -147,42 +147,42 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele // Before-First-Unlock state where UserDefaults files are still encrypted. // Reading in that state returns default values (0 / ""), causing migrations to // re-run and overwrite stored settings with empty strings. - guard UIApplication.shared.applicationState != .background else { return } + if UIApplication.shared.applicationState != .background { + // Capture before migrations run: true for existing users, false for fresh installs. + let isExistingUser = Storage.shared.migrationStep.exists - // 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 < 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 < 2 { + Storage.shared.migrateStep2() + Storage.shared.migrationStep.value = 2 + } - if Storage.shared.migrationStep.value < 3 { - Storage.shared.migrateStep3() - Storage.shared.migrationStep.value = 3 - } + 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 - } + // 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 < 5 { + Storage.shared.migrateStep5() + Storage.shared.migrationStep.value = 5 + } - if Storage.shared.migrationStep.value < 6 { - Storage.shared.migrateStep6() - Storage.shared.migrationStep.value = 6 + if Storage.shared.migrationStep.value < 6 { + Storage.shared.migrateStep6() + Storage.shared.migrationStep.value = 6 + } } // Synchronize info types to ensure arrays are the correct size From b23f27886435d323c2d7132f521b79fc5739e3f1 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:40:16 -0400 Subject: [PATCH 29/55] Defer migrations to first foreground after BFU background launch runMigrationsIfNeeded() extracts the migration block and is called from both viewDidLoad (normal launch) and appCameToForeground() (deferred case). The guard skips execution when applicationState == .background to prevent BFU corruption, and appCameToForeground() picks up any deferred migrations the first time the user unlocks after a reboot. The previous fix (wrapping migrations in an if-block inside viewDidLoad) correctly prevented BFU corruption but left migrations permanently unrun after a background cold-start, causing the app to behave as a fresh install and prompt for Nightscout/Dexcom setup. Co-Authored-By: Claude Sonnet 4.6 --- .../ViewControllers/MainViewController.swift | 91 ++++++++++--------- 1 file changed, 49 insertions(+), 42 deletions(-) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index f29c729b2..cc20d4fa1 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -142,48 +142,8 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele loadDebugData() - // Migrations must only run in the foreground. When the app is launched in the - // background (e.g. BGAppRefreshTask after a reboot), the device may be in a - // Before-First-Unlock state where UserDefaults files are still encrypted. - // Reading in that state returns default values (0 / ""), causing migrations to - // re-run and overwrite stored settings with empty strings. - if UIApplication.shared.applicationState != .background { - // 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 - } - } + // Migrations run in foreground only — see runMigrationsIfNeeded() for details. + runMigrationsIfNeeded() // Synchronize info types to ensure arrays are the correct size synchronizeInfoTypes() @@ -979,7 +939,54 @@ 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 appCameToForeground() { + // Complete any migrations that were deferred because the app launched in background + // (BGAppRefreshTask) while the device was in Before-First-Unlock state. + runMigrationsIfNeeded() + // reset screenlock state if needed UIApplication.shared.isIdleTimerDisabled = Storage.shared.screenlockSwitchState.value From 4dce14ce5d639fc8785b48a318ad3b5c0b653344 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 22 Mar 2026 19:44:03 -0400 Subject: [PATCH 30/55] Use didBecomeActive (not willEnterForeground) for deferred migration recovery willEnterForegroundNotification fires while applicationState may still be .background, causing the BFU guard in runMigrationsIfNeeded() to skip migrations a second time. didBecomeActiveNotification guarantees applicationState == .active, so the guard always passes. Adds a dedicated appDidBecomeActive() handler that only calls runMigrationsIfNeeded(). Since that function is idempotent (each step checks migrationStep.value < N), calling it on every activation after migrations have already completed is a fast no-op. Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/ViewControllers/MainViewController.swift | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index cc20d4fa1..9f72240eb 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -178,6 +178,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 @@ -982,11 +986,14 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } } - @objc func appCameToForeground() { - // Complete any migrations that were deferred because the app launched in background - // (BGAppRefreshTask) while the device was in Before-First-Unlock state. + @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() { // reset screenlock state if needed UIApplication.shared.isIdleTimerDisabled = Storage.shared.screenlockSwitchState.value From 7e6b19135ba8079314683cc6f2b6d64707375b45 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 22 Mar 2026 20:08:27 -0400 Subject: [PATCH 31/55] Remove BGAppRefreshTask completely BGAppRefreshTask caused iOS to cold-launch the app in the background after a reboot. In Before-First-Unlock state, UserDefaults is encrypted and all reads return defaults, causing migrations to re-run and wipe settings (Nightscout URL, etc.). Multiple fix attempts could not reliably guard against this without risking the UI never initialising. Removed entirely: - BackgroundRefreshManager.swift (deleted) - AppDelegate: BackgroundRefreshManager.shared.register() - MainViewController: BackgroundRefreshManager.shared.scheduleRefresh() and all migration-guard code added to work around the BFU issue - Info.plist: com.loopfollow.audiorefresh BGTaskSchedulerPermittedIdentifier - Info.plist: fetch UIBackgroundMode - project.pbxproj: all four BackgroundRefreshManager.swift references Migrations restored to their original unconditional form in viewDidLoad. Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow.xcodeproj/project.pbxproj | 17 ++-- LoopFollow/Application/AppDelegate.swift | 1 - .../Helpers/BackgroundRefreshManager.swift | 96 ------------------- LoopFollow/Info.plist | 2 - .../ViewControllers/MainViewController.swift | 87 ++++++----------- 5 files changed, 36 insertions(+), 167 deletions(-) delete mode 100644 LoopFollow/Helpers/BackgroundRefreshManager.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 305f058b3..18cd70cc3 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -140,7 +140,6 @@ DD608A082C1F584900F91132 /* DeviceStatusLoop.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A072C1F584900F91132 /* DeviceStatusLoop.swift */; }; DD608A0A2C23593900F91132 /* SMB.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A092C23593900F91132 /* SMB.swift */; }; DD608A0C2C27415C00F91132 /* BackgroundAlertManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A0B2C27415C00F91132 /* BackgroundAlertManager.swift */; }; - 66E3D12E66AA4534A144A54B /* BackgroundRefreshManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */; }; DD6A935E2BFA6FA2003FFB8E /* DeviceStatusOpenAPS.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6A935D2BFA6FA2003FFB8E /* DeviceStatusOpenAPS.swift */; }; DD7B0D442D730A3B0063DCB6 /* CycleHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7B0D432D730A320063DCB6 /* CycleHelper.swift */; }; DD7E19842ACDA50C00DBD158 /* Overrides.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E19832ACDA50C00DBD158 /* Overrides.swift */; }; @@ -582,7 +581,6 @@ DD608A072C1F584900F91132 /* DeviceStatusLoop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatusLoop.swift; sourceTree = ""; }; DD608A092C23593900F91132 /* SMB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMB.swift; sourceTree = ""; }; DD608A0B2C27415C00F91132 /* BackgroundAlertManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundAlertManager.swift; sourceTree = ""; }; - A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshManager.swift; sourceTree = ""; }; DD6A935D2BFA6FA2003FFB8E /* DeviceStatusOpenAPS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatusOpenAPS.swift; sourceTree = ""; }; DD7B0D432D730A320063DCB6 /* CycleHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CycleHelper.swift; sourceTree = ""; }; DD7E19832ACDA50C00DBD158 /* Overrides.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Overrides.swift; sourceTree = ""; }; @@ -1657,7 +1655,6 @@ FCC6886A24898FD800A0279D /* ObservationToken.swift */, FCC6886C2489909D00A0279D /* AnyConvertible.swift */, FCC688592489554800A0279D /* BackgroundTaskAudio.swift */, - A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */, FCFEEC9F2488157B00402A7F /* Chart.swift */, FCC0FAC124922A22003E610E /* DictionaryKeyPath.swift */, FC16A98024996C07003D6245 /* DateTime.swift */, @@ -2259,7 +2256,6 @@ DDFF3D852D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift in Sources */, DDCF9A882D85FD33004DF4DD /* AlarmData.swift in Sources */, DD608A0C2C27415C00F91132 /* BackgroundAlertManager.swift in Sources */, - 66E3D12E66AA4534A144A54B /* BackgroundRefreshManager.swift in Sources */, 379BECB02F65DA4B0069DC62 /* LiveActivitySettingsView.swift in Sources */, DD4878082C7B30BF0048F05C /* RemoteSettingsView.swift in Sources */, DDE75D2B2DE5E613007C1FC1 /* NavigationRow.swift in Sources */, @@ -2395,7 +2391,7 @@ CODE_SIGN_ENTITLEMENTS = LoopFollowLAExtensionExtension.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = 2HEY366Q6J; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = NO; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -2447,7 +2443,7 @@ CODE_SIGN_ENTITLEMENTS = LoopFollowLAExtensionExtension.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = 2HEY366Q6J; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = NO; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -2496,7 +2492,7 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = 2HEY366Q6J; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; @@ -2523,7 +2519,7 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = 2HEY366Q6J; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; @@ -2669,7 +2665,7 @@ CODE_SIGN_ENTITLEMENTS = "LoopFollow/Loop Follow.entitlements"; CODE_SIGN_STYLE = Automatic; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = 2HEY366Q6J; INFOPLIST_FILE = LoopFollow/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = ( @@ -2694,7 +2690,7 @@ CODE_SIGN_ENTITLEMENTS = "LoopFollow/Loop Follow.entitlements"; CODE_SIGN_STYLE = Automatic; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + DEVELOPMENT_TEAM = 2HEY366Q6J; INFOPLIST_FILE = LoopFollow/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = ( @@ -2751,7 +2747,6 @@ }; /* End XCConfigurationList section */ - /* Begin XCVersionGroup section */ FC3AE7B3249E8E0E00AAE1E0 /* LoopFollow.xcdatamodeld */ = { isa = XCVersionGroup; diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index d79de7d18..d8c25dc8b 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -46,7 +46,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { UIApplication.shared.registerForRemoteNotifications() } - BackgroundRefreshManager.shared.register() return true } diff --git a/LoopFollow/Helpers/BackgroundRefreshManager.swift b/LoopFollow/Helpers/BackgroundRefreshManager.swift deleted file mode 100644 index bac7e1c8e..000000000 --- a/LoopFollow/Helpers/BackgroundRefreshManager.swift +++ /dev/null @@ -1,96 +0,0 @@ -// LoopFollow -// BackgroundRefreshManager.swift - -import BackgroundTasks -import UIKit - -class BackgroundRefreshManager { - static let shared = BackgroundRefreshManager() - private init() {} - - private let taskIdentifier = "com.loopfollow.audiorefresh" - - func register() { - BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: nil) { task in - guard let refreshTask = task as? BGAppRefreshTask else { return } - self.handleRefreshTask(refreshTask) - } - } - - private func handleRefreshTask(_ task: BGAppRefreshTask) { - LogManager.shared.log(category: .taskScheduler, message: "BGAppRefreshTask fired") - - // Guard against double setTaskCompleted if expiration fires while the - // main-queue block is in-flight (Apple documents this as a programming error). - var completed = false - - task.expirationHandler = { - guard !completed else { return } - completed = true - LogManager.shared.log(category: .taskScheduler, message: "BGAppRefreshTask expired") - task.setTaskCompleted(success: false) - self.scheduleRefresh() - } - - DispatchQueue.main.async { - guard !completed else { return } - completed = true - if let mainVC = self.getMainViewController() { - if !mainVC.backgroundTask.player.isPlaying { - LogManager.shared.log(category: .taskScheduler, message: "audio dead, attempting restart") - mainVC.backgroundTask.stopBackgroundTask() - mainVC.backgroundTask.startBackgroundTask() - LogManager.shared.log(category: .taskScheduler, message: "audio restart initiated") - } else { - LogManager.shared.log(category: .taskScheduler, message: "audio alive, no action needed", isDebug: true) - } - } - self.scheduleRefresh() - task.setTaskCompleted(success: true) - } - } - - func scheduleRefresh() { - let request = BGAppRefreshTaskRequest(identifier: taskIdentifier) - request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) - do { - try BGTaskScheduler.shared.submit(request) - } catch { - LogManager.shared.log(category: .taskScheduler, message: "Failed to schedule BGAppRefreshTask: \(error)") - } - } - - private func getMainViewController() -> MainViewController? { - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first, - let rootVC = window.rootViewController - else { - return nil - } - - if let mainVC = rootVC as? MainViewController { - return mainVC - } - - if let navVC = rootVC as? UINavigationController, - let mainVC = navVC.viewControllers.first as? MainViewController - { - return mainVC - } - - if let tabVC = rootVC as? UITabBarController { - for vc in tabVC.viewControllers ?? [] { - if let mainVC = vc as? MainViewController { - return mainVC - } - if let navVC = vc as? UINavigationController, - let mainVC = navVC.viewControllers.first as? MainViewController - { - return mainVC - } - } - } - - return nil - } -} diff --git a/LoopFollow/Info.plist b/LoopFollow/Info.plist index 5cc7f4146..28385ac6e 100644 --- a/LoopFollow/Info.plist +++ b/LoopFollow/Info.plist @@ -7,7 +7,6 @@ BGTaskSchedulerPermittedIdentifiers com.$(unique_id).LoopFollow$(app_suffix) - com.loopfollow.audiorefresh CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) @@ -88,7 +87,6 @@ UIBackgroundModes audio - fetch processing bluetooth-central remote-notification diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 9f72240eb..862f31470 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -142,8 +142,36 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele loadDebugData() - // Migrations run in foreground only — see runMigrationsIfNeeded() for details. - runMigrationsIfNeeded() + // 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 + } // Synchronize info types to ensure arrays are the correct size synchronizeInfoTypes() @@ -178,10 +206,6 @@ 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 @@ -935,7 +959,6 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele if Storage.shared.backgroundRefreshType.value == .silentTune { backgroundTask.startBackgroundTask() - BackgroundRefreshManager.shared.scheduleRefresh() } if Storage.shared.backgroundRefreshType.value != .none { @@ -943,56 +966,6 @@ 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() { // reset screenlock state if needed UIApplication.shared.isIdleTimerDisabled = Storage.shared.screenlockSwitchState.value From 29fdfc406925c4aece256f0084b94be365ce4b52 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 22 Mar 2026 20:39:12 -0400 Subject: [PATCH 32/55] Revert "Remove BGAppRefreshTask completely" This reverts commit 7e6b19135ba8079314683cc6f2b6d64707375b45. --- LoopFollow.xcodeproj/project.pbxproj | 17 ++-- LoopFollow/Application/AppDelegate.swift | 1 + .../Helpers/BackgroundRefreshManager.swift | 96 +++++++++++++++++++ LoopFollow/Info.plist | 2 + .../ViewControllers/MainViewController.swift | 87 +++++++++++------ 5 files changed, 167 insertions(+), 36 deletions(-) create mode 100644 LoopFollow/Helpers/BackgroundRefreshManager.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 18cd70cc3..305f058b3 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -140,6 +140,7 @@ DD608A082C1F584900F91132 /* DeviceStatusLoop.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A072C1F584900F91132 /* DeviceStatusLoop.swift */; }; DD608A0A2C23593900F91132 /* SMB.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A092C23593900F91132 /* SMB.swift */; }; DD608A0C2C27415C00F91132 /* BackgroundAlertManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD608A0B2C27415C00F91132 /* BackgroundAlertManager.swift */; }; + 66E3D12E66AA4534A144A54B /* BackgroundRefreshManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */; }; DD6A935E2BFA6FA2003FFB8E /* DeviceStatusOpenAPS.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD6A935D2BFA6FA2003FFB8E /* DeviceStatusOpenAPS.swift */; }; DD7B0D442D730A3B0063DCB6 /* CycleHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7B0D432D730A320063DCB6 /* CycleHelper.swift */; }; DD7E19842ACDA50C00DBD158 /* Overrides.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7E19832ACDA50C00DBD158 /* Overrides.swift */; }; @@ -581,6 +582,7 @@ DD608A072C1F584900F91132 /* DeviceStatusLoop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatusLoop.swift; sourceTree = ""; }; DD608A092C23593900F91132 /* SMB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMB.swift; sourceTree = ""; }; DD608A0B2C27415C00F91132 /* BackgroundAlertManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundAlertManager.swift; sourceTree = ""; }; + A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshManager.swift; sourceTree = ""; }; DD6A935D2BFA6FA2003FFB8E /* DeviceStatusOpenAPS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStatusOpenAPS.swift; sourceTree = ""; }; DD7B0D432D730A320063DCB6 /* CycleHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CycleHelper.swift; sourceTree = ""; }; DD7E19832ACDA50C00DBD158 /* Overrides.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Overrides.swift; sourceTree = ""; }; @@ -1655,6 +1657,7 @@ FCC6886A24898FD800A0279D /* ObservationToken.swift */, FCC6886C2489909D00A0279D /* AnyConvertible.swift */, FCC688592489554800A0279D /* BackgroundTaskAudio.swift */, + A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */, FCFEEC9F2488157B00402A7F /* Chart.swift */, FCC0FAC124922A22003E610E /* DictionaryKeyPath.swift */, FC16A98024996C07003D6245 /* DateTime.swift */, @@ -2256,6 +2259,7 @@ DDFF3D852D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift in Sources */, DDCF9A882D85FD33004DF4DD /* AlarmData.swift in Sources */, DD608A0C2C27415C00F91132 /* BackgroundAlertManager.swift in Sources */, + 66E3D12E66AA4534A144A54B /* BackgroundRefreshManager.swift in Sources */, 379BECB02F65DA4B0069DC62 /* LiveActivitySettingsView.swift in Sources */, DD4878082C7B30BF0048F05C /* RemoteSettingsView.swift in Sources */, DDE75D2B2DE5E613007C1FC1 /* NavigationRow.swift in Sources */, @@ -2391,7 +2395,7 @@ CODE_SIGN_ENTITLEMENTS = LoopFollowLAExtensionExtension.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 2HEY366Q6J; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = NO; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -2443,7 +2447,7 @@ CODE_SIGN_ENTITLEMENTS = LoopFollowLAExtensionExtension.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 2HEY366Q6J; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = NO; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -2492,7 +2496,7 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 2HEY366Q6J; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; @@ -2519,7 +2523,7 @@ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 2HEY366Q6J; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; @@ -2665,7 +2669,7 @@ CODE_SIGN_ENTITLEMENTS = "LoopFollow/Loop Follow.entitlements"; CODE_SIGN_STYLE = Automatic; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = 2HEY366Q6J; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; INFOPLIST_FILE = LoopFollow/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = ( @@ -2690,7 +2694,7 @@ CODE_SIGN_ENTITLEMENTS = "LoopFollow/Loop Follow.entitlements"; CODE_SIGN_STYLE = Automatic; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = 2HEY366Q6J; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; INFOPLIST_FILE = LoopFollow/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = ( @@ -2747,6 +2751,7 @@ }; /* End XCConfigurationList section */ + /* Begin XCVersionGroup section */ FC3AE7B3249E8E0E00AAE1E0 /* LoopFollow.xcdatamodeld */ = { isa = XCVersionGroup; diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index d8c25dc8b..d79de7d18 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -46,6 +46,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { UIApplication.shared.registerForRemoteNotifications() } + BackgroundRefreshManager.shared.register() return true } diff --git a/LoopFollow/Helpers/BackgroundRefreshManager.swift b/LoopFollow/Helpers/BackgroundRefreshManager.swift new file mode 100644 index 000000000..bac7e1c8e --- /dev/null +++ b/LoopFollow/Helpers/BackgroundRefreshManager.swift @@ -0,0 +1,96 @@ +// LoopFollow +// BackgroundRefreshManager.swift + +import BackgroundTasks +import UIKit + +class BackgroundRefreshManager { + static let shared = BackgroundRefreshManager() + private init() {} + + private let taskIdentifier = "com.loopfollow.audiorefresh" + + func register() { + BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: nil) { task in + guard let refreshTask = task as? BGAppRefreshTask else { return } + self.handleRefreshTask(refreshTask) + } + } + + private func handleRefreshTask(_ task: BGAppRefreshTask) { + LogManager.shared.log(category: .taskScheduler, message: "BGAppRefreshTask fired") + + // Guard against double setTaskCompleted if expiration fires while the + // main-queue block is in-flight (Apple documents this as a programming error). + var completed = false + + task.expirationHandler = { + guard !completed else { return } + completed = true + LogManager.shared.log(category: .taskScheduler, message: "BGAppRefreshTask expired") + task.setTaskCompleted(success: false) + self.scheduleRefresh() + } + + DispatchQueue.main.async { + guard !completed else { return } + completed = true + if let mainVC = self.getMainViewController() { + if !mainVC.backgroundTask.player.isPlaying { + LogManager.shared.log(category: .taskScheduler, message: "audio dead, attempting restart") + mainVC.backgroundTask.stopBackgroundTask() + mainVC.backgroundTask.startBackgroundTask() + LogManager.shared.log(category: .taskScheduler, message: "audio restart initiated") + } else { + LogManager.shared.log(category: .taskScheduler, message: "audio alive, no action needed", isDebug: true) + } + } + self.scheduleRefresh() + task.setTaskCompleted(success: true) + } + } + + func scheduleRefresh() { + let request = BGAppRefreshTaskRequest(identifier: taskIdentifier) + request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) + do { + try BGTaskScheduler.shared.submit(request) + } catch { + LogManager.shared.log(category: .taskScheduler, message: "Failed to schedule BGAppRefreshTask: \(error)") + } + } + + private func getMainViewController() -> MainViewController? { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first, + let rootVC = window.rootViewController + else { + return nil + } + + if let mainVC = rootVC as? MainViewController { + return mainVC + } + + if let navVC = rootVC as? UINavigationController, + let mainVC = navVC.viewControllers.first as? MainViewController + { + return mainVC + } + + if let tabVC = rootVC as? UITabBarController { + for vc in tabVC.viewControllers ?? [] { + if let mainVC = vc as? MainViewController { + return mainVC + } + if let navVC = vc as? UINavigationController, + let mainVC = navVC.viewControllers.first as? MainViewController + { + return mainVC + } + } + } + + return nil + } +} diff --git a/LoopFollow/Info.plist b/LoopFollow/Info.plist index 28385ac6e..5cc7f4146 100644 --- a/LoopFollow/Info.plist +++ b/LoopFollow/Info.plist @@ -7,6 +7,7 @@ BGTaskSchedulerPermittedIdentifiers com.$(unique_id).LoopFollow$(app_suffix) + com.loopfollow.audiorefresh CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) @@ -87,6 +88,7 @@ UIBackgroundModes audio + fetch processing bluetooth-central remote-notification diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 862f31470..9f72240eb 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -142,36 +142,8 @@ 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 - } - if Storage.shared.migrationStep.value < 6 { - Storage.shared.migrateStep6() - Storage.shared.migrationStep.value = 6 - } + // Migrations run in foreground only — see runMigrationsIfNeeded() for details. + runMigrationsIfNeeded() // Synchronize info types to ensure arrays are the correct size synchronizeInfoTypes() @@ -206,6 +178,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 @@ -959,6 +935,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele if Storage.shared.backgroundRefreshType.value == .silentTune { backgroundTask.startBackgroundTask() + BackgroundRefreshManager.shared.scheduleRefresh() } if Storage.shared.backgroundRefreshType.value != .none { @@ -966,6 +943,56 @@ 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() { // reset screenlock state if needed UIApplication.shared.isIdleTimerDisabled = Storage.shared.screenlockSwitchState.value From 9f22f423628baf163910f2569f7e3c863fa30e71 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 22 Mar 2026 20:39:38 -0400 Subject: [PATCH 33/55] Guard migrateStep1 core fields against BFU empty reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four primary fields (url, device, nsWriteAuth, nsAdminAuth) were unconditionally copied from the App Group suite to UserDefaults.standard with no .exists check — unlike every other field in the same function. When the app launches in the background (remote-notification mode) while the device is in Before-First-Unlock state, the App Group UserDefaults file is encrypted and unreadable. object(forKey:) returns nil, .exists returns false, and .value returns the default ("" / false). Without the guard, "" was written to url in Standard UserDefaults and flushed to disk on first unlock, wiping the Nightscout URL. Adding .exists checks matches the pattern used by all helper migrations in the same function. A fresh install correctly skips (nothing to migrate). An existing user correctly copies (old key still present in App Group since migrateStep1 never removes it). BFU state correctly skips (App Group unreadable, Standard value preserved). Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/Storage/Storage+Migrate.swift | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/LoopFollow/Storage/Storage+Migrate.swift b/LoopFollow/Storage/Storage+Migrate.swift index aa0868543..e7406ece2 100644 --- a/LoopFollow/Storage/Storage+Migrate.swift +++ b/LoopFollow/Storage/Storage+Migrate.swift @@ -125,10 +125,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( From 69d76f87b3c4d33f752708484c52d7bf03eb5026 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 22 Mar 2026 21:52:09 -0400 Subject: [PATCH 34/55] Fix reboot settings wipe: reload StorageValues on foreground after BFU launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BGAppRefreshTask cold-launches the app while the device is locked (BFU), causing StorageValue to cache empty defaults from encrypted UserDefaults. The scene connects during that background launch, so viewDidLoad does not run again when the user foregrounds — leaving url="" in the @Published cache and the setup screen showing despite correct data on disk. Fix: add StorageValue.reload() (re-reads disk, fires @Published only if changed) and call it for url/shareUserName/sharePassword at the top of appCameToForeground(), correcting the stale cache the first time the user opens the app after a reboot. Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/Storage/Framework/StorageValue.swift | 12 ++++++++++++ LoopFollow/ViewControllers/MainViewController.swift | 9 +++++++++ 2 files changed, 21 insertions(+) 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/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 9f72240eb..827e6c8c6 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -994,6 +994,15 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } @objc func appCameToForeground() { + // Re-read critical StorageValues from disk. If the app was cold-launched by BGAppRefreshTask + // while the device was locked (Before-First-Unlock), Storage.shared was initialized while + // UserDefaults was encrypted, caching "" for url and other strings. Now that the user has + // unlocked the device and foregrounded the app, reload from disk so Combine observers fire + // and the UI reflects the actual saved configuration. + Storage.shared.url.reload() + Storage.shared.shareUserName.reload() + Storage.shared.sharePassword.reload() + // reset screenlock state if needed UIApplication.shared.isIdleTimerDisabled = Storage.shared.screenlockSwitchState.value From ca99a58fd63418d8bba3f05aaff88f8ea95009ab Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 22 Mar 2026 21:55:43 -0400 Subject: [PATCH 35/55] Reload all Nightscout credentials on foreground, not just url/share fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit token, sharedSecret, nsWriteAuth, nsAdminAuth would all be stale after a BFU background launch — Nightscout API calls would fail or use wrong auth even if the setup screen was correctly dismissed by the url reload. Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/ViewControllers/MainViewController.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 827e6c8c6..34ba108f3 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -1000,6 +1000,10 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele // unlocked the device and foregrounded the app, reload from disk so Combine observers fire // and the UI reflects the actual saved configuration. Storage.shared.url.reload() + Storage.shared.token.reload() + Storage.shared.sharedSecret.reload() + Storage.shared.nsWriteAuth.reload() + Storage.shared.nsAdminAuth.reload() Storage.shared.shareUserName.reload() Storage.shared.sharePassword.reload() From 348cfc952fcf370223ff553de87237cf2dfa9115 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 22 Mar 2026 22:05:51 -0400 Subject: [PATCH 36/55] Gate BFU reload behind isProtectedDataAvailable flag; reload all StorageValues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of calling individual reloads on every foreground (noisy, unnecessary disk reads, cascade of observers on normal launches), capture whether protected data was unavailable at launch time. On the first foreground after a BFU launch, call Storage.reloadAll() — which reloads every StorageValue, firing @Published only where the cached value actually changed. Normal foregrounds are unaffected. Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/Application/AppDelegate.swift | 6 + LoopFollow/Storage/Storage.swift | 185 ++++++++++++++++++ .../ViewControllers/MainViewController.swift | 20 +- 3 files changed, 199 insertions(+), 12 deletions(-) diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index d79de7d18..75b2e0916 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -47,6 +47,12 @@ 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. + Storage.shared.needsBFUReload = !UIApplication.shared.isProtectedDataAvailable + return true } 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 34ba108f3..6a3a574c3 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -994,18 +994,14 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele } @objc func appCameToForeground() { - // Re-read critical StorageValues from disk. If the app was cold-launched by BGAppRefreshTask - // while the device was locked (Before-First-Unlock), Storage.shared was initialized while - // UserDefaults was encrypted, caching "" for url and other strings. Now that the user has - // unlocked the device and foregrounded the app, reload from disk so Combine observers fire - // and the UI reflects the actual saved configuration. - Storage.shared.url.reload() - Storage.shared.token.reload() - Storage.shared.sharedSecret.reload() - Storage.shared.nsWriteAuth.reload() - Storage.shared.nsAdminAuth.reload() - Storage.shared.shareUserName.reload() - Storage.shared.sharePassword.reload() + // 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. + if Storage.shared.needsBFUReload { + Storage.shared.needsBFUReload = false + Storage.shared.reloadAll() + } // reset screenlock state if needed UIApplication.shared.isIdleTimerDisabled = Storage.shared.screenlockSwitchState.value From a79dac6d07cb5bb6bc5801f8324f4dff527c4887 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 22 Mar 2026 22:13:27 -0400 Subject: [PATCH 37/55] Add BFU diagnostic logs to AppDelegate and appCameToForeground Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/Application/AppDelegate.swift | 4 +++- LoopFollow/ViewControllers/MainViewController.swift | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 75b2e0916..ab7ac2a5a 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -51,7 +51,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // 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. - Storage.shared.needsBFUReload = !UIApplication.shared.isProtectedDataAvailable + let bfu = !UIApplication.shared.isProtectedDataAvailable + Storage.shared.needsBFUReload = bfu + LogManager.shared.log(category: .general, message: "BFU check: isProtectedDataAvailable=\(!bfu), needsBFUReload=\(bfu)") return true } diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 6a3a574c3..2a69e5c49 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -998,9 +998,12 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele // 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)'") } // reset screenlock state if needed From 7cf93fdf11d3ea87e25713565f7ea44adb5ca503 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 22 Mar 2026 22:24:53 -0400 Subject: [PATCH 38/55] Reschedule all tasks after BFU reload to fix blank charts on first foreground During BFU viewDidLoad, all tasks fire with url="" and reschedule 60s out. checkTasksNow() on first foreground finds nothing overdue. Fix: call scheduleAllTasks() after reloadAll() so tasks reset to their normal 2-5s initial delay, displacing the stale 60s BFU schedule. Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/ViewControllers/MainViewController.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 2a69e5c49..551db2559 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -1004,6 +1004,10 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele 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)'") + // 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 From b662c69b54f4671b3acdb5bd63ab8f7bc38f3feb Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 23 Mar 2026 08:20:07 -0400 Subject: [PATCH 39/55] Show loading overlay during BFU data reload instead of blank charts After BFU reloadAll(), viewDidLoad left isInitialLoad=false and no overlay. Reset loading state and show the overlay so the user sees the same spinner they see on a normal cold launch, rather than blank charts for 2-5 seconds. The overlay auto-hides via the normal markLoaded() path when data arrives. Co-Authored-By: Claude Sonnet 4.6 --- LoopFollow/ViewControllers/MainViewController.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 551db2559..d956beee5 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -1004,6 +1004,12 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele 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. From 3bdc8e6e77dc69c50cc9343a3e6f00369f176443 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 23 Mar 2026 08:38:22 -0400 Subject: [PATCH 40/55] Redesign CarPlay SmallFamilyView to match Loop's LA layout Two-column layout: BG + trend arrow + delta/unit on the left (colored by glucose threshold), projected BG + unit label on the right in white. Dynamic Island and lock screen views are unchanged. Co-Authored-By: Claude Sonnet 4.6 --- .../LoopFollowLiveActivity.swift | 42 ++++++++++++------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 5c28eed3b..720d58a26 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -119,25 +119,39 @@ private struct LockScreenFamilyAdaptiveView: View { 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)) + HStack(alignment: .center, spacing: 0) { + // Left: BG + trend arrow, delta with unit — colored by glucose threshold + VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(LAFormat.glucose(snapshot)) + .font(.system(size: 28, weight: .bold, design: .rounded)) + .monospacedDigit() + 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) - Text(LAFormat.trendArrow(snapshot)) - .font(.system(size: 22, weight: .semibold, design: .rounded)) - .foregroundStyle(.white.opacity(0.9)) + .foregroundStyle(LAColors.keyline(for: snapshot).opacity(0.85)) } - HStack(spacing: 8) { - Text(LAFormat.delta(snapshot)) - .font(.system(size: 14, weight: .semibold, design: .rounded)) + Spacer() + // Right: Projected BG with unit label + VStack(alignment: .trailing, spacing: 2) { + Text(LAFormat.projected(snapshot)) + .font(.system(size: 28, weight: .bold, design: .rounded)) .monospacedDigit() - .foregroundStyle(.white.opacity(0.85)) - Text(LAFormat.updated(snapshot)) + .foregroundStyle(.white) + Text(unitLabel) .font(.system(size: 14, weight: .regular, design: .rounded)) - .monospacedDigit() .foregroundStyle(.white.opacity(0.65)) } } From 0baa2d7e4e9c8f04c269020b9abf9db772bfd9f6 Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 23 Mar 2026 09:05:46 -0400 Subject: [PATCH 41/55] Fix CarPlay: bypass activityFamily detection in supplemental widget LoopFollowLiveActivityWidgetWithCarPlay is declared with .supplementalActivityFamilies([.small]) so it is only ever rendered in .small contexts (CarPlay, Watch Smart Stack). Use SmallFamilyView directly instead of routing through LockScreenFamilyAdaptiveView, which was falling through to LockScreenLiveActivityView when activityFamily wasn't detected as .small. Co-Authored-By: Claude Sonnet 4.6 --- LoopFollowLAExtension/LoopFollowLiveActivity.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 720d58a26..0c24b9895 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -60,11 +60,13 @@ struct LoopFollowLiveActivityWidget: Widget { /// Supplemental widget (iOS 18.0+) — adds CarPlay Dashboard + Watch Smart Stack /// via supplementalActivityFamilies([.small]). +/// This widget is ONLY shown in .small contexts (CarPlay, Watch Smart Stack), +/// so SmallFamilyView is used directly — no family-detection indirection needed. @available(iOS 18.0, *) struct LoopFollowLiveActivityWidgetWithCarPlay: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in - LockScreenFamilyAdaptiveView(state: context.state) + SmallFamilyView(snapshot: context.state.snapshot) .id(context.state.seq) } dynamicIsland: { context in makeDynamicIsland(context: context) From 2e7af367be553da78451150471411883b371754d Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 23 Mar 2026 09:22:50 -0400 Subject: [PATCH 42/55] Fix Watch/CarPlay: register only one widget per iOS version band Two ActivityConfiguration widgets for the same attributes type were registered simultaneously. The system used the primary widget for all contexts, ignoring the supplemental one. On iOS 18+: register only LoopFollowLiveActivityWidgetWithCarPlay (with .supplementalActivityFamilies([.small]) and family-adaptive routing via LockScreenFamilyAdaptiveView for all contexts). On iOS <18: register only LoopFollowLiveActivityWidget (lock screen and Dynamic Island only). Co-Authored-By: Claude Sonnet 4.6 --- LoopFollowLAExtension/LoopFollowLABundle.swift | 6 +++++- LoopFollowLAExtension/LoopFollowLiveActivity.swift | 10 +++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift index d98475b8e..419eb2132 100644 --- a/LoopFollowLAExtension/LoopFollowLABundle.swift +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -7,9 +7,13 @@ import WidgetKit @main struct LoopFollowLABundle: WidgetBundle { var body: some Widget { - LoopFollowLiveActivityWidget() + // Only one ActivityConfiguration for GlucoseLiveActivityAttributes is registered at a time. + // On iOS 18+, use the supplemental widget which handles Lock Screen, CarPlay, and Watch Smart Stack. + // On older iOS, use the primary widget (Lock Screen + Dynamic Island only). if #available(iOS 18.0, *) { LoopFollowLiveActivityWidgetWithCarPlay() + } else { + LoopFollowLiveActivityWidget() } } } diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 0c24b9895..faf841fb5 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -58,15 +58,15 @@ struct LoopFollowLiveActivityWidget: Widget { } } -/// Supplemental widget (iOS 18.0+) — adds CarPlay Dashboard + Watch Smart Stack -/// via supplementalActivityFamilies([.small]). -/// This widget is ONLY shown in .small contexts (CarPlay, Watch Smart Stack), -/// so SmallFamilyView is used directly — no family-detection indirection needed. +/// Supplemental widget (iOS 18.0+) — handles ALL rendering contexts: +/// Lock Screen (via LockScreenFamilyAdaptiveView → LockScreenLiveActivityView), +/// CarPlay Dashboard and Watch Smart Stack (via LockScreenFamilyAdaptiveView → SmallFamilyView). +/// Registered exclusively on iOS 18+; LoopFollowLiveActivityWidget is not registered on iOS 18+. @available(iOS 18.0, *) struct LoopFollowLiveActivityWidgetWithCarPlay: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in - SmallFamilyView(snapshot: context.state.snapshot) + LockScreenFamilyAdaptiveView(state: context.state) .id(context.state.seq) } dynamicIsland: { context in makeDynamicIsland(context: context) From 7c2d6d7937d960a5cf010cc46bf9d51d6ef049dd Mon Sep 17 00:00:00 2001 From: MtlPhil <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 23 Mar 2026 09:29:06 -0400 Subject: [PATCH 43/55] Fix build error and harden Watch/CarPlay routing - Revert bundle to if #available without else (WidgetBundleBuilder does not support if/else with #available) - Make primary widget also use LockScreenFamilyAdaptiveView on iOS 18+ so SmallFamilyView renders correctly regardless of which widget the system selects for .small contexts (CarPlay / Watch Smart Stack) Co-Authored-By: Claude Sonnet 4.6 --- .../LoopFollowLABundle.swift | 6 +----- .../LoopFollowLiveActivity.swift | 19 +++++++++++++------ 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift index 419eb2132..d98475b8e 100644 --- a/LoopFollowLAExtension/LoopFollowLABundle.swift +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -7,13 +7,9 @@ import WidgetKit @main struct LoopFollowLABundle: WidgetBundle { var body: some Widget { - // Only one ActivityConfiguration for GlucoseLiveActivityAttributes is registered at a time. - // On iOS 18+, use the supplemental widget which handles Lock Screen, CarPlay, and Watch Smart Stack. - // On older iOS, use the primary widget (Lock Screen + Dynamic Island only). + LoopFollowLiveActivityWidget() if #available(iOS 18.0, *) { LoopFollowLiveActivityWidgetWithCarPlay() - } else { - LoopFollowLiveActivityWidget() } } } diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index faf841fb5..5927d3b5a 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -43,15 +43,22 @@ private func makeDynamicIsland(context: ActivityViewContext Date: Mon, 23 Mar 2026 11:03:27 -0400 Subject: [PATCH 44/55] Watch & CarPlay widget --- .../LoopFollowLABundle.swift | 1 + .../LoopFollowLiveActivity.swift | 40 ++++++++++++++----- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift index d98475b8e..08307ec17 100644 --- a/LoopFollowLAExtension/LoopFollowLABundle.swift +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -8,6 +8,7 @@ import WidgetKit struct LoopFollowLABundle: WidgetBundle { var body: some Widget { LoopFollowLiveActivityWidget() + if #available(iOS 18.0, *) { LoopFollowLiveActivityWidgetWithCarPlay() } diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 5927d3b5a..a4a563b88 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -45,20 +45,16 @@ private func makeDynamicIsland(context: ActivityViewContext Date: Mon, 23 Mar 2026 11:53:16 -0400 Subject: [PATCH 45/55] Update LoopFollowLiveActivity.swift --- LoopFollowLAExtension/LoopFollowLiveActivity.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index a4a563b88..b3266dd73 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -121,6 +121,7 @@ private struct LockScreenFamilyAdaptiveView: View { } } +/* @available(iOS 18.0, *) private struct SmallFamilyView: View { let snapshot: GlucoseSnapshot @@ -134,13 +135,12 @@ private struct SmallFamilyView: View { } } } - +*/ // 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 @@ -186,7 +186,6 @@ private struct SmallFamilyView: View { .activityBackgroundTint(LAColors.backgroundTint(for: snapshot)) } } -*/ // MARK: - Lock Screen Contract View From bd3288d1a5f85226fe8c917b595301d4b8bf1517 Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 23 Mar 2026 12:25:06 -0400 Subject: [PATCH 46/55] Update LoopFollowLiveActivity.swift --- .../LoopFollowLiveActivity.swift | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index b3266dd73..b33adcc4d 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -66,6 +66,7 @@ struct LoopFollowLiveActivityWidget: Widget { /// CarPlay Dashboard and Watch Smart Stack (via LockScreenFamilyAdaptiveView → SmallFamilyView). /// Registered exclusively on iOS 18+; LoopFollowLiveActivityWidget is not registered on iOS 18+. +/* @available(iOS 18.0, *) struct LoopFollowLiveActivityWidgetWithCarPlay: Widget { var body: some WidgetConfiguration { @@ -82,6 +83,52 @@ struct LoopFollowLiveActivityWidgetWithCarPlay: Widget { .supplementalActivityFamilies([.small]) } } +*/ + +@available(iOS 18.0, *) +struct LoopFollowLiveActivityWidgetWithCarPlay: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: GlucoseLiveActivityAttributes.self) { context in + ZStack { + Color.blue + + VStack(spacing: 8) { + Text("NEW") + .font(.system(size: 28, weight: .bold, design: .rounded)) + Text("WIDGET") + .font(.system(size: 24, weight: .bold, design: .rounded)) + Text("iOS 18+") + .font(.system(size: 18, weight: .semibold, design: .rounded)) + } + .foregroundStyle(.white) + } + .id(context.state.seq) + .widgetURL(URL(string: "loopfollow://la-tap")!) + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Text("NEW") + .font(.headline) + } + DynamicIslandExpandedRegion(.trailing) { + Text("WGT") + .font(.headline) + } + DynamicIslandExpandedRegion(.bottom) { + Text("UPDATED BUILD") + .font(.headline) + } + } compactLeading: { + Text("N") + } compactTrailing: { + Text("W") + } minimal: { + Text("U") + } + } + .supplementalActivityFamilies([.small]) + } +} // MARK: - Live Activity content margins helper From f54977226ce209959a44d14dae68fc4941467227 Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:22:37 -0400 Subject: [PATCH 47/55] Update LoopFollowLABundle.swift --- .../LoopFollowLABundle.swift | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift index 08307ec17..e5383f975 100644 --- a/LoopFollowLAExtension/LoopFollowLABundle.swift +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -6,11 +6,23 @@ import WidgetKit @main struct LoopFollowLABundle: WidgetBundle { + @WidgetBundleBuilder var body: some Widget { - LoopFollowLiveActivityWidget() + LegacyLiveActivityBundle() + ModernLiveActivityBundle() + } +} - if #available(iOS 18.0, *) { - LoopFollowLiveActivityWidgetWithCarPlay() - } +@available(iOSApplicationExtension, introduced: 16.1, obsoleted: 18.0) +struct LegacyLiveActivityBundle: WidgetBundle { + var body: some Widget { + LoopFollowLiveActivityWidget() } } + +@available(iOSApplicationExtension 18.0, *) +struct ModernLiveActivityBundle: WidgetBundle { + var body: some Widget { + LoopFollowLiveActivityWidgetWithCarPlay() + } +} \ No newline at end of file From fcb5453978cbae304cae11fba76d2ac11fe562de Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:28:20 -0400 Subject: [PATCH 48/55] Update LoopFollowLABundle.swift --- .../LoopFollowLABundle.swift | 21 ++++++------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift index e5383f975..66c17d1e8 100644 --- a/LoopFollowLAExtension/LoopFollowLABundle.swift +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -8,21 +8,12 @@ import WidgetKit struct LoopFollowLABundle: WidgetBundle { @WidgetBundleBuilder var body: some Widget { - LegacyLiveActivityBundle() - ModernLiveActivityBundle() - } -} + if #available(iOSApplicationExtension 18.0, *) { + LoopFollowLiveActivityWidgetWithCarPlay() + } -@available(iOSApplicationExtension, introduced: 16.1, obsoleted: 18.0) -struct LegacyLiveActivityBundle: WidgetBundle { - var body: some Widget { - LoopFollowLiveActivityWidget() - } -} - -@available(iOSApplicationExtension 18.0, *) -struct ModernLiveActivityBundle: WidgetBundle { - var body: some Widget { - LoopFollowLiveActivityWidgetWithCarPlay() + if #unavailable(iOSApplicationExtension 18.0) { + LoopFollowLiveActivityWidget() + } } } \ No newline at end of file From 10e2d84d7eedf82afe3c63e5d22c01bca825eb50 Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:40:42 -0400 Subject: [PATCH 49/55] Update LoopFollowLABundle.swift --- LoopFollowLAExtension/LoopFollowLABundle.swift | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift index 66c17d1e8..8d16ea6d9 100644 --- a/LoopFollowLAExtension/LoopFollowLABundle.swift +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -6,14 +6,11 @@ import WidgetKit @main struct LoopFollowLABundle: WidgetBundle { - @WidgetBundleBuilder var body: some Widget { + LoopFollowLiveActivityWidget() + if #available(iOSApplicationExtension 18.0, *) { LoopFollowLiveActivityWidgetWithCarPlay() } - - if #unavailable(iOSApplicationExtension 18.0) { - LoopFollowLiveActivityWidget() - } } } \ No newline at end of file From 44400a0b238f565fd8c101db2b19901bc16802ab Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:46:13 -0400 Subject: [PATCH 50/55] Update LoopFollowLiveActivity.swift --- LoopFollowLAExtension/LoopFollowLiveActivity.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index b33adcc4d..d82782cec 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -45,7 +45,7 @@ private func makeDynamicIsland(context: ActivityViewContext Date: Mon, 23 Mar 2026 16:04:35 -0400 Subject: [PATCH 51/55] Update LoopFollowLABundle.swift --- LoopFollowLAExtension/LoopFollowLABundle.swift | 7 ------- 1 file changed, 7 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLABundle.swift b/LoopFollowLAExtension/LoopFollowLABundle.swift index 8d16ea6d9..fa75e44e4 100644 --- a/LoopFollowLAExtension/LoopFollowLABundle.swift +++ b/LoopFollowLAExtension/LoopFollowLABundle.swift @@ -1,6 +1,3 @@ -// LoopFollow -// LoopFollowLABundle.swift - import SwiftUI import WidgetKit @@ -8,9 +5,5 @@ import WidgetKit struct LoopFollowLABundle: WidgetBundle { var body: some Widget { LoopFollowLiveActivityWidget() - - if #available(iOSApplicationExtension 18.0, *) { - LoopFollowLiveActivityWidgetWithCarPlay() - } } } \ No newline at end of file From d9eed5d4595b45f24a4bfbe91317637395d64787 Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:09:49 -0400 Subject: [PATCH 52/55] Update LoopFollowLiveActivity.swift --- .../LoopFollowLiveActivity.swift | 197 +++++------------- 1 file changed, 55 insertions(+), 142 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index d82782cec..3a194da1d 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -5,7 +5,7 @@ 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) { @@ -42,91 +42,38 @@ 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 @@ -147,47 +93,25 @@ 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) } else { LockScreenLiveActivityView(state: state) - .activitySystemActionForegroundColor(.white) - .activityBackgroundTint(LAColors.backgroundTint(for: state.snapshot)) - .applyActivityContentMarginsFixIfAvailable() - .widgetURL(URL(string: "loopfollow://la-tap")!) } } } -/* -@available(iOS 18.0, *) -private struct SmallFamilyView: View { - let snapshot: GlucoseSnapshot - - var body: some View { - ZStack { - Color.red - Text("SMALL") - .font(.headline) - .foregroundStyle(.white) - } - } -} -*/ // 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 @@ -201,28 +125,31 @@ private struct SmallFamilyView: View { var body: some View { HStack(alignment: .center, spacing: 0) { - // Left: BG + trend arrow, delta with unit — colored by glucose threshold VStack(alignment: .leading, spacing: 2) { HStack(alignment: .firstTextBaseline, spacing: 4) { Text(LAFormat.glucose(snapshot)) .font(.system(size: 28, weight: .bold, design: .rounded)) .monospacedDigit() + 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(LAColors.keyline(for: snapshot).opacity(0.85)) } + Spacer() - // Right: Projected BG with unit label + 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)) @@ -230,7 +157,6 @@ private struct SmallFamilyView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) .padding(10) - .activityBackgroundTint(LAColors.backgroundTint(for: snapshot)) } } @@ -245,7 +171,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)) @@ -273,13 +198,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) @@ -293,7 +216,6 @@ private struct LockScreenLiveActivityView: View { .frame(maxWidth: .infinity, alignment: .trailing) } - // Footer: last update time Text("Last Update: \(LAFormat.updated(s))") .font(.system(size: 11, weight: .regular, design: .rounded)) .monospacedDigit() @@ -306,7 +228,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 { @@ -314,23 +236,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) ) } } @@ -371,19 +295,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 { @@ -423,6 +344,7 @@ private struct SlotView: View { private struct DynamicIslandLeadingView: View { let snapshot: GlucoseSnapshot + var body: some View { if snapshot.isNotLooping { Text("⚠️ Not Looping") @@ -437,14 +359,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() @@ -457,6 +382,7 @@ private struct DynamicIslandLeadingView: View { private struct DynamicIslandTrailingView: View { let snapshot: GlucoseSnapshot + var body: some View { if snapshot.isNotLooping { EmptyView() @@ -466,6 +392,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() @@ -478,6 +405,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") @@ -497,6 +425,7 @@ private struct DynamicIslandBottomView: View { private struct DynamicIslandCompactTrailingView: View { let snapshot: GlucoseSnapshot + var body: some View { if snapshot.isNotLooping { Text("Not Looping") @@ -515,6 +444,7 @@ private struct DynamicIslandCompactTrailingView: View { private struct DynamicIslandCompactLeadingView: View { let snapshot: GlucoseSnapshot + var body: some View { if snapshot.isNotLooping { Text("⚠️") @@ -530,6 +460,7 @@ private struct DynamicIslandCompactLeadingView: View { private struct DynamicIslandMinimalView: View { let snapshot: GlucoseSnapshot + var body: some View { if snapshot.isNotLooping { Text("⚠️") @@ -546,8 +477,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 @@ -575,8 +504,6 @@ private enum LAFormat { } } - // MARK: Glucose - static func glucose(_ s: GlucoseSnapshot) -> String { formatGlucoseValue(s.glucose, unit: s.unit) } @@ -587,7 +514,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 @@ -597,8 +523,6 @@ private enum LAFormat { } } - // MARK: Trend - static func trendArrow(_ s: GlucoseSnapshot) -> String { switch s.trend { case .upFast: "↑↑" @@ -612,8 +536,6 @@ private enum LAFormat { } } - // MARK: Secondary - static func iob(_ s: GlucoseSnapshot) -> String { guard let v = s.iob else { return "—" } return String(format: "%.1f", v) @@ -629,8 +551,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 @@ -639,7 +559,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 @@ -717,13 +636,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 }() @@ -744,12 +661,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 @@ -758,12 +674,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) } @@ -771,7 +685,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 @@ -784,4 +697,4 @@ private enum LAColors { return Color(uiColor: UIColor.systemGreen) } } -} +} \ No newline at end of file From 897a410c014a43b5d048deae99ab196208020903 Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:22:27 -0400 Subject: [PATCH 53/55] Update LoopFollowLiveActivity.swift --- LoopFollowLAExtension/LoopFollowLiveActivity.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 3a194da1d..28295bd3c 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -111,7 +111,6 @@ private struct LockScreenFamilyAdaptiveView: View { } // MARK: - Small family view (CarPlay Dashboard + Watch Smart Stack) - @available(iOS 18.0, *) private struct SmallFamilyView: View { let snapshot: GlucoseSnapshot @@ -130,16 +129,17 @@ private struct SmallFamilyView: View { 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)) } - .foregroundStyle(LAColors.keyline(for: snapshot)) Text("\(LAFormat.delta(snapshot)) \(unitLabel)") .font(.system(size: 14, weight: .semibold, design: .rounded)) .monospacedDigit() - .foregroundStyle(LAColors.keyline(for: snapshot).opacity(0.85)) + .foregroundStyle(.white.opacity(0.85)) } Spacer() From b90839b1bf98c76557275e144938c861f937b13c Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:53:25 -0400 Subject: [PATCH 54/55] Update LoopFollowLiveActivity.swift --- LoopFollowLAExtension/LoopFollowLiveActivity.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 28295bd3c..5079a6ad8 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -55,7 +55,6 @@ struct LoopFollowLiveActivityWidget: Widget { LockScreenFamilyAdaptiveView(state: context.state) .id(context.state.seq) .activitySystemActionForegroundColor(.white) - .activityBackgroundTint(LAColors.backgroundTint(for: context.state.snapshot)) .applyActivityContentMarginsFixIfAvailable() .widgetURL(URL(string: "loopfollow://la-tap")!) } dynamicIsland: { context in @@ -104,8 +103,10 @@ private struct LockScreenFamilyAdaptiveView: View { var body: some View { if activityFamily == .small { SmallFamilyView(snapshot: state.snapshot) + .activityBackgroundTint(Color.black.opacity(0.25)) } else { LockScreenLiveActivityView(state: state) + .activityBackgroundTint(LAColors.backgroundTint(for: state.snapshot)) } } } From 3b2f920567c889dc3db9d139fc90630d170c58de Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 12:33:42 +0000 Subject: [PATCH 55/55] Remove docs/ directory from PR scope The LiveActivity.md doc file should not be part of this PR. https://claude.ai/code/session_01WaUhT8PoPNKumX9ZK9jeBy --- docs/LiveActivity.md | 165 ------------------------------------------- 1 file changed, 165 deletions(-) delete mode 100644 docs/LiveActivity.md 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.