UserNotificationsManager.swift 19 KB

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