UserNotificationsManager.swift 21 KB

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