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)