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..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() @@ -45,6 +45,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { DispatchQueue.main.async { UIApplication.shared.registerForRemoteNotifications() } + + BackgroundRefreshManager.shared.register() return true } @@ -56,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] { @@ -80,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) @@ -88,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)") } } } @@ -120,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) { @@ -176,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 a8fbb236f..3819a7ac6 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 @@ -71,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" @@ -84,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/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() 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/Helpers/BackgroundTaskAudio.swift b/LoopFollow/Helpers/BackgroundTaskAudio.swift index 3f19ac63c..25aa6b3c8 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() @@ -25,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, @@ -57,7 +58,7 @@ class BackgroundTask { } } - 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") 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/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 db4836b88..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 diff --git a/LoopFollow/LiveActivity/GlucoseSnapshot.swift b/LoopFollow/LiveActivity/GlucoseSnapshot.swift index 4e914ab7e..8860391c2 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshot.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshot.swift @@ -111,10 +111,12 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { // MARK: - Renewal - /// True when the Live Activity is within 30 minutes of its renewal deadline. + /// 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, @@ -144,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 @@ -177,6 +179,52 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { 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) @@ -210,17 +258,6 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { 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) @@ -254,11 +291,13 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { showRenewalOverlay = try container.decodeIfPresent(Bool.self, forKey: .showRenewalOverlay) ?? false } - // MARK: - Derived Convenience - - /// Age of reading in seconds. - var age: TimeInterval { - Date().timeIntervalSince(updatedAt) + 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 } } diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift index dd845b116..40ff076af 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift @@ -3,10 +3,12 @@ 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 +17,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,43 +110,28 @@ 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")", - isDebug: true + isDebug: true, ) return nil } 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( category: .general, message: "LA snapshot built: updatedAt=\(updatedAt) interval=\(updatedAt.timeIntervalSince1970)", - isDebug: true + isDebug: true, ) - let profileNameRaw = Storage.shared.lastProfileName.value return GlucoseSnapshot( glucose: glucoseMgdl, delta: deltaMgdl, @@ -79,31 +140,33 @@ 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? @@ -112,11 +175,6 @@ enum GlucoseSnapshotBuilder { !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 +184,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 } 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 00d230e40..9faa8a41e 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -8,8 +8,9 @@ import Foundation import os import UIKit +import UserNotifications -/// Live Activity manager for LoopFollow. +// Live Activity manager for LoopFollow. final class LiveActivityManager { static let shared = LiveActivityManager() @@ -18,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, ) } @@ -55,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) @@ -65,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 { @@ -184,23 +185,29 @@ final class LiveActivityManager { 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() + producedAt: Date(), ) let renewDeadline = Date().addingTimeInterval(LiveActivityManager.renewalThreshold) @@ -249,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) @@ -277,6 +284,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 @@ -300,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) } @@ -336,32 +344,24 @@ final class LiveActivityManager { 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, - showRenewalOverlay: 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, 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) - // New LA is live — now it's safe to remove the old card. Task { await oldActivity.end(nil, dismissalPolicy: .immediate) } @@ -374,16 +374,23 @@ final class LiveActivityManager { stateObserverTask = nil pushToken = nil - bind(to: newActivity, logReason: "renew") + // 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 - // Update the store so the next duplicate check has the correct baseline. + cancelRenewalFailedNotification() GlucoseSnapshotStore.shared.save(freshSnapshot) LogManager.shared.log(category: .general, message: "[LA] Live Activity renewed successfully id=\(newActivity.id)") return true } catch { + // 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)") + if isFirstFailure { + scheduleRenewalFailedNotification() + } return false } } @@ -415,7 +422,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 { @@ -460,21 +467,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 } @@ -495,15 +502,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) } } @@ -548,6 +555,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 { + 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 { @@ -560,11 +594,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") + } } } } 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). 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/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 + } } 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/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 7d6f8bf35..bbf2de63c 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -964,6 +964,7 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele if Storage.shared.backgroundRefreshType.value == .silentTune { backgroundTask.startBackgroundTask() + BackgroundRefreshManager.shared.scheduleRefresh() } if Storage.shared.backgroundRefreshType.value != .none { 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: "–" } }