UserNotificationsManager.swift 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. import Combine
  2. import CoreData
  3. import Foundation
  4. import LoopKit
  5. import SwiftUI
  6. import Swinject
  7. import UserNotifications
  8. protocol UserNotificationsManager {
  9. func getNotificationSettings(completionHandler: @escaping (UNNotificationSettings) -> Void)
  10. func requestNotificationPermissions(completion: @escaping (Bool) -> Void)
  11. @MainActor func applySnooze(for duration: TimeInterval) async
  12. }
  13. // MARK: - SnoozeObserver Protocol
  14. protocol SnoozeObserver {
  15. @MainActor func snoozeDidChange(_ untilDate: Date)
  16. }
  17. final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, Injectable {
  18. enum Identifier: String {
  19. case carbsRequiredNotification = "Trio.carbsRequiredNotification"
  20. }
  21. @Injected() var alertPermissionsChecker: AlertPermissionsChecker!
  22. @Injected() private var settingsManager: SettingsManager!
  23. @Injected() private var broadcaster: Broadcaster!
  24. @Injected() private var glucoseStorage: GlucoseStorage!
  25. @Injected() private var apsManager: APSManager!
  26. @Injected() private var trioAlertManager: TrioAlertManager!
  27. @Persisted(key: "UserNotificationsManager.snoozeUntilDate") private var snoozeUntilDate: Date = .distantPast
  28. private let notificationCenter = UNUserNotificationCenter.current()
  29. private let viewContext = CoreDataStack.shared.persistentContainer.viewContext
  30. // Queue for handling Core Data change notifications
  31. private let queue = DispatchQueue(label: "BaseUserNotificationsManager.queue", qos: .userInitiated)
  32. private var coreDataPublisher: AnyPublisher<Set<NSManagedObjectID>, Never>?
  33. private var subscriptions = Set<AnyCancellable>()
  34. init(resolver: Resolver) {
  35. super.init()
  36. notificationCenter.delegate = self
  37. injectServices(resolver)
  38. coreDataPublisher =
  39. CoreDataStack.shared.entityChangePublisher
  40. .receive(on: queue)
  41. .share()
  42. .eraseToAnyPublisher()
  43. Task { await updateGlucoseBadge() }
  44. configureNotificationCategories()
  45. clearLegacyCarbsRequiredNotification()
  46. subscribeGlucoseUpdates()
  47. }
  48. private func configureNotificationCategories() {
  49. notificationCenter.getNotificationCategories { [weak self] existingCategories in
  50. guard let self else { return }
  51. let glucoseCategory = NotificationCategoryFactory.createGlucoseCategory()
  52. var categories = existingCategories
  53. categories.update(with: glucoseCategory)
  54. // UNUserNotificationCenter methods should be called on main thread
  55. Task { @MainActor [weak self] in
  56. guard let self else { return }
  57. self.notificationCenter.setNotificationCategories(categories)
  58. }
  59. }
  60. }
  61. /// Subscribes to the two sources that signal a glucose change so the app
  62. /// icon badge stays current:
  63. /// - `coreDataPublisher` filtered to `GlucoseStored` — catches deletions
  64. /// (batch inserts don't fire normal Core Data save notifications, so
  65. /// inserts come through `updatePublisher` below).
  66. /// - `glucoseStorage.updatePublisher` — fires on every new reading.
  67. private func subscribeGlucoseUpdates() {
  68. coreDataPublisher?.filteredByEntityName("GlucoseStored")
  69. .sink { [weak self] _ in Task { await self?.updateGlucoseBadge() } }
  70. .store(in: &subscriptions)
  71. glucoseStorage.updatePublisher
  72. .receive(on: DispatchQueue.global(qos: .background))
  73. .sink { [weak self] _ in Task { await self?.updateGlucoseBadge() } }
  74. .store(in: &subscriptions)
  75. }
  76. private func addAppBadge(glucose: Int?) {
  77. guard let glucose = glucose, settingsManager.settings.glucoseBadge else {
  78. DispatchQueue.main.async {
  79. self.notificationCenter.setBadgeCount(0) { error in
  80. guard let error else {
  81. return
  82. }
  83. print(error)
  84. }
  85. }
  86. return
  87. }
  88. let badge: Int
  89. if settingsManager.settings.units == .mmolL {
  90. badge = Int(round(Double((glucose * 10).asMmolL)))
  91. } else {
  92. badge = glucose
  93. }
  94. DispatchQueue.main.async {
  95. self.notificationCenter.setBadgeCount(badge) { error in
  96. guard let error else {
  97. return
  98. }
  99. print(error)
  100. }
  101. }
  102. }
  103. /// Removes any `Trio.carbsRequiredNotification` UN still sitting in the
  104. /// system from a pre-pipeline install. Safe no-op when none exist.
  105. private func clearLegacyCarbsRequiredNotification() {
  106. let id = Identifier.carbsRequiredNotification.rawValue
  107. notificationCenter.removePendingNotificationRequests(withIdentifiers: [id])
  108. notificationCenter.removeDeliveredNotifications(withIdentifiers: [id])
  109. }
  110. private func fetchGlucoseIDs() async throws -> [NSManagedObjectID] {
  111. let context = CoreDataStack.shared.newTaskContext()
  112. context.name = "fetchGlucoseIDs"
  113. let results = try await CoreDataStack.shared.fetchEntitiesAsync(
  114. ofType: GlucoseStored.self,
  115. onContext: context,
  116. predicate: NSPredicate.predicateFor20MinAgo,
  117. key: "date",
  118. ascending: false,
  119. fetchLimit: 3
  120. )
  121. return try await context.perform {
  122. guard let fetchedResults = results as? [GlucoseStored] else {
  123. throw CoreDataError.fetchError(function: #function, file: #file)
  124. }
  125. return fetchedResults.map(\.objectID)
  126. }
  127. }
  128. /// Refreshes the Trio app icon badge from the latest stored glucose
  129. /// reading. Glucose alarm emission has moved to `GlucoseAlertCoordinator`
  130. /// (urgent-low / low / forecasted-low / high are issued via
  131. /// `TrioAlertManager` based on the user-configured `[GlucoseAlert]` list).
  132. @MainActor private func updateGlucoseBadge() async {
  133. do {
  134. addAppBadge(glucose: nil)
  135. let glucoseIDs = try await fetchGlucoseIDs()
  136. let latest = try glucoseIDs.compactMap { id in
  137. try viewContext.existingObject(with: id) as? GlucoseStored
  138. }.first?.glucose
  139. addAppBadge(glucose: latest.map { Int($0) })
  140. } catch {
  141. debug(.service, "Failed to update glucose badge: \(error)")
  142. }
  143. }
  144. func getNotificationSettings(completionHandler: @escaping (UNNotificationSettings) -> Void) {
  145. notificationCenter.getNotificationSettings { settings in
  146. DispatchQueue.main.async {
  147. completionHandler(settings)
  148. }
  149. }
  150. }
  151. func requestNotificationPermissions(completion: @escaping (Bool) -> Void) {
  152. debug(.service, "requestNotificationPermissions")
  153. notificationCenter.requestAuthorization(options: [.badge, .sound, .alert]) { granted, error in
  154. if granted {
  155. debug(.service, "requestNotificationPermissions was granted")
  156. DispatchQueue.main.async {
  157. completion(granted)
  158. }
  159. } else {
  160. warning(.service, "requestNotificationPermissions failed", error: error)
  161. }
  162. }
  163. }
  164. /// Forwards to the canonical snooze entry point on `TrioAlertManager`.
  165. /// All snooze surfaces (this method via UN actions / Watch / Snooze
  166. /// module / in-app banner) converge there so persistent state, mute
  167. /// window, and observers stay in sync.
  168. @MainActor func applySnooze(for duration: TimeInterval) async {
  169. await trioAlertManager.applySnooze(for: duration)
  170. }
  171. }
  172. extension BaseUserNotificationsManager: UNUserNotificationCenterDelegate {
  173. func userNotificationCenter(
  174. _: UNUserNotificationCenter,
  175. willPresent notification: UNNotification,
  176. withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
  177. ) {
  178. let userInfo = notification.request.content.userInfo
  179. if userInfo[AlertUserInfoKey.managerIdentifier.rawValue] is String {
  180. completionHandler([.badge, .list])
  181. return
  182. }
  183. completionHandler([.banner, .badge, .sound, .list])
  184. }
  185. /// UNUserNotificationCenterDelegate method called when user interacts with a notification.
  186. /// This can be called off the main thread, so we ensure all work happens on @MainActor.
  187. func userNotificationCenter(
  188. _: UNUserNotificationCenter,
  189. didReceive response: UNNotificationResponse,
  190. withCompletionHandler completionHandler: @escaping () -> Void
  191. ) {
  192. defer { completionHandler() }
  193. let userInfo = response.notification.request.content.userInfo
  194. if userInfo[AlertUserInfoKey.managerIdentifier.rawValue] is String {
  195. trioAlertManager.handleNotificationResponse(response)
  196. return
  197. }
  198. // Handle quick snooze actions (from notification action buttons).
  199. if let quickAction = NotificationResponseAction(rawValue: response.actionIdentifier) {
  200. Task { @MainActor [weak self] in
  201. guard let self else { return }
  202. await self.applySnooze(for: quickAction.duration)
  203. }
  204. }
  205. }
  206. }