77//
88
99import 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)
1214public 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
0 commit comments