Skip to content

Commit e42c038

Browse files
authored
LOOP-4093 Log loop failure alerts (#507)
* Log loop not looping alerts * Track isCritical flag * Have LoopAlerts use AlertManager instead of AlertStore directly, for recording alerts * Add tests for LoopAlertsManager
1 parent 1f57fad commit e42c038

File tree

8 files changed

+256
-64
lines changed

8 files changed

+256
-64
lines changed

Loop.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,7 @@
471471
C13BAD941E8009B000050CB5 /* NumberFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43BFF0B31E45C1BE00FF19A9 /* NumberFormatter.swift */; };
472472
C13DA2B024F6C7690098BB29 /* UIViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13DA2AF24F6C7690098BB29 /* UIViewController.swift */; };
473473
C148CEE724FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C148CEE624FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift */; };
474+
C1549B782837DF4E002B190C /* LoopAlertManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1549B772837DF4E002B190C /* LoopAlertManagerTests.swift */; };
474475
C165756F2534C468004AE16E /* SimpleBolusViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C165756E2534C468004AE16E /* SimpleBolusViewModelTests.swift */; };
475476
C16575712538A36B004AE16E /* CGMStalenessMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16575702538A36B004AE16E /* CGMStalenessMonitor.swift */; };
476477
C16575732538AFF6004AE16E /* CGMStalenessMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16575722538AFF6004AE16E /* CGMStalenessMonitorTests.swift */; };
@@ -507,6 +508,7 @@
507508
C1DE5D23251BFC4D00439E49 /* SimpleBolusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1DE5D22251BFC4D00439E49 /* SimpleBolusView.swift */; };
508509
C1E2773E224177C000354103 /* ClockKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C1E2773D224177C000354103 /* ClockKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; };
509510
C1E2774822433D7A00354103 /* MKRingProgressView.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C1E2774722433D7A00354103 /* MKRingProgressView.framework */; };
511+
C1E3862628247C6100F561A4 /* StoredLoopNotRunningNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1E3862428247B7100F561A4 /* StoredLoopNotRunningNotification.swift */; };
510512
C1F2075C26D6F9B0007AB7EB /* ProfileExpirationAlerter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F2075B26D6F9B0007AB7EB /* ProfileExpirationAlerter.swift */; };
511513
C1F8B243223E73FD00DD66CF /* BolusProgressTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F8B1D122375E4200DD66CF /* BolusProgressTableViewCell.swift */; };
512514
C1FB428C217806A400FAB378 /* StateColorPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FB428B217806A300FAB378 /* StateColorPalette.swift */; };
@@ -1378,6 +1380,7 @@
13781380
C12F21A61DFA79CB00748193 /* recommend_temp_basal_very_low_end_in_range.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = recommend_temp_basal_very_low_end_in_range.json; sourceTree = "<group>"; };
13791381
C13DA2AF24F6C7690098BB29 /* UIViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIViewController.swift; sourceTree = "<group>"; };
13801382
C148CEE624FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeliveryUncertaintyAlertManager.swift; sourceTree = "<group>"; };
1383+
C1549B772837DF4E002B190C /* LoopAlertManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopAlertManagerTests.swift; sourceTree = "<group>"; };
13811384
C165756E2534C468004AE16E /* SimpleBolusViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleBolusViewModelTests.swift; sourceTree = "<group>"; };
13821385
C16575702538A36B004AE16E /* CGMStalenessMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMStalenessMonitor.swift; sourceTree = "<group>"; };
13831386
C16575722538AFF6004AE16E /* CGMStalenessMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMStalenessMonitorTests.swift; sourceTree = "<group>"; };
@@ -1414,6 +1417,7 @@
14141417
C1DE5D22251BFC4D00439E49 /* SimpleBolusView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SimpleBolusView.swift; sourceTree = "<group>"; };
14151418
C1E2773D224177C000354103 /* ClockKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ClockKit.framework; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS.sdk/System/Library/Frameworks/ClockKit.framework; sourceTree = DEVELOPER_DIR; };
14161419
C1E2774722433D7A00354103 /* MKRingProgressView.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MKRingProgressView.framework; sourceTree = BUILT_PRODUCTS_DIR; };
1420+
C1E3862428247B7100F561A4 /* StoredLoopNotRunningNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredLoopNotRunningNotification.swift; sourceTree = "<group>"; };
14171421
C1F2075B26D6F9B0007AB7EB /* ProfileExpirationAlerter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileExpirationAlerter.swift; sourceTree = "<group>"; };
14181422
C1F8B1D122375E4200DD66CF /* BolusProgressTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusProgressTableViewCell.swift; sourceTree = "<group>"; };
14191423
C1F8B1DB223862D500DD66CF /* BolusProgressTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BolusProgressTableViewCell.xib; sourceTree = "<group>"; };
@@ -1642,6 +1646,7 @@
16421646
1DA7A84324477698008257F0 /* InAppModalAlertIssuerTests.swift */,
16431647
1DFE9E162447B6270082C280 /* UserNotificationAlertIssuerTests.swift */,
16441648
A91E4C2024F867A700BE9213 /* StoredAlertTests.swift */,
1649+
C1549B772837DF4E002B190C /* LoopAlertManagerTests.swift */,
16451650
);
16461651
path = Alerts;
16471652
sourceTree = "<group>";
@@ -1723,6 +1728,7 @@
17231728
E9C00EF424C623EF00628F35 /* LoopSettings+Loop.swift */,
17241729
A987CD4824A58A0100439ADC /* ZipArchive.swift */,
17251730
C19008FD25225D3900721625 /* SimpleBolusCalculator.swift */,
1731+
C1E3862428247B7100F561A4 /* StoredLoopNotRunningNotification.swift */,
17261732
C1C660D0252E4DD5009B5C32 /* LoopConstants.swift */,
17271733
);
17281734
path = Models;
@@ -3561,6 +3567,7 @@
35613567
43511CE221FD80E400566C63 /* RetrospectiveCorrection.swift in Sources */,
35623568
1D05219B2469E9DF000EBBDE /* StoredAlert.swift in Sources */,
35633569
E9B0802B253BBDFF00BAD8F8 /* IntentExtensionInfo.swift in Sources */,
3570+
C1E3862628247C6100F561A4 /* StoredLoopNotRunningNotification.swift in Sources */,
35643571
A97F250825E056D500F0EE19 /* OnboardingManager.swift in Sources */,
35653572
438D42F91D7C88BC003244B0 /* PredictionInputEffect.swift in Sources */,
35663573
A9C62D8223316FF600535612 /* UserDefaults+Services.swift in Sources */,
@@ -3796,6 +3803,7 @@
37963803
A91E4C2324F86F1000BE9213 /* CriticalEventLogExportManagerTests.swift in Sources */,
37973804
E9C58A7324DB4A2700487A17 /* LoopDataManagerTests.swift in Sources */,
37983805
E98A55F324EDD9530008715D /* MockSettingsStore.swift in Sources */,
3806+
C1549B782837DF4E002B190C /* LoopAlertManagerTests.swift in Sources */,
37993807
C165756F2534C468004AE16E /* SimpleBolusViewModelTests.swift in Sources */,
38003808
A9DAE7D02332D77F006AE942 /* LoopTests.swift in Sources */,
38013809
B4FACBB12541FAB700199981 /* LoopSettingsAlerterTests.swift in Sources */,

