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..f16b4f6 100644 --- a/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift +++ b/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift @@ -9,7 +9,7 @@ import UIKit public final class LibreLoopCGMManager: CGMManager { public static let pluginIdentifier = "LibreLoopCGMManager" - public static let localizedTitle = "FreeStyle Libre 3" + public static let localizedTitle = "FreeStyle Libre 3 / 3+" public static let healthKitStorageDelay: TimeInterval = 0 public var localizedTitle: String { Self.localizedTitle } @@ -251,6 +251,12 @@ 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 + + /// Anything left standing in Loop's AlertStore replays on every app launch, + /// so this must list every alert we can issue. + 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 +338,9 @@ public final class LibreLoopCGMManager: CGMManager { monitor = nil isReconnecting = false recentSamples = [] - retractExpiryAlerts() + 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 +817,10 @@ public final class LibreLoopCGMManager: CGMManager { scanner.cancelConnection(peripheral) } } - completion() + // Retract while the delegate reference is still good: notifying it + // releases this manager. + retractAllAlerts() + notifyDelegateOfDeletion(completion: completion) } } diff --git a/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift b/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift index d6b16a0..cd679fc 100644 --- a/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift +++ b/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift @@ -47,12 +47,14 @@ extension LibreLoopCGMManager { return activatedAt } - func retractExpiryAlerts() { + /// Captures the delegate strongly 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..dd0a9fa 100644 --- a/LibreLoop/Sensor/LibreLoopSensorMonitor.swift +++ b/LibreLoop/Sensor/LibreLoopSensorMonitor.swift @@ -78,13 +78,6 @@ 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 private var readingHandler: ReadingHandler? private var disconnectHandler: DisconnectHandler? private var statusHandler: StatusHandler? @@ -521,25 +514,10 @@ public final class LibreLoopSensorMonitor: @unchecked Sendable { // to warmup when applicable (and report remaining warmup minutes). let lifecycle = SensorLifecycle(currentLifeCountMinutes: Int(reading.lifeCount)) let assessment = reading.currentGlucoseQualityAssessment(lifecycle: lifecycle) - // Log trendAndStatusByte (byte 14 of the realtime frame) - // alongside the decoded fields. LibreCRKit's live-capture - // fixture shows 0x0b for a stable+actionable reading on - // Libre 3 (trend=3 | bit3 actionable | rest=0). If we - // see byte 14 with bit 3 clear but other upper bits set, - // it would suggest a sensor variant has the flag in a - // different position than the test data assumed. - // - // Also log the full 29-byte decrypted plaintext as hex -- - // dropped straight into RealtimeGlucoseReading(plaintext:) - // it reproduces the exact frame for the LibreCRKit - // developer to inspect. + // The plaintext hex replays the exact frame through + // RealtimeGlucoseReading(plaintext:) when reviewing a report. let byte14 = String(format: "0x%02x", reading.trendAndStatusByte) let plaintextHex = packet.plaintext.map { String(format: "%02x", $0) }.joined() - // Surface the decoded data-quality evidence on every frame: the - // raw current-glucose word (the field that froze in the stuck-53 - // case), the DQ error (0x8000 family), sensor condition, and - // actionability. Previously these were only visible when they - // escalated to a blocking issue — but a held value reports clean. let mgdlStr = reading.currentGlucoseMgDL.map(String.init) ?? "nil" let word = String(format: "0x%04x", reading.currentWord) let dqInfo = "word=\(word) dq=\(reading.dqError) cond=\(reading.sensorCondition) act=\(reading.actionability)" @@ -555,24 +533,7 @@ 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 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))") - } lcHandler?(reading.lifeCount) if let sample = Self.makeSample(from: reading, assessment: assessment, receivedAt: event.receivedAt) { lock.lock() diff --git a/LibreLoopPlugin/Info.plist b/LibreLoopPlugin/Info.plist index af9b654..6e1983d 100644 --- a/LibreLoopPlugin/Info.plist +++ b/LibreLoopPlugin/Info.plist @@ -23,7 +23,7 @@ NSPrincipalClass LibreLoopPlugin com.loopkit.Loop.CGMManagerDisplayName - FreeStyle Libre 3 + FreeStyle Libre 3 / 3+ com.loopkit.Loop.CGMManagerIdentifier LibreLoopCGMManager diff --git a/LibreLoopTests/LibreLoopCGMManagerStateTests.swift b/LibreLoopTests/LibreLoopCGMManagerStateTests.swift index 0b6c6c6..56521ee 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,110 @@ final class LibreLoopSensorLifecycleTests: XCTestCase { ) } } + +/// `cgmManagerDelegate` is weak, so tests must hold this strongly. +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() + } + + @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" } +} + +/// An alert left standing in Loop's AlertStore is replayed on every app launch, +/// so both exit paths must clear them. +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 + }) + } + + 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) + } + + 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) + } + + 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..a4b5f5c 100644 --- a/LibreLoopUI/LibreLoopCGMManager/LibreLoopUICoordinator.swift +++ b/LibreLoopUI/LibreLoopCGMManager/LibreLoopUICoordinator.swift @@ -152,8 +152,10 @@ final class LibreLoopUICoordinator: UINavigationController, CGMManagerOnboarding self.completionDelegate?.completionNotifyingDidComplete(self) }, replaceSensor: { [weak self] in self?.startReplacementPairing() }, + // `delete` runs the manager's teardown and notifies the delegate + // itself; `notifyDelegateOfDeletion` alone skips the teardown. deleteCGM: { [weak self] in - self?.cgmManager?.notifyDelegateOfDeletion { + self?.cgmManager?.delete { DispatchQueue.main.async { guard let self = self else { return } self.completionDelegate?.completionNotifyingDidComplete(self)