Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 31 additions & 5 deletions Loop/Managers/Alerts/GlucoseAlertManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)?
Expand Down Expand Up @@ -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 }
Expand All @@ -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 {
Expand Down
44 changes: 44 additions & 0 deletions LoopTests/Managers/GlucoseAlertManagerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
}