diff --git a/LibreLoop.xcodeproj/xcshareddata/xcschemes/LibreLoop.xcscheme b/LibreLoop.xcodeproj/xcshareddata/xcschemes/LibreLoop.xcscheme
new file mode 100644
index 0000000..0ac610d
--- /dev/null
+++ b/LibreLoop.xcodeproj/xcshareddata/xcschemes/LibreLoop.xcscheme
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift b/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift
index fb31b9e..a3b0118 100644
--- a/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift
+++ b/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift
@@ -251,6 +251,19 @@ public final class LibreLoopCGMManager: CGMManager {
/// One-shot guard so the re-scan alert fires once per failure run, not every
/// failed attempt.
var hasIssuedReScanAlert = false
+
+ /// Every alert identifier this manager can issue.
+ ///
+ /// Alerts outlive the manager: Loop's AlertStore keeps them for the whole
+ /// local-cache window (90 days in this build), and launch-time playback
+ /// rebuilds any past-due `.delayed` alert as `.immediate` and presents it
+ /// again. So an alert left standing when the sensor is discarded or the CGM
+ /// is deleted re-fires on *every* app launch until the user acknowledges it.
+ /// Retract the whole set at both of those points — keep this list complete
+ /// when adding an alert.
+ static var allAlertIdentifiers: [Alert.AlertIdentifier] {
+ LibreLoopExpiryAlerts.allIdentifiers + [sensorAttentionAlertID, needsReScanAlertID]
+ }
/// Cap on the exponential reconnect backoff (seconds) so a persistently
/// failing/marginal link doesn't hammer the radio and drain the battery.
static let maxReconnectBackoff: TimeInterval = 300
@@ -332,7 +345,11 @@ public final class LibreLoopCGMManager: CGMManager {
monitor = nil
isReconnecting = false
recentSamples = []
- retractExpiryAlerts()
+ // Every standing alert belongs to the sensor we're discarding — the
+ // expiry schedule, and any sensor-attention / re-scan notice.
+ hasIssuedReScanAlert = false
+ lastSensorAttention = nil
+ retractAllAlerts()
// Emit .sensorEnd before we blank state so the event's
// deviceIdentifier still resolves to the session that's ending.
// Matches the .sensorStart we emitted at pairing time so Loop's
@@ -809,7 +826,17 @@ public final class LibreLoopCGMManager: CGMManager {
scanner.cancelConnection(peripheral)
}
}
- completion()
+ // Alerts outlive the manager. Anything still standing in Loop's
+ // AlertStore is replayed at every launch — a deleted CGM's expiry
+ // reminder re-firing days later is exactly the bug this prevents.
+ // Retract before notifying the delegate, while our delegate reference
+ // is still good.
+ retractAllAlerts()
+ // Notify last: `cgmManagerWantsDeletion` is what actually drops this
+ // manager from Loop, and it releases us. The LoopKit default `delete`
+ // does only this; we override it to add the teardown above, so the
+ // notification has to be re-issued here or the manager is never removed.
+ notifyDelegateOfDeletion(completion: completion)
}
}
diff --git a/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift b/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift
index d6b16a0..1b1fa6d 100644
--- a/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift
+++ b/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift
@@ -47,12 +47,22 @@ extension LibreLoopCGMManager {
return activatedAt
}
- func retractExpiryAlerts() {
+ /// Retract every alert this manager could have standing — the scheduled
+ /// expiry set plus the sensor-attention and re-scan alerts.
+ ///
+ /// Must be called whenever the sensor goes away (discard) or the manager
+ /// does (delete). An alert left in Loop's AlertStore unacknowledged and
+ /// unretracted is replayed at every app launch for the life of the cache
+ /// window — see `LibreLoopCGMManager.allAlertIdentifiers`.
+ ///
+ /// The delegate is captured strongly up front so the retraction still
+ /// lands if this manager is released immediately afterwards.
+ func retractAllAlerts() {
let delegate = cgmManagerDelegate
- let identifiers = LibreLoopExpiryAlerts.allIdentifiers.map {
+ let identifiers = Self.allAlertIdentifiers.map {
Alert.Identifier(managerIdentifier: pluginIdentifier, alertIdentifier: $0)
}
- llog("expiry alerts: retracting \(identifiers.count) identifier(s)")
+ llog("alerts: retracting \(identifiers.count) identifier(s)")
Task {
for identifier in identifiers {
await delegate?.retractAlert(identifier: identifier)
diff --git a/LibreLoop/Sensor/LibreLoopSensorMonitor.swift b/LibreLoop/Sensor/LibreLoopSensorMonitor.swift
index 5ecbe39..75f7517 100644
--- a/LibreLoop/Sensor/LibreLoopSensorMonitor.swift
+++ b/LibreLoop/Sensor/LibreLoopSensorMonitor.swift
@@ -78,13 +78,8 @@ public final class LibreLoopSensorMonitor: @unchecked Sendable {
private var lastPatchStatusAt: Date?
/// Last time a glucose frame arrived.
private var lastGlucoseAt: Date?
- /// Stuck-value detector state: the last raw current-glucose word, the
- /// lifeCount it arrived at, and the count of consecutive *advancing* frames
- /// that repeated it. Catches a held/frozen glucose (e.g. after a DQ error) —
- /// the repeats carry no error flag, so they look valid and get forwarded.
- private var lastGlucoseWord: UInt16?
- private var lastGlucoseWordLifeCount: UInt16?
- private var stuckGlucoseRun: Int = 0
+ /// Watches for a held/frozen current glucose — see `StuckGlucoseDetector`.
+ private var stuckDetector = StuckGlucoseDetector()
private var readingHandler: ReadingHandler?
private var disconnectHandler: DisconnectHandler?
private var statusHandler: StatusHandler?
@@ -555,23 +550,29 @@ public final class LibreLoopSensorMonitor: @unchecked Sendable {
lock.lock()
let lcHandler = lifeCountHandler
lastGlucoseAt = event.receivedAt // feed the silence watchdog
- // Stuck-value detector: count consecutive *advancing* frames that
- // repeat the raw current-glucose word. A same-minute resend
- // (lifeCount unchanged) doesn't count; a new lifeCount carrying
- // an identical word is a held/frozen value.
- if reading.lifeCount == lastGlucoseWordLifeCount {
- // same-minute resend — ignore for the stuck run
- } else if lastGlucoseWord == reading.currentWord {
- stuckGlucoseRun += 1
- } else {
- stuckGlucoseRun = 0
- }
- lastGlucoseWord = reading.currentWord
- lastGlucoseWordLifeCount = reading.lifeCount
- let stuckRun = stuckGlucoseRun
+ let histLag = Int(reading.lifeCount) - Int(reading.historicalLifeCount)
+ let stuckReport = stuckDetector.observe(.init(
+ lifeCount: reading.lifeCount,
+ currentWord: reading.currentWord,
+ currentMgDL: reading.currentGlucoseMgDL,
+ historicMgDL: reading.isHistoricalGlucoseValid ? reading.historicalGlucoseMgDL : nil,
+ historicLifeCount: reading.historicalLifeCount
+ ))
lock.unlock()
- if stuckRun >= 3 {
- llog("STUCK: current glucose word \(String(format: "0x%04x", reading.currentWord)) unchanged across \(stuckRun + 1) advancing frames (lifeCount=\(reading.lifeCount) mgdl=\(mgdlStr) dq=\(reading.dqError))")
+ switch stuckReport {
+ case .held(let held):
+ let histText = held.historicMgDL.map {
+ "\($0) mg/dL @LC \(held.historicLifeCount) (lag \(held.historicLag))"
+ } ?? "unavailable"
+ let driftText = held.historicDrift.map { String(format: "%+d", $0) } ?? "n/a"
+ let gapText = held.currentVsHistoric.map(String.init) ?? "n/a"
+ let label = held.diverged ? "STUCK-LATCH" : "STUCK"
+ llog("\(label): current glucose word \(String(format: "0x%04x", held.word)) unchanged across \(held.frames) advancing frames (lifeCount=\(reading.lifeCount) mgdl=\(mgdlStr) dq=\(reading.dqError) historic=\(histText) historicDrift=\(driftText) curVsHistoric=\(gapText) mg/dL)")
+ case .cleared(let frames, let step):
+ let stepText = step.map { String(format: "%+d", $0) } ?? "n/a"
+ llog("STUCK cleared after \(frames) advancing frames (lifeCount=\(reading.lifeCount) mgdl=\(mgdlStr) step=\(stepText) mg/dL)")
+ case nil:
+ break
}
lcHandler?(reading.lifeCount)
if let sample = Self.makeSample(from: reading, assessment: assessment, receivedAt: event.receivedAt) {
@@ -593,7 +594,6 @@ public final class LibreLoopSensorMonitor: @unchecked Sendable {
lock.unlock()
embedded?(reading.historicalLifeCount, histMgDL)
}
- let histLag = Int(reading.lifeCount) - Int(reading.historicalLifeCount)
emitRead("Realtime", summary: "\(reading.currentGlucoseMgDL.map(String.init) ?? "—") mg/dL LC \(reading.lifeCount)", at: event.receivedAt, [
("currentGlucose", reading.currentGlucoseMgDL.map { "\($0) mg/dL" } ?? "—"),
("currentValid", "\(reading.isCurrentGlucoseValid)"),
@@ -730,3 +730,143 @@ extension LibreLoopSensorMonitor {
try LibreLoopSensorMonitor(scanner: scanner, session: session, kEnc: kEnc, ivEnc: ivEnc)
}
}
+
+
+/// Watches the realtime glucose stream for a *held* current value — the same
+/// raw current-glucose word repeating across frames whose lifeCount keeps
+/// advancing. The repeats carry no error flag, so a held value looks perfectly
+/// valid on the way to Loop.
+///
+/// Run length on its own can't separate a held value from genuinely flat
+/// glucose. At 1 mg/dL resolution real glucose repeats for a surprisingly long
+/// time: a healthy 5-hour field capture contained runs of 4, 6, 7 and 10
+/// advancing frames, every one of them with the surrounding frame bytes and the
+/// sensor's historic series advancing normally. So the threshold sits above
+/// that observed ceiling, and every report also carries a second opinion: the
+/// sensor's committed 5-min historic series, which is produced independently
+/// and lands ~15 minutes behind the live value.
+///
+/// Once a run has outlasted its own historic lag, the two records are
+/// describing the same minutes, so the historic should have converged on the
+/// pinned value. If it hasn't, they genuinely disagree and the live value is
+/// the suspect (`diverged`). If it has, the whole sensor is reporting the
+/// value — which is just as useful to know, and is what the false-low report
+/// that motivated this detector actually looked like.
+///
+/// Pure and self-contained so it can be exercised directly in tests; the
+/// monitor owns one instance and formats the reports into the log.
+struct StuckGlucoseDetector {
+ /// Advancing frames a repeated word must span before it is worth a log line.
+ static let warnRun = 12
+ /// After the first report, repeat only every Nth frame — an hour-long hold
+ /// then costs ~10 lines instead of ~55.
+ static let repeatEvery = 5
+ /// Gap, in mg/dL, between the pinned current value and the historic series
+ /// that counts as the two records disagreeing.
+ static let divergenceMgDL = 15
+
+ struct Frame {
+ var lifeCount: UInt16
+ var currentWord: UInt16
+ /// Normalized displayable value; nil for an error/unavailable word.
+ var currentMgDL: UInt16?
+ /// Normalized historic value, or nil when the frame's historic slot
+ /// isn't valid yet.
+ var historicMgDL: UInt16?
+ var historicLifeCount: UInt16
+ }
+
+ struct Held: Equatable {
+ var word: UInt16
+ /// Advancing frames the run spans, inclusive of the frame that opened it.
+ var frames: Int
+ var mgDL: UInt16?
+ var historicMgDL: UInt16?
+ var historicLifeCount: UInt16
+ var historicLag: Int
+ /// How far the historic series moved since the run opened.
+ var historicDrift: Int?
+ var currentVsHistoric: Int?
+ /// The run has outlasted the historic lag and the records still disagree.
+ var diverged: Bool
+ }
+
+ enum Report: Equatable {
+ case held(Held)
+ /// A run that had been reported just ended. `step` is the size of the
+ /// move that broke it — diagnostic in itself, since a real flat stretch
+ /// resumes by ±1-2 while a released hold jumps.
+ case cleared(frames: Int, step: Int?)
+ }
+
+ private var lastWord: UInt16?
+ private var lastLifeCount: UInt16?
+ private var run = 0
+ private var reported = false
+ private var runStartHistoric: UInt16?
+ private var runMgDL: UInt16?
+
+ /// Feeds one realtime frame in. Returns a report when the frame crosses the
+ /// reporting threshold, lands on a repeat interval, or ends a reported run.
+ mutating func observe(_ frame: Frame) -> Report? {
+ var cleared: Report?
+
+ if frame.currentMgDL == nil {
+ // A repeated *error* word is a different failure, already surfaced
+ // through the quality-assessment path and never forwarded to Loop.
+ cleared = endRun(brokenBy: nil)
+ } else if frame.lifeCount == lastLifeCount {
+ // Same-minute resend — carries no new information about a hold.
+ } else if lastWord == frame.currentWord {
+ run += 1
+ if runStartHistoric == nil { runStartHistoric = frame.historicMgDL }
+ } else {
+ cleared = endRun(brokenBy: frame)
+ }
+
+ lastWord = frame.currentWord
+ lastLifeCount = frame.lifeCount
+
+ if let cleared { return cleared }
+
+ guard run >= Self.warnRun,
+ !reported || (run - Self.warnRun) % Self.repeatEvery == 0 else { return nil }
+ reported = true
+
+ let lag = Int(frame.lifeCount) - Int(frame.historicLifeCount)
+ let drift = zip2(frame.historicMgDL, runStartHistoric) { Int($0) - Int($1) }
+ let gap = zip2(frame.currentMgDL, frame.historicMgDL) { abs(Int($0) - Int($1)) }
+ return .held(Held(word: frame.currentWord,
+ frames: run + 1,
+ mgDL: frame.currentMgDL,
+ historicMgDL: frame.historicMgDL,
+ historicLifeCount: frame.historicLifeCount,
+ historicLag: lag,
+ historicDrift: drift,
+ currentVsHistoric: gap,
+ diverged: run > max(lag, 0) && (gap ?? 0) > Self.divergenceMgDL))
+ }
+
+ /// Resets run state, returning a `.cleared` report if the run that just
+ /// ended had been reported.
+ private mutating func endRun(brokenBy frame: Frame?) -> Report? {
+ let wasReported = reported
+ let length = run
+ let held = runMgDL
+
+ run = 0
+ reported = false
+ runStartHistoric = frame?.historicMgDL
+ runMgDL = frame?.currentMgDL
+
+ guard wasReported else { return nil }
+ let step = zip2(frame?.currentMgDL, held) { Int($0) - Int($1) }
+ return .cleared(frames: length + 1, step: step)
+ }
+}
+
+/// Combines two optionals, yielding nil unless both are present.
+private func zip2(_ a: A?, _ b: B?, _ transform: (A, B) -> R) -> R? {
+ guard let a, let b else { return nil }
+ return transform(a, b)
+}
diff --git a/LibreLoopTests/LibreLoopCGMManagerStateTests.swift b/LibreLoopTests/LibreLoopCGMManagerStateTests.swift
index 0b6c6c6..e2addde 100644
--- a/LibreLoopTests/LibreLoopCGMManagerStateTests.swift
+++ b/LibreLoopTests/LibreLoopCGMManagerStateTests.swift
@@ -1,4 +1,5 @@
import XCTest
+import LoopKit
@testable import LibreLoop
final class LibreLoopCGMManagerStateTests: XCTestCase {
@@ -64,3 +65,224 @@ final class LibreLoopSensorLifecycleTests: XCTestCase {
)
}
}
+
+/// The stuck-glucose detector exists because a Libre 3 field report showed the
+/// current value pinned for the better part of an hour while Loop suspended
+/// insulin on it. Its hard problem is the opposite direction: real glucose sits
+/// flat at 1 mg/dL resolution often enough that a naive run-length trigger is
+/// pure noise. These cover both sides.
+final class StuckGlucoseDetectorTests: XCTestCase {
+ private func frame(_ lifeCount: UInt16,
+ current: UInt16?,
+ historic: UInt16?,
+ lag: UInt16 = 15) -> StuckGlucoseDetector.Frame {
+ StuckGlucoseDetector.Frame(lifeCount: lifeCount,
+ currentWord: current ?? 0x8000,
+ currentMgDL: current,
+ historicMgDL: historic,
+ historicLifeCount: lifeCount &- lag)
+ }
+
+ /// Feeds a run of identical values and returns every report produced.
+ private func reports(_ frames: [StuckGlucoseDetector.Frame]) -> [StuckGlucoseDetector.Report] {
+ var detector = StuckGlucoseDetector()
+ return frames.compactMap { detector.observe($0) }
+ }
+
+ /// The longest genuinely-flat run in the 5-hour capture this was tuned
+ /// against was 10 advancing frames, with the historic series tracking
+ /// normally. Anything at or under that must stay silent.
+ func testFlatGlucoseBelowThresholdIsSilent() {
+ let frames = (0..<11).map { frame(1000 + UInt16($0), current: 165, historic: 157) }
+ XCTAssertTrue(reports(frames).isEmpty)
+ }
+
+ /// A same-minute resend repeats the word without advancing lifeCount, and
+ /// must not accumulate toward a run.
+ func testSameMinuteResendsDoNotAccumulate() {
+ let frames = (0..<40).map { _ in frame(1000, current: 165, historic: 157) }
+ XCTAssertTrue(reports(frames).isEmpty)
+ }
+
+ /// Past the threshold it reports once, then only every fifth frame.
+ func testLongHoldReportsOnceThenEveryFifthFrame() {
+ let frames = (0..<28).map { frame(1000 + UInt16($0), current: 53, historic: 55) }
+ let held: [StuckGlucoseDetector.Held] = reports(frames).compactMap {
+ if case .held(let h) = $0 { return h }
+ return nil
+ }
+ // Run indices 12, 17, 22, 27 → frame counts 13, 18, 23, 28.
+ XCTAssertEqual(held.map(\.frames), [13, 18, 23, 28])
+ }
+
+ /// Dan's shape: the live value is pinned low and the sensor's own historic
+ /// series carries the same low. The records agree, so this is not flagged as
+ /// a latch — which is itself the finding, since it points at the sensor
+ /// rather than at our decode of the realtime frame.
+ func testHoldWithAgreeingHistoricIsNotMarkedDiverged() {
+ let frames = (0..<28).map { frame(2000 + UInt16($0), current: 53, historic: 55) }
+ let held: [StuckGlucoseDetector.Held] = reports(frames).compactMap {
+ if case .held(let h) = $0 { return h }
+ return nil
+ }
+ XCTAssertFalse(held.isEmpty)
+ XCTAssertTrue(held.allSatisfy { !$0.diverged })
+ XCTAssertEqual(held.last?.currentVsHistoric, 2)
+ }
+
+ /// A true latch: the live value sticks while the independently committed
+ /// historic series keeps tracking real glucose down and away from it. Once
+ /// the run outlasts the 15-minute historic lag, the disagreement is real.
+ func testLatchDivergesOnceRunOutlastsHistoricLag() {
+ let frames = (0..<32).map { i -> StuckGlucoseDetector.Frame in
+ // Historic starts level with the pinned value, then falls 3/min.
+ let historic = 120 - 3 * max(0, i - 12)
+ return frame(3000 + UInt16(i), current: 120, historic: UInt16(max(40, historic)))
+ }
+ let held: [StuckGlucoseDetector.Held] = reports(frames).compactMap {
+ if case .held(let h) = $0 { return h }
+ return nil
+ }
+ XCTAssertFalse(held.first?.diverged ?? true, "not yet past the historic lag")
+ XCTAssertTrue(held.last?.diverged ?? false, "records disagree well past the lag")
+ }
+
+ /// The move that ends a hold is diagnostic on its own: flat glucose resumes
+ /// by a point or two, a released hold jumps.
+ func testClearedReportCarriesTheStepThatBrokeTheRun() {
+ var frames = (0..<20).map { frame(4000 + UInt16($0), current: 120, historic: 118) }
+ frames.append(frame(4020, current: 96, historic: 118))
+ guard case .cleared(let count, let step)? = reports(frames).last else {
+ return XCTFail("expected a cleared report")
+ }
+ XCTAssertEqual(count, 20)
+ XCTAssertEqual(step, -24)
+ }
+
+ /// A run of unavailable/error words is a different failure, already surfaced
+ /// through the quality-assessment path and never forwarded to Loop.
+ func testErrorWordsDoNotFormAStuckRun() {
+ let frames = (0..<40).map { frame(5000 + UInt16($0), current: nil, historic: 118) }
+ XCTAssertTrue(reports(frames).isEmpty)
+ }
+}
+
+/// Records the alert identifiers retracted through it. `cgmManagerDelegate` is
+/// weak, so tests must hold this strongly for the duration.
+private nonisolated final class RetractionRecordingDelegate: CGMManagerDelegate {
+ private let lock = NSLock()
+ private var _retracted: [Alert.Identifier] = []
+ var retracted: [Alert.Identifier] {
+ lock.lock()
+ defer { lock.unlock() }
+ return _retracted
+ }
+
+ private let retractionExpectation: XCTestExpectation
+ private let deletionExpectation: XCTestExpectation?
+
+ init(retractionExpectation: XCTestExpectation, deletionExpectation: XCTestExpectation? = nil) {
+ self.retractionExpectation = retractionExpectation
+ self.deletionExpectation = deletionExpectation
+ }
+
+ @MainActor func retractAlert(identifier: Alert.Identifier) async {
+ lock.lock()
+ _retracted.append(identifier)
+ lock.unlock()
+ retractionExpectation.fulfill()
+ }
+
+ func cgmManagerWantsDeletion(_ manager: CGMManager) async {
+ deletionExpectation?.fulfill()
+ }
+
+ // Unused by these tests.
+ @MainActor func issueAlert(_ alert: Alert) async {}
+ func doesIssuedAlertExist(identifier: Alert.Identifier) async throws -> Bool { false }
+ func lookupAllUnretracted(managerIdentifier: String) async throws -> [PersistedAlert] { [] }
+ func lookupAllUnacknowledgedUnretracted(managerIdentifier: String) async throws -> [PersistedAlert] { [] }
+ func recordRetractedAlert(_ alert: Alert, at date: Date) async throws {}
+ func deviceManager(_ manager: DeviceManager, logEventForDeviceIdentifier deviceIdentifier: String?, type: DeviceLogEntryType, message: String, completion: ((Error?) -> Void)?) {}
+ func cgmManager(_ manager: CGMManager, hasNew readingResult: CGMReadingResult) {}
+ func cgmManager(_ manager: CGMManager, hasNew events: [PersistedCgmEvent]) {}
+ func cgmManagerDidUpdateState(_ manager: CGMManager) {}
+ func cgmManager(_ manager: CGMManager, didUpdate status: CGMManagerStatus) {}
+ func startDateToFilterNewData(for manager: CGMManager) -> Date? { nil }
+ func credentialStoragePrefix(for manager: CGMManager) -> String { "test" }
+}
+
+/// Alerts outlive the manager: Loop's AlertStore keeps them for the whole
+/// local-cache window, and launch-time playback rebuilds a past-due `.delayed`
+/// alert as `.immediate` and presents it again. An alert left standing when the
+/// sensor or the CGM goes away therefore re-fires on every app launch — which is
+/// what a user hit, getting sensor-expiry alerts for a week after switching to a
+/// different CGM. These pin down the retraction on both exit paths.
+final class LibreLoopAlertRetractionTests: XCTestCase {
+ private func makeManager(delegate: CGMManagerDelegate) -> LibreLoopCGMManager {
+ let manager = LibreLoopCGMManager()
+ manager.delegateQueue = DispatchQueue(label: "LibreLoopAlertRetractionTests")
+ manager.cgmManagerDelegate = delegate
+ return manager
+ }
+
+ private func expectRetractions() -> XCTestExpectation {
+ let expectation = expectation(description: "every alert identifier retracted")
+ expectation.expectedFulfillmentCount = LibreLoopCGMManager.allAlertIdentifiers.count
+ return expectation
+ }
+
+ private func assertRetractedEverything(_ delegate: RetractionRecordingDelegate) {
+ XCTAssertEqual(Set(delegate.retracted.map(\.alertIdentifier)),
+ Set(LibreLoopCGMManager.allAlertIdentifiers))
+ XCTAssertTrue(delegate.retracted.allSatisfy {
+ $0.managerIdentifier == LibreLoopCGMManager.pluginIdentifier
+ })
+ }
+
+ /// Deleting the CGM must clear every alert. It must also still notify the
+ /// delegate — overriding `delete` without re-issuing that notification
+ /// leaves the manager attached to Loop.
+ func testDeleteRetractsEveryAlertAndNotifiesDelegate() {
+ let retractions = expectRetractions()
+ let deletion = expectation(description: "delegate notified of deletion")
+ let completed = expectation(description: "delete completion called")
+ let delegate = RetractionRecordingDelegate(retractionExpectation: retractions,
+ deletionExpectation: deletion)
+ let manager = makeManager(delegate: delegate)
+
+ manager.delete { completed.fulfill() }
+
+ wait(for: [retractions, deletion, completed], timeout: 5)
+ assertRetractedEverything(delegate)
+ }
+
+ /// Discarding the sensor leaves the CGM configured, but every standing alert
+ /// belonged to the sensor that just went away.
+ func testDiscardSensorRetractsEveryAlert() {
+ let retractions = expectRetractions()
+ let delegate = RetractionRecordingDelegate(retractionExpectation: retractions)
+ let manager = makeManager(delegate: delegate)
+ manager.hasIssuedReScanAlert = true
+
+ manager.discardSensor()
+
+ wait(for: [retractions], timeout: 5)
+ assertRetractedEverything(delegate)
+ XCTAssertFalse(manager.hasIssuedReScanAlert)
+ XCTAssertNil(manager.lastSensorAttention)
+ }
+
+ /// The retraction is only as complete as this list. Anything issuable and
+ /// missing from it silently re-fires forever.
+ func testAllAlertIdentifiersCoversEveryIssuableAlert() {
+ let identifiers = Set(LibreLoopCGMManager.allAlertIdentifiers)
+ for expiryIdentifier in LibreLoopExpiryAlerts.allIdentifiers {
+ XCTAssertTrue(identifiers.contains(expiryIdentifier), "missing \(expiryIdentifier)")
+ }
+ XCTAssertTrue(identifiers.contains(LibreLoopCGMManager.sensorAttentionAlertID))
+ XCTAssertTrue(identifiers.contains(LibreLoopCGMManager.needsReScanAlertID))
+ XCTAssertEqual(identifiers.count, LibreLoopCGMManager.allAlertIdentifiers.count,
+ "duplicate identifiers in allAlertIdentifiers")
+ }
+}
diff --git a/LibreLoopUI/LibreLoopCGMManager/LibreLoopUICoordinator.swift b/LibreLoopUI/LibreLoopCGMManager/LibreLoopUICoordinator.swift
index d035265..403f7f3 100644
--- a/LibreLoopUI/LibreLoopCGMManager/LibreLoopUICoordinator.swift
+++ b/LibreLoopUI/LibreLoopCGMManager/LibreLoopUICoordinator.swift
@@ -152,8 +152,14 @@ final class LibreLoopUICoordinator: UINavigationController, CGMManagerOnboarding
self.completionDelegate?.completionNotifyingDidComplete(self)
},
replaceSensor: { [weak self] in self?.startReplacementPairing() },
+ // `delete` — not `notifyDelegateOfDeletion` — so the manager's own
+ // teardown runs: dropping the shared central's listener/link, and
+ // retracting standing alerts. Loop's AlertStore replays anything
+ // left behind on every launch, so skipping this leaves a deleted
+ // sensor's expiry reminder firing for weeks. `delete` notifies the
+ // delegate itself.
deleteCGM: { [weak self] in
- self?.cgmManager?.notifyDelegateOfDeletion {
+ self?.cgmManager?.delete {
DispatchQueue.main.async {
guard let self = self else { return }
self.completionDelegate?.completionNotifyingDidComplete(self)