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)