UserNotificationsManager.swift 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. import AudioToolbox
  2. import Foundation
  3. import Swinject
  4. import UIKit
  5. import UserNotifications
  6. protocol UserNotificationsManager {}
  7. enum GlucoseSourceKey: String {
  8. case transmitterBattery
  9. case nightscoutPing
  10. }
  11. final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, Injectable {
  12. private enum Identifier: String {
  13. case glucocoseNotification = "FreeAPS.glucoseNotification"
  14. }
  15. @Injected() private var settingsManager: SettingsManager!
  16. @Injected() private var broadcaster: Broadcaster!
  17. @Injected() private var glucoseStorage: GlucoseStorage!
  18. @Injected(as: FetchGlucoseManager.self) private var sourceInfoProvider: SourceInfoProvider!
  19. @Persisted(key: "UserNotificationsManager.snoozeUntilDate") private var snoozeUntilDate: Date = .distantPast
  20. private let center = UNUserNotificationCenter.current()
  21. init(resolver: Resolver) {
  22. super.init()
  23. center.delegate = self
  24. injectServices(resolver)
  25. broadcaster.register(GlucoseObserver.self, observer: self)
  26. requestNotificationPermissionsIfNeeded()
  27. sendGlucoseNotification()
  28. }
  29. private func addAppBadge(glucose: Int?) {
  30. guard let glucose = glucose, settingsManager.settings.glucoseBadge else {
  31. DispatchQueue.main.async {
  32. UIApplication.shared.applicationIconBadgeNumber = 0
  33. }
  34. return
  35. }
  36. let badge: Int
  37. if settingsManager.settings.units == .mmolL {
  38. badge = Int(round(Double((glucose * 10).asMmolL)))
  39. } else {
  40. badge = glucose
  41. }
  42. DispatchQueue.main.async {
  43. UIApplication.shared.applicationIconBadgeNumber = badge
  44. }
  45. }
  46. private func sendGlucoseNotification() {
  47. addAppBadge(glucose: nil)
  48. let glucose = glucoseStorage.recent()
  49. guard let lastGlucose = glucose.last, let glucoseValue = lastGlucose.glucose else { return }
  50. addAppBadge(glucose: lastGlucose.glucose)
  51. guard glucoseStorage.alarm != nil || settingsManager.settings.glucoseNotificationsAlways else {
  52. return
  53. }
  54. ensureCanSendNotification {
  55. var titles: [String] = []
  56. var notificationAlarm = false
  57. switch self.glucoseStorage.alarm {
  58. case .none:
  59. titles.append(NSLocalizedString("Glucose", comment: "Glucose"))
  60. case .low:
  61. titles.append(NSLocalizedString("LOWALERT!", comment: "LOWALERT!"))
  62. notificationAlarm = true
  63. self.playSoundIfNeeded()
  64. case .high:
  65. titles.append(NSLocalizedString("HIGHALERT!", comment: "HIGHALERT!"))
  66. notificationAlarm = true
  67. self.playSoundIfNeeded()
  68. }
  69. if self.snoozeUntilDate > Date() {
  70. titles.append(NSLocalizedString("(Snoozed)", comment: "(Snoozed)"))
  71. notificationAlarm = false
  72. }
  73. let delta = glucose.count >= 2 ? glucoseValue - (glucose[glucose.count - 2].glucose ?? 0) : nil
  74. let body = self.glucoseText(glucoseValue: glucoseValue, delta: delta, direction: lastGlucose.direction) + self
  75. .infoBody()
  76. titles.append(body)
  77. let content = UNMutableNotificationContent()
  78. content.title = titles.joined(separator: " ")
  79. content.body = body
  80. if notificationAlarm {
  81. content.sound = .default
  82. }
  83. self.addRequest(identifier: .glucocoseNotification, content: content, deleteOld: true)
  84. }
  85. }
  86. private func glucoseText(glucoseValue: Int, delta: Int?, direction: BloodGlucose.Direction?) -> String {
  87. let units = settingsManager.settings.units
  88. let glucoseText = glucoseFormatter
  89. .string(from: Double(
  90. units == .mmolL ? glucoseValue
  91. .asMmolL : Decimal(glucoseValue)
  92. ) as NSNumber)! + " " + NSLocalizedString(units.rawValue, comment: "units")
  93. let directionText = direction?.symbol ?? "↔︎"
  94. let deltaText = delta
  95. .map {
  96. self.deltaFormatter
  97. .string(from: Double(
  98. units == .mmolL ? $0
  99. .asMmolL : Decimal($0)
  100. ) as NSNumber)!
  101. } ?? "--"
  102. return glucoseText + " " + directionText + " " + deltaText
  103. }
  104. private func infoBody() -> String {
  105. var body = ""
  106. if settingsManager.settings.addSourceInfoToGlucoseNotifications,
  107. let info = sourceInfoProvider.sourceInfo()
  108. {
  109. // NS ping
  110. if let ping = info[GlucoseSourceKey.nightscoutPing.rawValue] as? TimeInterval {
  111. body.append(
  112. "\n"
  113. + String(
  114. format: NSLocalizedString("Nightscout ping: %d ms", comment: "Nightscout ping"),
  115. Int(ping * 1000)
  116. )
  117. )
  118. }
  119. // Transmitter battery
  120. if let transmitterBattery = info[GlucoseSourceKey.transmitterBattery.rawValue] as? Int {
  121. body.append(
  122. "\n"
  123. + String(
  124. format: NSLocalizedString("Transmitter: %@%%", comment: "Transmitter: %@%%"),
  125. transmitterBattery
  126. )
  127. )
  128. }
  129. }
  130. return body
  131. }
  132. private func requestNotificationPermissionsIfNeeded() {
  133. center.getNotificationSettings { settings in
  134. debug(.service, "UNUserNotificationCenter.authorizationStatus: \(String(describing: settings.authorizationStatus))")
  135. if ![.authorized, .provisional].contains(settings.authorizationStatus) {
  136. self.requestNotificationPermissions()
  137. }
  138. }
  139. }
  140. private func requestNotificationPermissions() {
  141. debug(.service, "requestNotificationPermissions")
  142. center.requestAuthorization(options: [.badge, .sound, .alert]) { granted, error in
  143. if granted {
  144. debug(.service, "requestNotificationPermissions was granted")
  145. } else {
  146. warning(.service, "requestNotificationPermissions failed", error: error)
  147. }
  148. }
  149. }
  150. private func ensureCanSendNotification(_ completion: @escaping () -> Void) {
  151. center.getNotificationSettings { settings in
  152. guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
  153. warning(.service, "ensureCanSendNotification failed, authorization denied")
  154. return
  155. }
  156. debug(.service, "Sending notification was allowed")
  157. completion()
  158. }
  159. }
  160. private func addRequest(identifier: Identifier, content: UNMutableNotificationContent, deleteOld: Bool = false) {
  161. let request = UNNotificationRequest(identifier: identifier.rawValue, content: content, trigger: nil)
  162. if deleteOld {
  163. center.removeDeliveredNotifications(withIdentifiers: [identifier.rawValue])
  164. center.removePendingNotificationRequests(withIdentifiers: [identifier.rawValue])
  165. }
  166. center.add(request) { error in
  167. if let error = error {
  168. warning(.service, "Unable to addNotificationRequest", error: error)
  169. return
  170. }
  171. debug(.service, "Sending \(identifier) notification")
  172. }
  173. }
  174. private func playSoundIfNeeded() {
  175. guard settingsManager.settings.useAlarmSound, snoozeUntilDate < Date() else { return }
  176. Self.stopPlaying = false
  177. playSound()
  178. }
  179. static let soundID: UInt32 = 1336
  180. private static var stopPlaying = false
  181. private func playSound(times: Int = 3) {
  182. guard times > 0, !Self.stopPlaying else {
  183. return
  184. }
  185. AudioServicesPlaySystemSoundWithCompletion(Self.soundID) {
  186. self.playSound(times: times - 1)
  187. }
  188. }
  189. static func stopSound() {
  190. stopPlaying = true
  191. AudioServicesDisposeSystemSoundID(soundID)
  192. }
  193. private var glucoseFormatter: NumberFormatter {
  194. let formatter = NumberFormatter()
  195. formatter.numberStyle = .decimal
  196. formatter.maximumFractionDigits = 0
  197. if settingsManager.settings.units == .mmolL {
  198. formatter.minimumFractionDigits = 1
  199. formatter.maximumFractionDigits = 1
  200. }
  201. formatter.roundingMode = .halfUp
  202. return formatter
  203. }
  204. private var deltaFormatter: NumberFormatter {
  205. let formatter = NumberFormatter()
  206. formatter.numberStyle = .decimal
  207. formatter.maximumFractionDigits = 2
  208. formatter.positivePrefix = "+"
  209. return formatter
  210. }
  211. }
  212. extension BaseUserNotificationsManager: GlucoseObserver {
  213. func glucoseDidUpdate(_: [BloodGlucose]) {
  214. sendGlucoseNotification()
  215. }
  216. }
  217. extension BaseUserNotificationsManager: UNUserNotificationCenterDelegate {
  218. func userNotificationCenter(
  219. _: UNUserNotificationCenter,
  220. willPresent _: UNNotification,
  221. withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
  222. ) {
  223. completionHandler([.banner, .badge, .sound])
  224. }
  225. }