From a8ad35da9a0d586d6ac0eaa2361a2bb97492f45c Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 15 Sep 2026 17:03:13 -0500 Subject: [PATCH] Persist glucose alert episode state across launches lowState, urgentLowState, highState and predictedLowInEpisode were in-memory only, so every launch reset lastFiredAt to nil. Acknowledge a High Glucose alert, restart the app, and the next in-boundary reading re-alerts it. The same applied to Urgent Low, and a restart also defeated an active snooze, since the repeat interval is enforced purely through lastFiredAt. Encode the four as a single blob in UserDefaults, written from a didSet on each and restored in init. --- .../Managers/Alerts/GlucoseAlertManager.swift | 36 ++++++++++++--- .../Managers/GlucoseAlertManagerTests.swift | 44 +++++++++++++++++++ 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/Loop/Managers/Alerts/GlucoseAlertManager.swift b/Loop/Managers/Alerts/GlucoseAlertManager.swift index 45b3ce8b38..2dbbedaa36 100644 --- a/Loop/Managers/Alerts/GlucoseAlertManager.swift +++ b/Loop/Managers/Alerts/GlucoseAlertManager.swift @@ -227,6 +227,7 @@ final class GlucoseAlertManager: ObservableObject { private static let lowSoundKey = "GlucoseAlertLowSound" private static let highSoundKey = "GlucoseAlertHighSound" private static let predictedLowSoundKey = "GlucoseAlertPredictedLowSound" + private static let episodeStateKey = "GlucoseAlertEpisodeState" // Per-alarm sound defaults. Urgent low keeps the loud critical tone; // the rest get a gentler default the user can change. @@ -436,17 +437,27 @@ final class GlucoseAlertManager: ObservableObject { // MARK: - Hysteresis - private struct AlertState: Equatable { + private struct AlertState: Equatable, Codable { var inBoundary: Bool = false var lastFiredAt: Date? /// When BG first crossed into the alert boundary this episode. Used to /// honor a configured first-alert delay. Reset on recovery. var boundaryEnteredAt: Date? } - private var lowState = AlertState() - private var urgentLowState = AlertState() - private var highState = AlertState() - private var predictedLowInEpisode = false + + /// Episode state survives relaunch: without it a restart clears + /// `lastFiredAt` and the next in-boundary reading re-alerts something the + /// user already acknowledged, and defeats an active snooze. + private struct EpisodeState: Codable { + var low: AlertState + var urgentLow: AlertState + var high: AlertState + var predictedLowInEpisode: Bool + } + private var lowState = AlertState() { didSet { persistEpisodeState() } } + private var urgentLowState = AlertState() { didSet { persistEpisodeState() } } + private var highState = AlertState() { didSet { persistEpisodeState() } } + private var predictedLowInEpisode = false { didSet { persistEpisodeState() } } /// Most recent real CGM reading. Used to suppress a redundant predicted-low /// alert when glucose is already at/below the Low threshold. private var latestReading: (mgdl: Double, date: Date)? @@ -525,6 +536,14 @@ final class GlucoseAlertManager: ObservableObject { for key in keysToRemove { userDefaults.removeObject(forKey: key) } } + if let data = userDefaults.data(forKey: Self.episodeStateKey), + let saved = try? JSONDecoder().decode(EpisodeState.self, from: data) { + lowState = saved.low + urgentLowState = saved.urgentLow + highState = saved.high + predictedLowInEpisode = saved.predictedLowInEpisode + } + NotificationCenter.default.publisher(for: .LoopCycleCompleted) .sink { [weak self] notification in guard let predicted = (notification.object as? LoopDataManager)?.predictedGlucose else { return } @@ -549,6 +568,13 @@ final class GlucoseAlertManager: ObservableObject { userDefaults.set(data, forKey: Self.profilesKey) } + private func persistEpisodeState() { + let state = EpisodeState(low: lowState, urgentLow: urgentLowState, + high: highState, predictedLowInEpisode: predictedLowInEpisode) + guard let data = try? JSONEncoder().encode(state) else { return } + userDefaults.set(data, forKey: Self.episodeStateKey) + } + // MARK: - Evaluation func evaluate(samples: [NewGlucoseSample], now: Date = Date()) async { diff --git a/LoopTests/Managers/GlucoseAlertManagerTests.swift b/LoopTests/Managers/GlucoseAlertManagerTests.swift index 408d4b42ef..9dca1d1a78 100644 --- a/LoopTests/Managers/GlucoseAlertManagerTests.swift +++ b/LoopTests/Managers/GlucoseAlertManagerTests.swift @@ -90,4 +90,48 @@ final class GlucoseAlertManagerTests: XCTestCase { await manager.evaluate(samples: [sample(50, at: now)], now: now) XCTAssertEqual(issuer.issuedIDs, [GlucoseAlertManager.lowAlertIdentifier]) } + + /// Simulates an app restart by building a second manager over the same + /// defaults. + private func relaunch() -> GlucoseAlertManager { + GlucoseAlertManager(alertIssuer: issuer, userDefaults: defaults) + } + + func testHighAlertDoesNotRepeatAfterRelaunch() async { + let now = Date() + await manager.evaluate(samples: [sample(200, at: now)], now: now) + XCTAssertEqual(issuer.issuedIDs, [GlucoseAlertManager.highAlertIdentifier]) + + issuer.reset() + let restarted = relaunch() + let later = now.addingTimeInterval(5 * 60) + await restarted.evaluate(samples: [sample(205, at: later)], now: later) + XCTAssertEqual(issuer.issuedIDs, [], "Still the same episode; must not re-alert") + } + + func testRecoveryAfterRelaunchRetractsHigh() async { + let now = Date() + await manager.evaluate(samples: [sample(200, at: now)], now: now) + issuer.reset() + + let restarted = relaunch() + let later = now.addingTimeInterval(5 * 60) + await restarted.evaluate(samples: [sample(150, at: later)], now: later) + XCTAssertEqual(issuer.retractedIDs, [GlucoseAlertManager.highAlertIdentifier]) + } + + /// A new episode after recovery alerts again. + func testHighAlertsAgainInNewEpisodeAfterRelaunch() async { + let now = Date() + await manager.evaluate(samples: [sample(200, at: now)], now: now) + + let restarted = relaunch() + let recovered = now.addingTimeInterval(5 * 60) + await restarted.evaluate(samples: [sample(150, at: recovered)], now: recovered) + issuer.reset() + + let rising = now.addingTimeInterval(10 * 60) + await restarted.evaluate(samples: [sample(200, at: rising)], now: rising) + XCTAssertEqual(issuer.issuedIDs, [GlucoseAlertManager.highAlertIdentifier]) + } }