Skip to content

Commit 8a47b0e

Browse files
authored
LOOP-1167: play back delivered and pending alerts on re-launch (#77)
* checkpoint * checkpoint: seems to be working! * Unit tests
1 parent 7b0a542 commit 8a47b0e

File tree

4 files changed

+391
-126
lines changed

4 files changed

+391
-126
lines changed

Loop/Managers/DeviceAlert/DeviceAlertManager.swift

Lines changed: 187 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,19 @@ protocol DeviceAlertManagerResponder: class {
1313
func acknowledgeDeviceAlert(identifier: DeviceAlert.Identifier)
1414
}
1515

16+
public protocol UserNotificationCenter {
17+
func add(_ request: UNNotificationRequest, withCompletionHandler: ((Error?) -> Void)?)
18+
func removePendingNotificationRequests(withIdentifiers: [String])
19+
func removeDeliveredNotifications(withIdentifiers: [String])
20+
func getDeliveredNotifications(completionHandler: @escaping ([UNNotification]) -> Void)
21+
func getPendingNotificationRequests(completionHandler: @escaping ([UNNotificationRequest]) -> Void)
22+
}
23+
extension UNUserNotificationCenter: UserNotificationCenter {}
24+
25+
public enum DeviceAlertUserNotificationUserInfoKey: String {
26+
case deviceAlert, deviceAlertTimestamp
27+
}
28+
1629
/// Main (singleton-ish) class that is responsible for:
1730
/// - managing the different targets (handlers) that will post alerts
1831
/// - managing the different responders that might acknowledge the alert
@@ -21,17 +34,28 @@ protocol DeviceAlertManagerResponder: class {
2134
public final class DeviceAlertManager {
2235
static let soundsDirectoryURL = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).last!.appendingPathComponent("Sounds")
2336

37+
static let timestampFormatter = ISO8601DateFormatter()
38+
2439
private let log = DiagnosticLog(category: "DeviceAlertManager")
2540

2641
var handlers: [DeviceAlertPresenter] = []
2742
var responders: [String: Weak<DeviceAlertResponder>] = [:]
2843
var soundVendors: [String: Weak<DeviceAlertSoundVendor>] = [:]
44+
45+
let userNotificationCenter: UserNotificationCenter
46+
let fileManager: FileManager
2947

3048
public init(rootViewController: UIViewController,
31-
handlers: [DeviceAlertPresenter]? = nil) {
49+
handlers: [DeviceAlertPresenter]? = nil,
50+
userNotificationCenter: UserNotificationCenter = UNUserNotificationCenter.current(),
51+
fileManager: FileManager = FileManager.default) {
52+
self.userNotificationCenter = userNotificationCenter
53+
self.fileManager = fileManager
3254
self.handlers = handlers ??
33-
[UserNotificationDeviceAlertPresenter(),
55+
[UserNotificationDeviceAlertPresenter(userNotificationCenter: userNotificationCenter),
3456
InAppModalDeviceAlertPresenter(rootViewController: rootViewController, deviceAlertManagerResponder: self)]
57+
58+
playbackPersistedAlerts()
3559
}
3660

3761
public func addAlertResponder(managerIdentifier: String, alertResponder: DeviceAlertResponder) {
@@ -57,6 +81,10 @@ extension DeviceAlertManager: DeviceAlertManagerResponder {
5781
if let responder = responders[identifier.managerIdentifier]?.value {
5882
responder.acknowledgeAlert(alertIdentifier: identifier.alertIdentifier)
5983
}
84+
// Also clear the alert from the NotificationCenter
85+
log.debug("Removing notification %@ from delivered & pending notifications", identifier.value)
86+
userNotificationCenter.removeDeliveredNotifications(withIdentifiers: [identifier.value])
87+
userNotificationCenter.removePendingNotificationRequests(withIdentifiers: [identifier.value])
6088
}
6189
}
6290

@@ -93,8 +121,7 @@ extension DeviceAlertManager {
93121
return
94122
}
95123
do {
96-
let fileManager = FileManager.default
97-
try fileManager.createDirectory(atPath: DeviceAlertManager.soundsDirectoryURL.path, withIntermediateDirectories: true, attributes: nil)
124+
try fileManager.createDirectory(at: DeviceAlertManager.soundsDirectoryURL, withIntermediateDirectories: true, attributes: nil)
98125
for sound in sounds {
99126
if let fromFilename = sound.filename,
100127
let toURL = DeviceAlertManager.soundURL(managerIdentifier: managerIdentifier, sound: sound) {
@@ -112,8 +139,8 @@ extension FileManager {
112139
func copyIfNewer(from fromURL: URL, to toURL: URL) throws {
113140
if fileExists(atPath: toURL.path) {
114141
// If the source file is newer, remove the old one, otherwise skip it.
115-
let toCreationDate = try toURL.fileCreationDate()
116-
let fromCreationDate = try fromURL.fileCreationDate()
142+
let toCreationDate = try toURL.fileCreationDate(self)
143+
let fromCreationDate = try fromURL.fileCreationDate(self)
117144
if fromCreationDate > toCreationDate {
118145
try removeItem(at: toURL)
119146
} else {
@@ -126,7 +153,159 @@ extension FileManager {
126153

127154
extension URL {
128155

129-
func fileCreationDate() throws -> Date {
130-
return try FileManager.default.attributesOfItem(atPath: self.path)[.creationDate] as! Date
156+
func fileCreationDate(_ fileManager: FileManager) throws -> Date {
157+
return try fileManager.attributesOfItem(atPath: self.path)[.creationDate] as! Date
158+
}
159+
}
160+
161+
162+
extension DeviceAlertManager {
163+
164+
private func playbackPersistedAlerts() {
165+
166+
userNotificationCenter.getDeliveredNotifications {
167+
$0.forEach { notification in
168+
self.log.debug("Delivered alert: %@", "\(notification)")
169+
self.playbackDeliveredNotification(notification)
170+
}
171+
}
172+
173+
userNotificationCenter.getPendingNotificationRequests {
174+
$0.forEach { request in
175+
self.log.debug("Pending alert: %@", "\(request)")
176+
self.playbackPendingNotificationRequest(request)
177+
}
178+
}
179+
}
180+
181+
private func playbackDeliveredNotification(_ notification: UNNotification) {
182+
// Assume if it was delivered, the trigger should be .immediate.
183+
playbackAnyNotificationRequest(notification.request, usingTrigger: .immediate)
184+
}
185+
186+
private func playbackPendingNotificationRequest(_ request: UNNotificationRequest) {
187+
playbackAnyNotificationRequest(request)
188+
}
189+
190+
private func playbackAnyNotificationRequest(_ request: UNNotificationRequest, usingTrigger trigger: DeviceAlert.Trigger? = nil) {
191+
guard let savedAlertString = request.content.userInfo[DeviceAlertUserNotificationUserInfoKey.deviceAlert.rawValue] as? String,
192+
let savedAlertTimestampString = request.content.userInfo[DeviceAlertUserNotificationUserInfoKey.deviceAlertTimestamp.rawValue] as? String,
193+
let savedAlertTimestamp = DeviceAlertManager.timestampFormatter.date(from: savedAlertTimestampString) else {
194+
self.log.error("Could not find persistent alert in notification")
195+
return
196+
}
197+
do {
198+
let savedAlert = try DeviceAlert.decode(from: savedAlertString)
199+
let newTrigger = trigger ?? determineNewTrigger(from: savedAlert, timestamp: savedAlertTimestamp)
200+
let newAlert = DeviceAlert(identifier: savedAlert.identifier,
201+
foregroundContent: savedAlert.foregroundContent,
202+
backgroundContent: savedAlert.backgroundContent,
203+
trigger: newTrigger,
204+
sound: savedAlert.sound)
205+
self.log.debug("Replaying %@Alert: %@ with %@trigger %@",
206+
trigger != nil ? "" : "Pending ",
207+
trigger != nil ? "" : "new ",
208+
savedAlertString, "\(newTrigger)")
209+
self.issueAlert(newAlert)
210+
} catch {
211+
self.log.error("Could not decode alert: error %@, from %@", error.localizedDescription, savedAlertString)
212+
}
213+
}
214+
215+
private func determineNewTrigger(from alert: DeviceAlert, timestamp: Date) -> DeviceAlert.Trigger {
216+
switch alert.trigger {
217+
case .immediate:
218+
return alert.trigger
219+
case .delayed(let interval):
220+
let triggerTime = timestamp.addingTimeInterval(interval)
221+
let timeIntervalSinceNow = triggerTime.timeIntervalSinceNow
222+
if timeIntervalSinceNow < 0 {
223+
// Trigger time has passed...trigger immediately
224+
return .immediate
225+
} else {
226+
return .delayed(interval: timeIntervalSinceNow)
227+
}
228+
case .repeating:
229+
// Strange case here: if it is a repeating trigger, we can't really play back exactly
230+
// at the right "remaining time" and then repeat at the original period. So, I think
231+
// the best we can do is just use the original trigger
232+
return alert.trigger
233+
}
234+
}
235+
236+
}
237+
238+
public extension DeviceAlert {
239+
240+
enum Error: String, Swift.Error {
241+
case noBackgroundContent
242+
}
243+
244+
fileprivate func getUserNotificationContent(timestamp: Date) throws -> UNNotificationContent {
245+
guard let content = backgroundContent else {
246+
throw Error.noBackgroundContent
247+
}
248+
let userNotificationContent = UNMutableNotificationContent()
249+
userNotificationContent.title = content.title
250+
userNotificationContent.body = content.body
251+
userNotificationContent.sound = getUserNotificationSound()
252+
// TODO: Once we have a final design and approval for custom UserNotification buttons, we'll need to set categoryIdentifier
253+
// userNotificationContent.categoryIdentifier = LoopNotificationCategory.alert.rawValue
254+
userNotificationContent.threadIdentifier = identifier.value // Used to match categoryIdentifier, but I /think/ we want multiple threads for multiple alert types, no?
255+
userNotificationContent.userInfo = [
256+
LoopNotificationUserInfoKey.managerIDForAlert.rawValue: identifier.managerIdentifier,
257+
LoopNotificationUserInfoKey.alertTypeID.rawValue: identifier.alertIdentifier,
258+
]
259+
let encodedAlert = try encodeToString()
260+
userNotificationContent.userInfo[DeviceAlertUserNotificationUserInfoKey.deviceAlert.rawValue] = encodedAlert
261+
userNotificationContent.userInfo[DeviceAlertUserNotificationUserInfoKey.deviceAlertTimestamp.rawValue] =
262+
DeviceAlertManager.timestampFormatter.string(from: timestamp)
263+
print("Alert: \(encodedAlert)")
264+
return userNotificationContent
265+
}
266+
267+
private func getUserNotificationSound() -> UNNotificationSound? {
268+
guard let content = backgroundContent else {
269+
return nil
270+
}
271+
if let sound = sound {
272+
switch sound {
273+
case .vibrate:
274+
// TODO: Not sure how to "force" UNNotificationSound to "vibrate only"...so for now we just do the default
275+
break
276+
case .silence:
277+
// TODO: Not sure how to "force" UNNotificationSound to "silence"...so for now we just do the default
278+
break
279+
default:
280+
if let actualFileName = DeviceAlertManager.soundURL(for: self)?.lastPathComponent {
281+
let unname = UNNotificationSoundName(rawValue: actualFileName)
282+
return content.isCritical ? UNNotificationSound.criticalSoundNamed(unname) : UNNotificationSound(named: unname)
283+
}
284+
}
285+
}
286+
287+
return content.isCritical ? .defaultCritical : .default
288+
}
289+
}
290+
291+
public extension UNNotificationRequest {
292+
convenience init(from deviceAlert: DeviceAlert, timestamp: Date) throws {
293+
let uncontent = try deviceAlert.getUserNotificationContent(timestamp: timestamp)
294+
self.init(identifier: deviceAlert.identifier.value,
295+
content: uncontent,
296+
trigger: UNTimeIntervalNotificationTrigger(from: deviceAlert.trigger))
297+
}
298+
}
299+
300+
fileprivate extension UNTimeIntervalNotificationTrigger {
301+
convenience init?(from deviceAlertTrigger: DeviceAlert.Trigger) {
302+
switch deviceAlertTrigger {
303+
case .immediate:
304+
return nil
305+
case .delayed(let timeInterval):
306+
self.init(timeInterval: timeInterval, repeats: false)
307+
case .repeating(let repeatInterval):
308+
self.init(timeInterval: repeatInterval, repeats: true)
309+
}
131310
}
132311
}

Loop/Managers/DeviceAlert/UserNotificationDeviceAlertPresenter.swift

Lines changed: 10 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -7,34 +7,32 @@
77
//
88

99
import LoopKit
10-
import UserNotifications
11-
12-
protocol UserNotificationCenter {
13-
func add(_ request: UNNotificationRequest, withCompletionHandler: ((Error?) -> Void)?)
14-
func removePendingNotificationRequests(withIdentifiers: [String])
15-
func removeDeliveredNotifications(withIdentifiers: [String])
16-
}
17-
18-
extension UNUserNotificationCenter: UserNotificationCenter {}
1910

2011
class UserNotificationDeviceAlertPresenter: DeviceAlertPresenter {
2112

2213
let userNotificationCenter: UserNotificationCenter
2314
let log = DiagnosticLog(category: "UserNotificationDeviceAlertPresenter")
2415

25-
init(userNotificationCenter: UserNotificationCenter = UNUserNotificationCenter.current()) {
16+
init(userNotificationCenter: UserNotificationCenter) {
2617
self.userNotificationCenter = userNotificationCenter
2718
}
28-
19+
2920
func issueAlert(_ alert: DeviceAlert) {
21+
issueAlert(alert, timestamp: Date())
22+
}
23+
24+
func issueAlert(_ alert: DeviceAlert, timestamp: Date) {
3025
DispatchQueue.main.async {
31-
if let request = alert.asUserNotificationRequest() {
26+
do {
27+
let request = try UNNotificationRequest(from: alert, timestamp: timestamp)
3228
self.userNotificationCenter.add(request) { error in
3329
if let error = error {
3430
self.log.error("Something went wrong posting the user notification: %@", error.localizedDescription)
3531
}
3632
}
3733
// For now, UserNotifications do not not acknowledge...not yet at least
34+
} catch {
35+
self.log.error("Error issuing alert: %@", error.localizedDescription)
3836
}
3937
}
4038
}
@@ -51,69 +49,3 @@ class UserNotificationDeviceAlertPresenter: DeviceAlertPresenter {
5149
}
5250
}
5351
}
54-
55-
public extension DeviceAlert {
56-
57-
fileprivate func asUserNotificationRequest() -> UNNotificationRequest? {
58-
guard let uncontent = getUserNotificationContent() else {
59-
return nil
60-
}
61-
return UNNotificationRequest(identifier: identifier.value,
62-
content: uncontent,
63-
trigger: trigger.asUserNotificationTrigger())
64-
}
65-
66-
private func getUserNotificationContent() -> UNNotificationContent? {
67-
guard let content = backgroundContent else {
68-
return nil
69-
}
70-
let userNotificationContent = UNMutableNotificationContent()
71-
userNotificationContent.title = content.title
72-
userNotificationContent.body = content.body
73-
userNotificationContent.sound = getUserNotificationSound()
74-
// TODO: Once we have a final design and approval for custom UserNotification buttons, we'll need to set categoryIdentifier
75-
// userNotificationContent.categoryIdentifier = LoopNotificationCategory.alert.rawValue
76-
userNotificationContent.threadIdentifier = identifier.value // Used to match categoryIdentifier, but I /think/ we want multiple threads for multiple alert types, no?
77-
userNotificationContent.userInfo = [
78-
LoopNotificationUserInfoKey.managerIDForAlert.rawValue: identifier.managerIdentifier,
79-
LoopNotificationUserInfoKey.alertTypeID.rawValue: identifier.alertIdentifier
80-
]
81-
return userNotificationContent
82-
}
83-
84-
private func getUserNotificationSound() -> UNNotificationSound? {
85-
guard let content = backgroundContent else {
86-
return nil
87-
}
88-
if let sound = sound {
89-
switch sound {
90-
case .vibrate:
91-
// TODO: Not sure how to "force" UNNotificationSound to "vibrate only"...so for now we just do the default
92-
break
93-
case .silence:
94-
// TODO: Not sure how to "force" UNNotificationSound to "silence"...so for now we just do the default
95-
break
96-
default:
97-
if let actualFileName = DeviceAlertManager.soundURL(for: self)?.lastPathComponent {
98-
let unname = UNNotificationSoundName(rawValue: actualFileName)
99-
return content.isCritical ? UNNotificationSound.criticalSoundNamed(unname) : UNNotificationSound(named: unname)
100-
}
101-
}
102-
}
103-
104-
return content.isCritical ? .defaultCritical : .default
105-
}
106-
}
107-
108-
public extension DeviceAlert.Trigger {
109-
func asUserNotificationTrigger() -> UNNotificationTrigger? {
110-
switch self {
111-
case .immediate:
112-
return nil
113-
case .delayed(let timeInterval):
114-
return UNTimeIntervalNotificationTrigger(timeInterval: timeInterval, repeats: false)
115-
case .repeating(let repeatInterval):
116-
return UNTimeIntervalNotificationTrigger(timeInterval: repeatInterval, repeats: true)
117-
}
118-
}
119-
}

0 commit comments

Comments
 (0)