UserNotificationsManager.swift 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638
  1. import AudioToolbox
  2. import Combine
  3. import CoreData
  4. import Foundation
  5. import LoopKit
  6. import SwiftUI
  7. import Swinject
  8. import UIKit
  9. import UserNotifications
  10. protocol UserNotificationsManager {}
  11. enum GlucoseSourceKey: String {
  12. case transmitterBattery
  13. case nightscoutPing
  14. case description
  15. }
  16. enum NotificationAction: String {
  17. static let key = "action"
  18. case snooze
  19. case pumpConfig
  20. case none
  21. }
  22. protocol BolusFailureObserver {
  23. func bolusDidFail()
  24. }
  25. protocol alertMessageNotificationObserver {
  26. func alertMessageNotification(_ message: MessageContent)
  27. }
  28. protocol pumpNotificationObserver {
  29. func pumpNotification(alert: AlertEntry)
  30. func pumpRemoveNotification()
  31. }
  32. final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, Injectable {
  33. private enum Identifier: String {
  34. case glucoseNotification = "Trio.glucoseNotification"
  35. case carbsRequiredNotification = "Trio.carbsRequiredNotification"
  36. case noLoopFirstNotification = "Trio.noLoopFirstNotification"
  37. case noLoopSecondNotification = "Trio.noLoopSecondNotification"
  38. case bolusFailedNotification = "Trio.bolusFailedNotification"
  39. case pumpNotification = "Trio.pumpNotification"
  40. case alertMessageNotification = "Trio.alertMessageNotification"
  41. }
  42. @Injected() var alertPermissionsChecker: AlertPermissionsChecker!
  43. @Injected() private var settingsManager: SettingsManager!
  44. @Injected() private var broadcaster: Broadcaster!
  45. @Injected() private var glucoseStorage: GlucoseStorage!
  46. @Injected() private var apsManager: APSManager!
  47. @Injected() private var router: Router!
  48. @Injected(as: FetchGlucoseManager.self) private var sourceInfoProvider: SourceInfoProvider!
  49. @Persisted(key: "UserNotificationsManager.snoozeUntilDate") private var snoozeUntilDate: Date = .distantPast
  50. private let center = UNUserNotificationCenter.current()
  51. private var lifetime = Lifetime()
  52. private let viewContext = CoreDataStack.shared.persistentContainer.viewContext
  53. private let backgroundContext = CoreDataStack.shared.newTaskContext()
  54. // Queue for handling Core Data change notifications
  55. private let queue = DispatchQueue(label: "BaseUserNotificationsManager.queue", qos: .userInitiated)
  56. private var coreDataPublisher: AnyPublisher<Set<NSManagedObjectID>, Never>?
  57. private var subscriptions = Set<AnyCancellable>()
  58. let firstInterval = 20 // min
  59. let secondInterval = 40 // min
  60. init(resolver: Resolver) {
  61. super.init()
  62. center.delegate = self
  63. injectServices(resolver)
  64. coreDataPublisher =
  65. changedObjectsOnManagedObjectContextDidSavePublisher()
  66. .receive(on: queue)
  67. .share()
  68. .eraseToAnyPublisher()
  69. broadcaster.register(DeterminationObserver.self, observer: self)
  70. broadcaster.register(BolusFailureObserver.self, observer: self)
  71. broadcaster.register(pumpNotificationObserver.self, observer: self)
  72. broadcaster.register(alertMessageNotificationObserver.self, observer: self)
  73. requestNotificationPermissionsIfNeeded()
  74. Task {
  75. await sendGlucoseNotification()
  76. }
  77. registerHandlers()
  78. registerSubscribers()
  79. subscribeOnLoop()
  80. }
  81. private func subscribeOnLoop() {
  82. apsManager.lastLoopDateSubject
  83. .sink { [weak self] date in
  84. self?.scheduleMissingLoopNotifiactions(date: date)
  85. }
  86. .store(in: &lifetime)
  87. }
  88. private func registerHandlers() {
  89. // Due to the Batch insert this only is used for observing Deletion of Glucose entries
  90. coreDataPublisher?.filterByEntityName("GlucoseStored").sink { [weak self] _ in
  91. guard let self = self else { return }
  92. Task {
  93. await self.sendGlucoseNotification()
  94. }
  95. }.store(in: &subscriptions)
  96. }
  97. private func registerSubscribers() {
  98. glucoseStorage.updatePublisher
  99. .receive(on: DispatchQueue.global(qos: .background))
  100. .sink { [weak self] _ in
  101. guard let self = self else { return }
  102. Task {
  103. await self.sendGlucoseNotification()
  104. }
  105. }
  106. .store(in: &subscriptions)
  107. }
  108. private func addAppBadge(glucose: Int?) {
  109. guard let glucose = glucose, settingsManager.settings.glucoseBadge else {
  110. DispatchQueue.main.async {
  111. self.center.setBadgeCount(0) { error in
  112. guard let error else {
  113. return
  114. }
  115. print(error)
  116. }
  117. }
  118. return
  119. }
  120. let badge: Int
  121. if settingsManager.settings.units == .mmolL {
  122. badge = Int(round(Double((glucose * 10).asMmolL)))
  123. } else {
  124. badge = glucose
  125. }
  126. DispatchQueue.main.async {
  127. self.center.setBadgeCount(badge) { error in
  128. guard let error else {
  129. return
  130. }
  131. print(error)
  132. }
  133. }
  134. }
  135. private func notifyCarbsRequired(_ carbs: Int) {
  136. guard Decimal(carbs) >= settingsManager.settings.carbsRequiredThreshold,
  137. settingsManager.settings.showCarbsRequiredBadge, settingsManager.settings.notificationsCarb else { return }
  138. var titles: [String] = []
  139. let content = UNMutableNotificationContent()
  140. if snoozeUntilDate > Date() {
  141. titles.append(NSLocalizedString("(Snoozed)", comment: "(Snoozed)"))
  142. } else {
  143. content.sound = .default
  144. playSoundIfNeeded()
  145. }
  146. titles.append(String(format: NSLocalizedString("Carbs required: %d g", comment: "Carbs required"), carbs))
  147. content.title = titles.joined(separator: " ")
  148. content.body = String(
  149. format: NSLocalizedString(
  150. "To prevent LOW required %d g of carbs",
  151. comment: "To prevent LOW required %d g of carbs"
  152. ),
  153. carbs
  154. )
  155. addRequest(identifier: .carbsRequiredNotification, content: content, deleteOld: true, messageSubtype: .carb)
  156. }
  157. private func scheduleMissingLoopNotifiactions(date _: Date) {
  158. let title = NSLocalizedString("Trio Not Active", comment: "Trio Not Active")
  159. let body = NSLocalizedString("Last loop was more than %d min ago", comment: "Last loop was more than %d min ago")
  160. let firstContent = UNMutableNotificationContent()
  161. firstContent.title = title
  162. firstContent.body = String(format: body, firstInterval)
  163. firstContent.sound = .default
  164. let secondContent = UNMutableNotificationContent()
  165. secondContent.title = title
  166. secondContent.body = String(format: body, secondInterval)
  167. secondContent.sound = .default
  168. let firstTrigger = UNTimeIntervalNotificationTrigger(timeInterval: 60 * TimeInterval(firstInterval), repeats: false)
  169. let secondTrigger = UNTimeIntervalNotificationTrigger(timeInterval: 60 * TimeInterval(secondInterval), repeats: false)
  170. addRequest(
  171. identifier: .noLoopFirstNotification,
  172. content: firstContent,
  173. deleteOld: true,
  174. trigger: firstTrigger,
  175. messageType: .error,
  176. messageSubtype: .algorithm
  177. )
  178. addRequest(
  179. identifier: .noLoopSecondNotification,
  180. content: secondContent,
  181. deleteOld: true,
  182. trigger: secondTrigger,
  183. messageType: .error,
  184. messageSubtype: .algorithm
  185. )
  186. }
  187. private func notifyBolusFailure() {
  188. let title = NSLocalizedString("Bolus failed", comment: "Bolus failed")
  189. let body = NSLocalizedString(
  190. "Bolus failed or inaccurate. Check pump history before repeating.",
  191. comment: "Bolus failed or inaccurate. Check pump history before repeating."
  192. )
  193. let content = UNMutableNotificationContent()
  194. content.title = title
  195. content.body = body
  196. content.sound = .default
  197. addRequest(
  198. identifier: .noLoopFirstNotification,
  199. content: content,
  200. deleteOld: true,
  201. trigger: nil,
  202. messageType: .error,
  203. messageSubtype: .pump
  204. )
  205. }
  206. private func fetchGlucoseIDs() async -> [NSManagedObjectID] {
  207. let results = await CoreDataStack.shared.fetchEntitiesAsync(
  208. ofType: GlucoseStored.self,
  209. onContext: backgroundContext,
  210. predicate: NSPredicate.predicateFor20MinAgo,
  211. key: "date",
  212. ascending: false,
  213. fetchLimit: 3
  214. )
  215. return await backgroundContext.perform {
  216. guard let fetchedResults = results as? [GlucoseStored] else { return [] }
  217. return fetchedResults.map(\.objectID)
  218. }
  219. }
  220. @MainActor private func sendGlucoseNotification() async {
  221. do {
  222. addAppBadge(glucose: nil)
  223. let glucoseIDs = await fetchGlucoseIDs()
  224. let glucoseObjects = try glucoseIDs.compactMap { id in
  225. try viewContext.existingObject(with: id) as? GlucoseStored
  226. }
  227. guard let lastReading = glucoseObjects.first?.glucose,
  228. let secondLastReading = glucoseObjects.dropFirst().first?.glucose,
  229. let lastDirection = glucoseObjects.first?.directionEnum?.symbol else { return }
  230. addAppBadge(glucose: (glucoseObjects.first?.glucose).map { Int($0) })
  231. var titles: [String] = []
  232. var notificationAlarm = false
  233. var messageType = MessageType.info
  234. switch glucoseStorage.alarm {
  235. case .none:
  236. titles.append(NSLocalizedString("Glucose", comment: "Glucose"))
  237. case .low:
  238. titles.append(NSLocalizedString("LOWALERT!", comment: "LOWALERT!"))
  239. messageType = MessageType.warning
  240. notificationAlarm = true
  241. case .high:
  242. titles.append(NSLocalizedString("HIGHALERT!", comment: "HIGHALERT!"))
  243. messageType = MessageType.warning
  244. notificationAlarm = true
  245. }
  246. let delta = glucoseObjects.count >= 2 ? lastReading - secondLastReading : nil
  247. let body = glucoseText(
  248. glucoseValue: Int(lastReading),
  249. delta: Int(delta ?? 0),
  250. direction: lastDirection
  251. ) + infoBody()
  252. if snoozeUntilDate > Date() {
  253. titles.append(NSLocalizedString("(Snoozed)", comment: "(Snoozed)"))
  254. notificationAlarm = false
  255. } else {
  256. titles.append(body)
  257. let content = UNMutableNotificationContent()
  258. content.title = titles.joined(separator: " ")
  259. content.body = body
  260. if notificationAlarm {
  261. playSoundIfNeeded()
  262. content.sound = .default
  263. content.userInfo[NotificationAction.key] = NotificationAction.snooze.rawValue
  264. }
  265. addRequest(
  266. identifier: .glucoseNotification,
  267. content: content,
  268. deleteOld: true,
  269. messageType: messageType,
  270. messageSubtype: .glucose,
  271. action: NotificationAction.snooze
  272. )
  273. }
  274. } catch {
  275. debugPrint(
  276. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to send glucose notification with error: \(error.localizedDescription)"
  277. )
  278. }
  279. }
  280. private func glucoseText(glucoseValue: Int, delta: Int?, direction: String?) -> String {
  281. let units = settingsManager.settings.units
  282. let glucoseText = glucoseFormatter
  283. .string(from: Double(
  284. units == .mmolL ? glucoseValue
  285. .asMmolL : Decimal(glucoseValue)
  286. ) as NSNumber)! + " " + NSLocalizedString(units.rawValue, comment: "units")
  287. let directionText = direction ?? "↔︎"
  288. let deltaText = delta
  289. .map {
  290. self.deltaFormatter
  291. .string(from: Double(
  292. units == .mmolL ? $0
  293. .asMmolL : Decimal($0)
  294. ) as NSNumber)!
  295. } ?? "--"
  296. return glucoseText + " " + directionText + " " + deltaText
  297. }
  298. private func infoBody() -> String {
  299. var body = ""
  300. if settingsManager.settings.addSourceInfoToGlucoseNotifications,
  301. let info = sourceInfoProvider.sourceInfo()
  302. {
  303. // Description
  304. if let description = info[GlucoseSourceKey.description.rawValue] as? String {
  305. body.append("\n" + description)
  306. }
  307. // NS ping
  308. if let ping = info[GlucoseSourceKey.nightscoutPing.rawValue] as? TimeInterval {
  309. body.append(
  310. "\n"
  311. + String(
  312. format: NSLocalizedString("Nightscout ping: %d ms", comment: "Nightscout ping"),
  313. Int(ping * 1000)
  314. )
  315. )
  316. }
  317. // Transmitter battery
  318. if let transmitterBattery = info[GlucoseSourceKey.transmitterBattery.rawValue] as? Int {
  319. body.append(
  320. "\n"
  321. + String(
  322. format: NSLocalizedString("Transmitter: %@%%", comment: "Transmitter: %@%%"),
  323. "\(transmitterBattery)"
  324. )
  325. )
  326. }
  327. }
  328. return body
  329. }
  330. private func requestNotificationPermissionsIfNeeded() {
  331. center.getNotificationSettings { settings in
  332. debug(.service, "UNUserNotificationCenter.authorizationStatus: \(String(describing: settings.authorizationStatus))")
  333. if ![.authorized, .provisional].contains(settings.authorizationStatus) {
  334. self.requestNotificationPermissions()
  335. }
  336. }
  337. }
  338. private func requestNotificationPermissions() {
  339. debug(.service, "requestNotificationPermissions")
  340. center.requestAuthorization(options: [.badge, .sound, .alert]) { granted, error in
  341. if granted {
  342. debug(.service, "requestNotificationPermissions was granted")
  343. } else {
  344. warning(.service, "requestNotificationPermissions failed", error: error)
  345. }
  346. }
  347. }
  348. private func addRequest(
  349. identifier: Identifier,
  350. content: UNMutableNotificationContent,
  351. deleteOld: Bool = false,
  352. trigger: UNNotificationTrigger? = nil,
  353. messageType: MessageType = MessageType.other,
  354. messageSubtype: MessageSubtype = MessageSubtype.misc,
  355. action: NotificationAction = NotificationAction.none
  356. ) {
  357. let messageCont = MessageContent(
  358. content: content.body,
  359. type: messageType,
  360. subtype: messageSubtype,
  361. title: content.title,
  362. useAPN: false,
  363. trigger: trigger,
  364. action: action
  365. )
  366. var alertIdentifier = identifier.rawValue
  367. alertIdentifier = identifier == .pumpNotification ? alertIdentifier + content
  368. .title : (identifier == .alertMessageNotification ? alertIdentifier + content.body : alertIdentifier)
  369. if deleteOld {
  370. DispatchQueue.main.async {
  371. self.center.removeDeliveredNotifications(withIdentifiers: [alertIdentifier])
  372. self.center.removePendingNotificationRequests(withIdentifiers: [alertIdentifier])
  373. }
  374. }
  375. if alertPermissionsChecker.notificationsDisabled {
  376. router.alertMessage.send(messageCont)
  377. return
  378. }
  379. guard router.allowNotify(messageCont, settingsManager.settings) else { return }
  380. let request = UNNotificationRequest(identifier: alertIdentifier, content: content, trigger: trigger)
  381. DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
  382. self.center.add(request) { error in
  383. if let error = error {
  384. warning(.service, "Unable to addNotificationRequest", error: error)
  385. return
  386. }
  387. debug(.service, "Sending \(identifier) notification for \(request.content.title)")
  388. }
  389. }
  390. }
  391. private func playSoundIfNeeded() {
  392. guard settingsManager.settings.useAlarmSound, snoozeUntilDate < Date() else { return }
  393. Self.stopPlaying = false
  394. playSound()
  395. }
  396. static let soundID: UInt32 = 1336
  397. private static var stopPlaying = false
  398. private func playSound(times: Int = 1) {
  399. guard times > 0, !Self.stopPlaying else {
  400. return
  401. }
  402. AudioServicesPlaySystemSoundWithCompletion(Self.soundID) {
  403. self.playSound(times: times - 1)
  404. }
  405. }
  406. static func stopSound() {
  407. stopPlaying = true
  408. AudioServicesDisposeSystemSoundID(soundID)
  409. }
  410. private var glucoseFormatter: NumberFormatter {
  411. let formatter = NumberFormatter()
  412. formatter.numberStyle = .decimal
  413. formatter.maximumFractionDigits = 0
  414. if settingsManager.settings.units == .mmolL {
  415. formatter.minimumFractionDigits = 1
  416. formatter.maximumFractionDigits = 1
  417. }
  418. formatter.roundingMode = .halfUp
  419. return formatter
  420. }
  421. private var deltaFormatter: NumberFormatter {
  422. let formatter = NumberFormatter()
  423. formatter.numberStyle = .decimal
  424. formatter.maximumFractionDigits = 1
  425. formatter.positivePrefix = "+"
  426. return formatter
  427. }
  428. }
  429. extension BaseUserNotificationsManager: alertMessageNotificationObserver {
  430. func alertMessageNotification(_ message: MessageContent) {
  431. let content = UNMutableNotificationContent()
  432. var identifier: Identifier = .alertMessageNotification
  433. if message.title == "" {
  434. switch message.type {
  435. case .info:
  436. content.title = NSLocalizedString("Info", comment: "Info title")
  437. case .warning:
  438. content.title = NSLocalizedString("Warning", comment: "Warning title")
  439. case .error:
  440. content.title = NSLocalizedString("Error", comment: "Error title")
  441. default:
  442. content.title = message.title
  443. }
  444. } else {
  445. content.title = message.title
  446. }
  447. switch message.subtype {
  448. case .pump:
  449. identifier = .pumpNotification
  450. case .carb:
  451. identifier = .carbsRequiredNotification
  452. case .glucose:
  453. identifier = .glucoseNotification
  454. case .algorithm:
  455. if message.trigger != nil {
  456. identifier = message.content.contains(String(firstInterval)) ? Identifier.noLoopFirstNotification : Identifier
  457. .noLoopSecondNotification
  458. } else {
  459. identifier = Identifier.alertMessageNotification
  460. }
  461. default:
  462. identifier = .alertMessageNotification
  463. }
  464. switch message.action {
  465. case .snooze:
  466. content.userInfo[NotificationAction.key] = NotificationAction.snooze.rawValue
  467. case .pumpConfig:
  468. content.userInfo[NotificationAction.key] = NotificationAction.pumpConfig.rawValue
  469. default: break
  470. }
  471. content.body = NSLocalizedString(message.content, comment: "Info message")
  472. content.sound = .default
  473. addRequest(
  474. identifier: identifier,
  475. content: content,
  476. deleteOld: true,
  477. trigger: message.trigger,
  478. messageType: message.type,
  479. messageSubtype: message.subtype,
  480. action: message.action
  481. )
  482. }
  483. }
  484. extension BaseUserNotificationsManager: pumpNotificationObserver {
  485. func pumpNotification(alert: AlertEntry) {
  486. let content = UNMutableNotificationContent()
  487. let alertUp = alert.alertIdentifier.uppercased()
  488. let typeMessage: MessageType
  489. if alertUp.contains("FAULT") || alertUp.contains("ERROR") {
  490. content.userInfo[NotificationAction.key] = NotificationAction.pumpConfig.rawValue
  491. typeMessage = .error
  492. } else {
  493. typeMessage = .warning
  494. guard settingsManager.settings.notificationsPump else { return }
  495. }
  496. content.title = alert.contentTitle ?? "Unknown"
  497. content.body = alert.contentBody ?? "Unknown"
  498. content.sound = .default
  499. addRequest(
  500. identifier: .pumpNotification,
  501. content: content,
  502. deleteOld: true,
  503. trigger: nil,
  504. messageType: typeMessage,
  505. messageSubtype: .pump,
  506. action: .pumpConfig
  507. )
  508. }
  509. func pumpRemoveNotification() {
  510. let identifier: Identifier = .pumpNotification
  511. DispatchQueue.main.async {
  512. self.center.removeDeliveredNotifications(withIdentifiers: [identifier.rawValue])
  513. self.center.removePendingNotificationRequests(withIdentifiers: [identifier.rawValue])
  514. }
  515. }
  516. }
  517. extension BaseUserNotificationsManager: DeterminationObserver {
  518. func determinationDidUpdate(_ determination: Determination) {
  519. guard let carndRequired = determination.carbsReq else { return }
  520. notifyCarbsRequired(Int(carndRequired))
  521. }
  522. }
  523. extension BaseUserNotificationsManager: BolusFailureObserver {
  524. func bolusDidFail() {
  525. notifyBolusFailure()
  526. }
  527. }
  528. extension BaseUserNotificationsManager: UNUserNotificationCenterDelegate {
  529. func userNotificationCenter(
  530. _: UNUserNotificationCenter,
  531. willPresent _: UNNotification,
  532. withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
  533. ) {
  534. completionHandler([.banner, .badge, .sound, .list])
  535. }
  536. func userNotificationCenter(
  537. _: UNUserNotificationCenter,
  538. didReceive response: UNNotificationResponse,
  539. withCompletionHandler completionHandler: @escaping () -> Void
  540. ) {
  541. defer { completionHandler() }
  542. guard let actionRaw = response.notification.request.content.userInfo[NotificationAction.key] as? String,
  543. let action = NotificationAction(rawValue: actionRaw)
  544. else { return }
  545. switch action {
  546. case .snooze:
  547. router.mainModalScreen.send(.snooze)
  548. case .pumpConfig:
  549. let messageCont = MessageContent(
  550. content: response.notification.request.content.body,
  551. type: MessageType.other,
  552. subtype: .pump,
  553. useAPN: false,
  554. action: .pumpConfig
  555. )
  556. router.alertMessage.send(messageCont)
  557. default: break
  558. }
  559. }
  560. }