Loop/Extensions/UserDefaults+Loop.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ extension UserDefaults {
1313
private enum Key: String {
1414
case pumpManagerState = "com.loopkit.Loop.PumpManagerState"
1515
case cgmManagerState = "com.loopkit.Loop.CGMManagerState"
16+
case loopNotRunningNotifications = "com.loopkit.Loop.loopNotRunningNotifications"
1617
}
1718

1819
var pumpManagerRawValue: [String: Any]? {
@@ -45,4 +46,23 @@ extension UserDefaults {
4546
cgmManagerState = newValue?.rawValue
4647
}
4748
}
49+
50+
var loopNotRunningNotifications: [StoredLoopNotRunningNotification] {
51+
get {
52+
let decoder = JSONDecoder()
53+
guard let data = object(forKey: Key.loopNotRunningNotifications.rawValue) as? Data else {
54+
return []
55+
}
56+
return (try? decoder.decode([StoredLoopNotRunningNotification].self, from: data)) ?? []
57+
}
58+
set {
59+
do {
60+
let encoder = JSONEncoder()
61+
let data = try encoder.encode(newValue)
62+
set(data, forKey: Key.loopNotRunningNotifications.rawValue)
63+
} catch {
64+
assertionFailure("Unable to encode Loop not running notification")
65+
}
66+
}
67+
}
4868
}

