UserNotificationsManager.swift 22 KB

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