import AudioToolbox import Combine import CoreData import Foundation import LoopKit import SwiftUI import Swinject import UIKit import UserNotifications protocol UserNotificationsManager {} enum GlucoseSourceKey: String { case transmitterBattery case nightscoutPing case description } enum NotificationAction: String { static let key = "action" case snooze case pumpConfig } protocol BolusFailureObserver { func bolusDidFail() } protocol alertMessageNotificationObserver { func alertMessageNotification(_ message: MessageContent) } protocol pumpNotificationObserver { func pumpNotification(alert: AlertEntry) func pumpRemoveNotification() } final class BaseUserNotificationsManager: NSObject, UserNotificationsManager, Injectable { private enum Identifier: String { case glucocoseNotification = "FreeAPS.glucoseNotification" case carbsRequiredNotification = "FreeAPS.carbsRequiredNotification" case noLoopFirstNotification = "FreeAPS.noLoopFirstNotification" case noLoopSecondNotification = "FreeAPS.noLoopSecondNotification" case bolusFailedNotification = "FreeAPS.bolusFailedNotification" case pumpNotification = "FreeAPS.pumpNotification" case alertMessageNotification = "FreeAPS.alertMessageNotification" } @Injected() var alertPermissionsChecker: AlertPermissionsChecker! @Injected() private var settingsManager: SettingsManager! @Injected() private var broadcaster: Broadcaster! @Injected() private var glucoseStorage: GlucoseStorage! @Injected() private var apsManager: APSManager! @Injected() private var router: Router! @Injected(as: FetchGlucoseManager.self) private var sourceInfoProvider: SourceInfoProvider! @Persisted(key: "UserNotificationsManager.snoozeUntilDate") private var snoozeUntilDate: Date = .distantPast private let center = UNUserNotificationCenter.current() private var lifetime = Lifetime() private let viewContext = CoreDataStack.shared.persistentContainer.viewContext private let backgroundContext = CoreDataStack.shared.newTaskContext() private var coreDataPublisher: AnyPublisher, Never>? private var subscriptions = Set() init(resolver: Resolver) { super.init() center.delegate = self injectServices(resolver) coreDataPublisher = changedObjectsOnManagedObjectContextDidSavePublisher() .receive(on: DispatchQueue.global(qos: .background)) .share() .eraseToAnyPublisher() broadcaster.register(DeterminationObserver.self, observer: self) broadcaster.register(BolusFailureObserver.self, observer: self) broadcaster.register(pumpNotificationObserver.self, observer: self) broadcaster.register(alertMessageNotificationObserver.self, observer: self) requestNotificationPermissionsIfNeeded() Task { await sendGlucoseNotification() } registerHandlers() registerSubscribers() subscribeOnLoop() } private func subscribeOnLoop() { apsManager.lastLoopDateSubject .sink { [weak self] date in self?.scheduleMissingLoopNotifiactions(date: date) } .store(in: &lifetime) } private func registerHandlers() { // Due to the Batch insert this only is used for observing Deletion of Glucose entries coreDataPublisher?.filterByEntityName("GlucoseStored").sink { [weak self] _ in guard let self = self else { return } Task { await self.sendGlucoseNotification() } }.store(in: &subscriptions) } private func registerSubscribers() { glucoseStorage.updatePublisher .receive(on: DispatchQueue.global(qos: .background)) .sink { [weak self] _ in guard let self = self else { return } Task { await self.sendGlucoseNotification() } } .store(in: &subscriptions) } private func addAppBadge(glucose: Int?) { guard let glucose = glucose, settingsManager.settings.glucoseBadge else { DispatchQueue.main.async { UIApplication.shared.applicationIconBadgeNumber = 0 } return } let badge: Int if settingsManager.settings.units == .mmolL { badge = Int(round(Double((glucose * 10).asMmolL))) } else { badge = glucose } DispatchQueue.main.async { UIApplication.shared.applicationIconBadgeNumber = badge } } private func notifyCarbsRequired(_ carbs: Int) { guard Decimal(carbs) >= settingsManager.settings.carbsRequiredThreshold, settingsManager.settings.showCarbsRequiredBadge else { return } var titles: [String] = [] let content = UNMutableNotificationContent() if snoozeUntilDate > Date() { titles.append(NSLocalizedString("(Snoozed)", comment: "(Snoozed)")) } else { content.sound = .default playSoundIfNeeded() } titles.append(String(format: NSLocalizedString("Carbs required: %d g", comment: "Carbs required"), carbs)) content.title = titles.joined(separator: " ") content.body = String( format: NSLocalizedString( "To prevent LOW required %d g of carbs", comment: "To prevent LOW required %d g of carbs" ), carbs ) addRequest(identifier: .carbsRequiredNotification, content: content, deleteOld: true) } private func scheduleMissingLoopNotifiactions(date _: Date) { let title = NSLocalizedString("Trio Not Active", comment: "Trio Not Active") let body = NSLocalizedString("Last loop was more than %d min ago", comment: "Last loop was more than %d min ago") let firstInterval = 20 // min let secondInterval = 40 // min let firstContent = UNMutableNotificationContent() firstContent.title = title firstContent.body = String(format: body, firstInterval) firstContent.sound = .default let secondContent = UNMutableNotificationContent() secondContent.title = title secondContent.body = String(format: body, secondInterval) secondContent.sound = .default let firstTrigger = UNTimeIntervalNotificationTrigger(timeInterval: 60 * TimeInterval(firstInterval), repeats: false) let secondTrigger = UNTimeIntervalNotificationTrigger(timeInterval: 60 * TimeInterval(secondInterval), repeats: false) addRequest( identifier: .noLoopFirstNotification, content: firstContent, deleteOld: true, trigger: firstTrigger ) addRequest( identifier: .noLoopSecondNotification, content: secondContent, deleteOld: true, trigger: secondTrigger ) } private func notifyBolusFailure() { let title = NSLocalizedString("Bolus failed", comment: "Bolus failed") let body = NSLocalizedString( "Bolus failed or inaccurate. Check pump history before repeating.", comment: "Bolus failed or inaccurate. Check pump history before repeating." ) let content = UNMutableNotificationContent() content.title = title content.body = body content.sound = .default addRequest( identifier: .noLoopFirstNotification, content: content, deleteOld: true, trigger: nil ) } private func fetchGlucoseIDs() async -> [NSManagedObjectID] { let results = await CoreDataStack.shared.fetchEntitiesAsync( ofType: GlucoseStored.self, onContext: backgroundContext, predicate: NSPredicate.predicateFor20MinAgo, key: "date", ascending: false, fetchLimit: 3 ) guard let fetchedResults = results as? [GlucoseStored] else { return [] } return await backgroundContext.perform { return fetchedResults.map(\.objectID) } } @MainActor private func sendGlucoseNotification() async { do { addAppBadge(glucose: nil) let glucoseIDs = await fetchGlucoseIDs() let glucoseObjects = try glucoseIDs.compactMap { id in try viewContext.existingObject(with: id) as? GlucoseStored } guard let lastReading = glucoseObjects.first?.glucose, let secondLastReading = glucoseObjects.dropFirst().first?.glucose, let lastDirection = glucoseObjects.first?.directionEnum?.symbol else { return } addAppBadge(glucose: (glucoseObjects.first?.glucose).map { Int($0) }) guard glucoseStorage.alarm != nil || settingsManager.settings.glucoseNotificationsAlways else { return } var titles: [String] = [] var notificationAlarm = false switch glucoseStorage.alarm { case .none: titles.append(NSLocalizedString("Glucose", comment: "Glucose")) case .low: titles.append(NSLocalizedString("LOWALERT!", comment: "LOWALERT!")) notificationAlarm = true case .high: titles.append(NSLocalizedString("HIGHALERT!", comment: "HIGHALERT!")) notificationAlarm = true } let delta = glucoseObjects.count >= 2 ? lastReading - secondLastReading : nil let body = glucoseText( glucoseValue: Int(lastReading), delta: Int(delta ?? 0), direction: lastDirection ) + infoBody() if snoozeUntilDate > Date() { titles.append(NSLocalizedString("(Snoozed)", comment: "(Snoozed)")) notificationAlarm = false } else { titles.append(body) let content = UNMutableNotificationContent() content.title = titles.joined(separator: " ") content.body = body if notificationAlarm { playSoundIfNeeded() content.sound = .default content.userInfo[NotificationAction.key] = NotificationAction.snooze.rawValue } addRequest(identifier: .glucocoseNotification, content: content, deleteOld: true) } } catch { debugPrint( "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to send glucose notification with error: \(error.localizedDescription)" ) } } private func glucoseText(glucoseValue: Int, delta: Int?, direction: String?) -> String { let units = settingsManager.settings.units let glucoseText = glucoseFormatter .string(from: Double( units == .mmolL ? glucoseValue .asMmolL : Decimal(glucoseValue) ) as NSNumber)! + " " + NSLocalizedString(units.rawValue, comment: "units") let directionText = direction ?? "↔︎" let deltaText = delta .map { self.deltaFormatter .string(from: Double( units == .mmolL ? $0 .asMmolL : Decimal($0) ) as NSNumber)! } ?? "--" return glucoseText + " " + directionText + " " + deltaText } private func infoBody() -> String { var body = "" if settingsManager.settings.addSourceInfoToGlucoseNotifications, let info = sourceInfoProvider.sourceInfo() { // Description if let description = info[GlucoseSourceKey.description.rawValue] as? String { body.append("\n" + description) } // NS ping if let ping = info[GlucoseSourceKey.nightscoutPing.rawValue] as? TimeInterval { body.append( "\n" + String( format: NSLocalizedString("Nightscout ping: %d ms", comment: "Nightscout ping"), Int(ping * 1000) ) ) } // Transmitter battery if let transmitterBattery = info[GlucoseSourceKey.transmitterBattery.rawValue] as? Int { body.append( "\n" + String( format: NSLocalizedString("Transmitter: %@%%", comment: "Transmitter: %@%%"), "\(transmitterBattery)" ) ) } } return body } private func requestNotificationPermissionsIfNeeded() { center.getNotificationSettings { settings in debug(.service, "UNUserNotificationCenter.authorizationStatus: \(String(describing: settings.authorizationStatus))") if ![.authorized, .provisional].contains(settings.authorizationStatus) { self.requestNotificationPermissions() } } } private func requestNotificationPermissions() { debug(.service, "requestNotificationPermissions") center.requestAuthorization(options: [.badge, .sound, .alert]) { granted, error in if granted { debug(.service, "requestNotificationPermissions was granted") } else { warning(.service, "requestNotificationPermissions failed", error: error) } } } private func ensureCanSendNotification(_ completion: @escaping () -> Void) { center.getNotificationSettings { settings in guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else { warning(.service, "ensureCanSendNotification failed, authorization denied") return } debug(.service, "Sending notification was allowed") completion() } } private func addRequest( identifier: Identifier, content: UNMutableNotificationContent, deleteOld: Bool = false, trigger: UNNotificationTrigger? = nil, messageType: MessageType = MessageType.other ) { if alertPermissionsChecker.notificationsDisabled, trigger == nil { if trigger != nil { debug(.default, "TODO: Triggers are not supported by alertMessage") return } let messageCont = MessageContent( content: content.body, type: messageType, title: content.title, useAPN: false ) router.alertMessage.send(messageCont) return } let timestamp = Date().timeIntervalSince1970 let uniqueIdentifier = "\(identifier.rawValue)_\(timestamp)" content.threadIdentifier = String(describing: messageType) let request = UNNotificationRequest(identifier: uniqueIdentifier, content: content, trigger: trigger) if deleteOld { DispatchQueue.main.async { self.center.removeDeliveredNotifications(withIdentifiers: [identifier.rawValue]) self.center.removePendingNotificationRequests(withIdentifiers: [identifier.rawValue]) } } DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { self.center.add(request) { error in if let error = error { warning(.service, "Unable to addNotificationRequest", error: error) return } debug(.service, "Sending \(identifier) notification") } } } private func playSoundIfNeeded() { guard settingsManager.settings.useAlarmSound, snoozeUntilDate < Date() else { return } Self.stopPlaying = false playSound() } static let soundID: UInt32 = 1336 private static var stopPlaying = false private func playSound(times: Int = 1) { guard times > 0, !Self.stopPlaying else { return } AudioServicesPlaySystemSoundWithCompletion(Self.soundID) { self.playSound(times: times - 1) } } static func stopSound() { stopPlaying = true AudioServicesDisposeSystemSoundID(soundID) } private var glucoseFormatter: NumberFormatter { let formatter = NumberFormatter() formatter.numberStyle = .decimal formatter.maximumFractionDigits = 0 if settingsManager.settings.units == .mmolL { formatter.minimumFractionDigits = 1 formatter.maximumFractionDigits = 1 } formatter.roundingMode = .halfUp return formatter } private var deltaFormatter: NumberFormatter { let formatter = NumberFormatter() formatter.numberStyle = .decimal formatter.maximumFractionDigits = 1 formatter.positivePrefix = "+" return formatter } } extension BaseUserNotificationsManager: alertMessageNotificationObserver { func alertMessageNotification(_ message: MessageContent) { let content = UNMutableNotificationContent() switch message.type { case .info: content.title = NSLocalizedString("Info", comment: "Info title") case .warning: content.title = NSLocalizedString("Warning", comment: "Warning title") case .errorPump: content.title = NSLocalizedString("Error", comment: "Error title") default: content.title = message.title } content.body = NSLocalizedString(message.content, comment: "Info message") content.sound = .default addRequest( identifier: .alertMessageNotification, content: content, deleteOld: true, trigger: nil, messageType: message.type ) } } extension BaseUserNotificationsManager: pumpNotificationObserver { func pumpNotification(alert: AlertEntry) { let content = UNMutableNotificationContent() let alertUp = alert.alertIdentifier.uppercased() if alertUp.contains("FAULT") || alertUp.contains("ERROR") { content.userInfo[NotificationAction.key] = NotificationAction.pumpConfig.rawValue } content.title = alert.contentTitle ?? "Unknown" content.body = alert.contentBody ?? "Unknown" content.sound = .default addRequest( identifier: .pumpNotification, content: content, deleteOld: true, trigger: nil, messageType: MessageType.errorPump ) } func pumpRemoveNotification() { let identifier: Identifier = .pumpNotification DispatchQueue.main.async { self.center.removeDeliveredNotifications(withIdentifiers: [identifier.rawValue]) self.center.removePendingNotificationRequests(withIdentifiers: [identifier.rawValue]) } } } extension BaseUserNotificationsManager: DeterminationObserver { func determinationDidUpdate(_ determination: Determination) { guard let carndRequired = determination.carbsReq else { return } notifyCarbsRequired(Int(carndRequired)) } } extension BaseUserNotificationsManager: BolusFailureObserver { func bolusDidFail() { notifyBolusFailure() } } extension BaseUserNotificationsManager: UNUserNotificationCenterDelegate { func userNotificationCenter( _: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { switch notification.request.identifier { case Identifier.pumpNotification.rawValue: completionHandler([.banner, .badge, .sound, .list]) default: completionHandler([.banner, .badge, .sound, .list]) } } func userNotificationCenter( _: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void ) { defer { completionHandler() } guard let actionRaw = response.notification.request.content.userInfo[NotificationAction.key] as? String, let action = NotificationAction(rawValue: actionRaw) else { return } switch action { case .snooze: router.mainModalScreen.send(.snooze) case .pumpConfig: let messageCont = MessageContent(content: response.notification.request.content.body, type: MessageType.pumpConfig) router.alertMessage.send(messageCont) } } }