Loop/Managers/Alerts/AlertManager.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,10 @@ extension AlertManager: PersistedAlertStore {
317317
public func recordRetractedAlert(_ alert: Alert, at date: Date) {
318318
alertStore.recordRetractedAlert(alert, at: date)
319319
}
320+
321+
public func recordIssued(alert: Alert, at date: Date = Date(), completion: ((Result<Void, Error>) -> Void)? = nil) {
322+
alertStore.recordIssued(alert: alert, at: date, completion: completion)
323+
}
320324
}
321325

322326
// MARK: Extensions

Loop/Managers/LoopAlertsManager.swift

Lines changed: 109 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
//
88

99
import LoopKit
10+
import Combine
11+
import UserNotifications
1012

1113
/// Class responsible for monitoring "system level" operations and alerting the user to any anomalous situations (e.g. bluetooth off)
1214
public class LoopAlertsManager {
@@ -15,13 +17,24 @@ public class LoopAlertsManager {
1517

1618
private lazy var log = DiagnosticLog(category: String(describing: LoopAlertsManager.self))
1719

18-
private weak var alertManager: AlertManager?
20+
private var alertManager: AlertManager
1921

2022
private let bluetoothPoweredOffIdentifier = Alert.Identifier(managerIdentifier: managerIdentifier, alertIdentifier: "bluetoothPoweredOff")
23+
24+
lazy private var cancellables = Set<AnyCancellable>()
25+
26+
// For testing
27+
var getCurrentDate = { return Date() }
2128

2229
init(alertManager: AlertManager, bluetoothProvider: BluetoothProvider) {
2330
self.alertManager = alertManager
2431
bluetoothProvider.addBluetoothObserver(self, queue: .main)
32+
33+
NotificationCenter.default.publisher(for: .LoopCompleted)
34+
.sink { [weak self] _ in
35+
self?.loopDidComplete()
36+
}
37+
.store(in: &cancellables)
2538
}
2639

2740
private func onBluetoothPermissionDenied() {
@@ -31,12 +44,12 @@ public class LoopAlertsManager {
3144
let content = Alert.Content(title: title,
3245
body: body,
3346
acknowledgeActionButtonLabel: NSLocalizedString("Dismiss", comment: "Default alert dismissal"))
34-
alertManager?.issueAlert(Alert(identifier: bluetoothPoweredOffIdentifier, foregroundContent: content, backgroundContent: content, trigger: .immediate))
47+
alertManager.issueAlert(Alert(identifier: bluetoothPoweredOffIdentifier, foregroundContent: content, backgroundContent: content, trigger: .immediate))
3548
}
3649

3750
private func onBluetoothPoweredOn() {
3851
log.default("Bluetooth powered on")
39-
alertManager?.retractAlert(identifier: bluetoothPoweredOffIdentifier)
52+
alertManager.retractAlert(identifier: bluetoothPoweredOffIdentifier)
4053
}
4154

4255
private func onBluetoothPoweredOff() {
@@ -50,13 +63,105 @@ public class LoopAlertsManager {
5063
let fgcontent = Alert.Content(title: title,
5164
body: fgBody,
5265
acknowledgeActionButtonLabel: NSLocalizedString("Dismiss", comment: "Default alert dismissal"))
53-
alertManager?.issueAlert(Alert(identifier: bluetoothPoweredOffIdentifier,
66+
alertManager.issueAlert(Alert(identifier: bluetoothPoweredOffIdentifier,
5467
foregroundContent: fgcontent,
5568
backgroundContent: bgcontent,
5669
trigger: .immediate,
5770
interruptionLevel: .critical))
5871
}
5972

73+
func loopDidComplete() {
74+
clearLoopNotRunningNotifications()
75+
scheduleLoopNotRunningNotifications()
76+
}
77+
78+
func scheduleLoopNotRunningNotifications() {
79+
// Give a little extra time for a loop-in-progress to complete
80+
let gracePeriod = TimeInterval(minutes: 0.5)
81+
82+
var scheduledNotifications: [StoredLoopNotRunningNotification] = []
83+
84+
for (minutes, isCritical) in [(20.0, false), (40.0, false), (60.0, true), (120.0, true)] {
85+
let notification = UNMutableNotificationContent()
86+
let failureInterval = TimeInterval(minutes: minutes)
87+
88+
let formatter = DateComponentsFormatter()
89+
formatter.maximumUnitCount = 1
90+
formatter.allowedUnits = [.hour, .minute]
91+
formatter.unitsStyle = .full
92+
93+
if let failureIntervalString = formatter.string(from: failureInterval)?.localizedLowercase {
94+
notification.body = String(format: NSLocalizedString("Loop has not completed successfully in %@", comment: "The notification alert describing a long-lasting loop failure. The substitution parameter is the time interval since the last loop"), failureIntervalString)
95+
}
96+
97+
notification.title = NSLocalizedString("Loop Failure", comment: "The notification title for a loop failure")
98+
if isCritical, FeatureFlags.criticalAlertsEnabled {
99+
if #available(iOS 15.0, *) {
100+
notification.interruptionLevel = .critical
101+
}
102+
notification.sound = .defaultCritical
103+
} else {
104+
if #available(iOS 15.0, *) {
105+
notification.interruptionLevel = .timeSensitive
106+
}
107+
notification.sound = .default
108+
}
109+
notification.categoryIdentifier = LoopNotificationCategory.loopNotRunning.rawValue
110+
notification.threadIdentifier = LoopNotificationCategory.loopNotRunning.rawValue
111+
112+
let trigger = UNTimeIntervalNotificationTrigger(
113+
timeInterval: failureInterval + gracePeriod,
114+
repeats: false
115+
)
116+
117+
let request = UNNotificationRequest(
118+
identifier: "\(LoopNotificationCategory.loopNotRunning.rawValue)\(failureInterval)",
119+
content: notification,
120+
trigger: trigger
121+
)
122+
123+
if let nextTriggerDate = trigger.nextTriggerDate() {
124+
let scheduledNotification = StoredLoopNotRunningNotification(
125+
alertAt: nextTriggerDate,
126+
title: notification.title,
127+
body: notification.body,
128+
timeInterval: failureInterval,
129+
isCritical: isCritical)
130+
scheduledNotifications.append(scheduledNotification)
131+
}
132+
UNUserNotificationCenter.current().add(request)
133+
}
134+
UserDefaults.appGroup?.loopNotRunningNotifications = scheduledNotifications
135+
}
136+
137+
func clearLoopNotRunningNotifications() {
138+
139+
// Any past alerts have been delivered at this point
140+
let now = getCurrentDate()
141+
for notification in UserDefaults.appGroup?.loopNotRunningNotifications ?? [] {
142+
print("Comparing alert \(notification.alertAt) to \(now) (real date = \(Date())")
143+
if notification.alertAt < now {
144+
let alertIdentifier = Alert.Identifier(managerIdentifier: "Loop", alertIdentifier: "loopNotLooping")
145+
let content = Alert.Content(title: notification.title, body: notification.body, acknowledgeActionButtonLabel: "ios-notification-default")
146+
let interruptionLevel: Alert.InterruptionLevel = notification.isCritical ? .critical : .timeSensitive
147+
let alert = Alert(identifier: alertIdentifier, foregroundContent: nil, backgroundContent: content, trigger: .immediate, interruptionLevel: interruptionLevel)
148+
alertManager.recordIssued(alert: alert, at: notification.alertAt)
149+
}
150+
}
151+
152+
UserDefaults.appGroup?.loopNotRunningNotifications = []
153+
154+
// Clear out any existing not-running notifications
155+
UNUserNotificationCenter.current().getDeliveredNotifications { (notifications) in
156+
let loopNotRunningIdentifiers = notifications.filter({
157+
$0.request.content.categoryIdentifier == LoopNotificationCategory.loopNotRunning.rawValue
158+
}).map({
159+
$0.request.identifier
160+
})
161+
162+
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: loopNotRunningIdentifiers)
163+
}
164+
}
60165
}
61166

62167
// MARK: - BluetoothObserver

Loop/Managers/LoopDataManager.swift

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -368,8 +368,6 @@ final class LoopDataManager: LoopSettingsAlerterDelegate {
368368

369369
private func loopDidComplete(date: Date, dosingDecision: StoredDosingDecision, duration: TimeInterval) {
370370
lastLoopCompleted = date
371-
NotificationManager.clearLoopNotRunningNotifications()
372-
NotificationManager.scheduleLoopNotRunningNotifications()
373371
analyticsServicesManager.loopDidSucceed(duration)
374372
dosingDecisionStore.storeDosingDecision(dosingDecision) {}
375373

Loop/Managers/NotificationManager.swift

Lines changed: 0 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -120,62 +120,4 @@ extension NotificationManager {
120120

121121
UNUserNotificationCenter.current().add(request)
122122
}
123-
124-
static func scheduleLoopNotRunningNotifications() {
125-
// Give a little extra time for a loop-in-progress to complete
126-
let gracePeriod = TimeInterval(minutes: 0.5)
127-
128-
for (minutes, isCritical) in [(20.0, false), (40.0, false), (60.0, true), (120.0, true)] {
129-
let notification = UNMutableNotificationContent()
130-
let failureInterval = TimeInterval(minutes: minutes)
131-
132-
let formatter = DateComponentsFormatter()
133-
formatter.maximumUnitCount = 1
134-
formatter.allowedUnits = [.hour, .minute]
135-
formatter.unitsStyle = .full
136-
137-
if let failureIntervalString = formatter.string(from: failureInterval)?.localizedLowercase {
138-
notification.body = String(format: NSLocalizedString("Loop has not completed successfully in %@", comment: "The notification alert describing a long-lasting loop failure. The substitution parameter is the time interval since the last loop"), failureIntervalString)
139-
}
140-
141-
notification.title = NSLocalizedString("Loop Failure", comment: "The notification title for a loop failure")
142-
if isCritical, FeatureFlags.criticalAlertsEnabled {
143-
if #available(iOS 15.0, *) {
144-
notification.interruptionLevel = .critical
145-
}
146-
notification.sound = .defaultCritical
147-
} else {
148-
if #available(iOS 15.0, *) {
149-
notification.interruptionLevel = .timeSensitive
150-
}
151-
notification.sound = .default
152-
}
153-
notification.categoryIdentifier = LoopNotificationCategory.loopNotRunning.rawValue
154-
notification.threadIdentifier = LoopNotificationCategory.loopNotRunning.rawValue
155-
156-
let request = UNNotificationRequest(
157-
identifier: "\(LoopNotificationCategory.loopNotRunning.rawValue)\(failureInterval)",
158-
content: notification,
159-
trigger: UNTimeIntervalNotificationTrigger(
160-
timeInterval: failureInterval + gracePeriod,
161-
repeats: false
162-
)
163-
)
164-
165-
UNUserNotificationCenter.current().add(request)
166-
}
167-
}
168-
169-
static func clearLoopNotRunningNotifications() {
170-
// Clear out any existing not-running notifications
171-
UNUserNotificationCenter.current().getDeliveredNotifications { (notifications) in
172-
let loopNotRunningIdentifiers = notifications.filter({
173-
$0.request.content.categoryIdentifier == LoopNotificationCategory.loopNotRunning.rawValue
174-
}).map({
175-
$0.request.identifier
176-
})
177-
178-
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: loopNotRunningIdentifiers)
179-
}
180-
}
181123
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
//
2+
// StoredLoopNotRunningNotification.swift
3+
// LoopCore
4+
//
5+
// Created by Pete Schwamb on 5/5/22.
6+
// Copyright © 2022 LoopKit Authors. All rights reserved.
7+
//
8+
9+
import Foundation
10+
11+
struct StoredLoopNotRunningNotification: Codable {
12+
var alertAt: Date
13+
var title: String
14+
var body: String
15+
var timeInterval: TimeInterval
16+
var isCritical: Bool
17+
}
18+

0 commit comments

Comments
 (0)