UserNotificationsManager.swift 22 KB

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