// // DataStore.swift // USB Meter // // Created by Bogdan Timofte on 03/03/2020. // Copyright © 2020 Bogdan Timofte. All rights reserved. // import SwiftUI import Combine import CoreBluetooth import CoreData import UserNotifications struct BatteryCheckpointPlausibilityWarning: Identifiable, Hashable { let title: String let message: String var id: String { "\(title)\n\(message)" } } final class AppData : ObservableObject { struct MeterSummary: Identifiable { let macAddress: String let displayName: String let modelSummary: String let advertisedName: String? let lastSeen: Date? let lastConnected: Date? let meter: Meter? var id: String { macAddress } } private var bluetoothManagerNotification: AnyCancellable? private var meterStoreObserver: AnyCancellable? private var meterStoreCloudObserver: AnyCancellable? private var chargeInsightsStoreObserver: AnyCancellable? private var chargeInsightsRemoteObserver: AnyCancellable? private let meterStore = MeterNameStore.shared private var chargeInsightsStore: ChargeInsightsStore? private let chargeNotificationCoordinator = ChargeNotificationCoordinator() init() { bluetoothManagerNotification = bluetoothManager.objectWillChange.sink { [weak self] _ in self?.scheduleObjectWillChange() } meterStoreObserver = NotificationCenter.default.publisher(for: .meterNameStoreDidChange) .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.refreshMeterMetadata() } meterStoreCloudObserver = NotificationCenter.default.publisher(for: .meterNameStoreCloudStatusDidChange) .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.scheduleObjectWillChange() } } let bluetoothManager = BluetoothManager() @Published var enableRecordFeature: Bool = true @Published var meters: [UUID:Meter] = [UUID:Meter]() @Published private(set) var chargedDevices: [ChargedDeviceSummary] = [] var deviceSummaries: [ChargedDeviceSummary] { chargedDevices.filter { !$0.isCharger } } var chargerSummaries: [ChargedDeviceSummary] { chargedDevices.filter { $0.isCharger } } var cloudAvailability: MeterNameStore.CloudAvailability { meterStore.currentCloudAvailability } func activateChargeInsights(context: NSManagedObjectContext) { guard chargeInsightsStore == nil else { return } context.automaticallyMergesChangesFromParent = true context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy chargeInsightsStore = ChargeInsightsStore(context: context) chargeInsightsStoreObserver = NotificationCenter.default.publisher( for: .NSManagedObjectContextObjectsDidChange, object: context ) .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.reloadChargedDevices() } chargeInsightsRemoteObserver = NotificationCenter.default.publisher( for: .NSPersistentStoreRemoteChange, object: nil ) .receive(on: DispatchQueue.main) .sink { [weak self] _ in self?.reloadChargedDevices() } chargeNotificationCoordinator.ensureAuthorizationIfNeeded() reloadChargedDevices() } func meterName(for macAddress: String) -> String? { meterStore.name(for: macAddress) } func setMeterName(_ name: String, for macAddress: String) { meterStore.upsert(macAddress: macAddress, name: name, temperatureUnitRawValue: nil) } func temperatureUnitPreference(for macAddress: String) -> TemperatureUnitPreference { let rawValue = meterStore.temperatureUnitRawValue(for: macAddress) ?? TemperatureUnitPreference.celsius.rawValue return TemperatureUnitPreference(rawValue: rawValue) ?? .celsius } func setTemperatureUnitPreference(_ preference: TemperatureUnitPreference, for macAddress: String) { meterStore.upsert(macAddress: macAddress, name: nil, temperatureUnitRawValue: preference.rawValue) } func registerMeter(macAddress: String, modelName: String?, advertisedName: String?) { meterStore.registerMeter(macAddress: macAddress, modelName: modelName, advertisedName: advertisedName) } func noteMeterSeen(at date: Date, macAddress: String) { meterStore.noteLastSeen(date, for: macAddress) } func noteMeterConnected(at date: Date, macAddress: String) { meterStore.noteLastConnected(date, for: macAddress) } func lastSeen(for macAddress: String) -> Date? { meterStore.lastSeen(for: macAddress) } func lastConnected(for macAddress: String) -> Date? { meterStore.lastConnected(for: macAddress) } func chargedDeviceSummary(id: UUID) -> ChargedDeviceSummary? { chargedDevices.first(where: { $0.id == id }) } func chargeSessionSummary(id: UUID) -> ChargeSessionSummary? { for chargedDevice in chargedDevices { if let session = chargedDevice.sessions.first(where: { $0.id == id }) { return session } } return nil } func chargedDevices(for meterMACAddress: String) -> [ChargedDeviceSummary] { let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() return chargedDevices.filter { chargedDevice in guard chargedDevice.isCharger == false else { return false } return chargedDevice.lastAssociatedMeterMAC == normalizedMAC || chargedDevice.sessions.contains(where: { $0.meterMACAddress == normalizedMAC }) } } func chargers(for meterMACAddress: String) -> [ChargedDeviceSummary] { let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() return chargedDevices.filter { chargedDevice in guard chargedDevice.isCharger else { return false } return chargedDevice.lastAssociatedMeterMAC == normalizedMAC || chargedDevice.sessions.contains(where: { $0.meterMACAddress == normalizedMAC }) } } func currentChargedDeviceSummary(for meterMACAddress: String) -> ChargedDeviceSummary? { let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() if let activeSession = activeChargeSessionSummary(for: normalizedMAC), let liveDevice = chargedDevices.first(where: { $0.id == activeSession.chargedDeviceID && $0.isCharger == false }) { return liveDevice } return chargedDevices.first(where: { $0.isCharger == false && $0.lastAssociatedMeterMAC == normalizedMAC }) } func currentChargerSummary(for meterMACAddress: String) -> ChargedDeviceSummary? { let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() if let activeSession = activeChargeSessionSummary(for: normalizedMAC), let chargerID = activeSession.chargerID, let liveCharger = chargedDevices.first(where: { $0.id == chargerID && $0.isCharger }) { return liveCharger } return chargedDevices.first(where: { $0.isCharger && $0.lastAssociatedMeterMAC == normalizedMAC }) } func activeChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? { chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: meterMACAddress) } @discardableResult func createDevice( name: String, deviceClass: ChargedDeviceClass, chargingStateAvailability: ChargingStateAvailability, supportsWiredCharging: Bool, supportsWirelessCharging: Bool, wirelessChargingProfile: WirelessChargingProfile, configuredCompletionCurrents: [ChargeSessionKind: Double], notes: String?, meterMACAddress: String? ) -> Bool { let didSave = chargeInsightsStore?.createDevice( name: name, deviceClass: deviceClass, chargingStateAvailability: chargingStateAvailability, supportsWiredCharging: supportsWiredCharging, supportsWirelessCharging: supportsWirelessCharging, wirelessChargingProfile: wirelessChargingProfile, configuredCompletionCurrents: configuredCompletionCurrents, notes: notes, assignTo: meterMACAddress ) ?? false if didSave { reloadChargedDevices() } return didSave } @discardableResult func createCharger( name: String, notes: String?, meterMACAddress: String? ) -> Bool { let didSave = chargeInsightsStore?.createCharger( name: name, notes: notes, assignTo: meterMACAddress ) ?? false if didSave { reloadChargedDevices() } return didSave } @discardableResult func updateDevice( id: UUID, name: String, deviceClass: ChargedDeviceClass, chargingStateAvailability: ChargingStateAvailability, supportsWiredCharging: Bool, supportsWirelessCharging: Bool, wirelessChargingProfile: WirelessChargingProfile, configuredCompletionCurrents: [ChargeSessionKind: Double], notes: String? ) -> Bool { let didSave = chargeInsightsStore?.updateDevice( id: id, name: name, deviceClass: deviceClass, chargingStateAvailability: chargingStateAvailability, supportsWiredCharging: supportsWiredCharging, supportsWirelessCharging: supportsWirelessCharging, wirelessChargingProfile: wirelessChargingProfile, configuredCompletionCurrents: configuredCompletionCurrents, notes: notes ) ?? false if didSave { reloadChargedDevices() } return didSave } @discardableResult func updateCharger( id: UUID, name: String, notes: String? ) -> Bool { let didSave = chargeInsightsStore?.updateCharger( id: id, name: name, notes: notes ) ?? false if didSave { reloadChargedDevices() } return didSave } @discardableResult func assignChargedDevice(_ chargedDeviceID: UUID, to meterMACAddress: String) -> Bool { let didSave = chargeInsightsStore?.assignChargedDevice(id: chargedDeviceID, to: meterMACAddress) ?? false if didSave { reloadChargedDevices() } return didSave } @discardableResult func assignCharger(_ chargerID: UUID, to meterMACAddress: String) -> Bool { let didSave = chargeInsightsStore?.assignCharger(id: chargerID, to: meterMACAddress) ?? false if didSave { reloadChargedDevices() } return didSave } func restoreChargeMonitoringStateIfNeeded(for meter: Meter) { guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else { return } guard activeSession.status == .active else { return } meter.restoreChargeMonitoringIfNeeded(from: activeSession) } @discardableResult func startChargeSession( for meter: Meter, chargedDeviceID: UUID, chargerID: UUID?, chargingTransportMode: ChargingTransportMode, chargingStateMode: ChargingStateMode, autoStopEnabled: Bool, initialBatteryPercent: Double?, startsFromFlatBattery: Bool ) -> Bool { meter.resetMeterCountersForNewSession() guard let snapshot = meter.chargingMonitorSnapshot else { return false } let didSave = chargeInsightsStore?.startSession( for: snapshot, chargedDeviceID: chargedDeviceID, chargerID: chargerID, chargingTransportMode: chargingTransportMode, chargingStateMode: chargingStateMode, autoStopEnabled: autoStopEnabled, initialBatteryPercent: initialBatteryPercent, startsFromFlatBattery: startsFromFlatBattery ) ?? false if didSave { reloadChargedDevices() meter.resetChargeRecordGraph() if let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description), meter.supportsRecordingThreshold, activeSession.stopThresholdAmps > 0 { meter.recordingTreshold = activeSession.stopThresholdAmps } restoreChargeMonitoringStateIfNeeded(for: meter) } return didSave } @discardableResult func pauseChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool { let observedAt = meter?.chargingMonitorSnapshot?.observedAt ?? Date() let didSave = chargeInsightsStore?.pauseSession(id: sessionID, observedAt: observedAt) ?? false if didSave { reloadChargedDevices() } return didSave } @discardableResult func resumeChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool { let snapshot = meter?.chargingMonitorSnapshot let didSave = chargeInsightsStore?.resumeSession(id: sessionID, snapshot: snapshot) ?? false if didSave { reloadChargedDevices() } return didSave } @discardableResult func stopChargeSession( sessionID: UUID, finalBatteryPercent: Double, label: String? = "Final" ) -> Bool { let didSave = chargeInsightsStore?.stopSession( id: sessionID, finalBatteryPercent: finalBatteryPercent, label: label ) ?? false if didSave { reloadChargedDevices() } return didSave } func observeChargeSnapshot(from meter: Meter, observedAt: Date = Date()) { guard let snapshot = meter.chargingMonitorSnapshot(at: observedAt) else { return } if chargeInsightsStore?.observe(snapshot: snapshot) == true { reloadChargedDevices() } } @discardableResult func addBatteryCheckpoint(percent: Double, label: String?, for meter: Meter) -> Bool { observeChargeSnapshot(from: meter) let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) let checkpointEnergyWh = activeSession.map { displayedSessionEnergyWh(for: $0, on: meter) } let checkpointChargeAh = activeSession.map { displayedSessionChargeAh(for: $0, on: meter) } let didSave = chargeInsightsStore?.addBatteryCheckpoint( percent: percent, label: label, for: meter.btSerial.macAddress.description, measuredEnergyWh: checkpointEnergyWh, measuredChargeAh: checkpointChargeAh ) ?? false if didSave { reloadChargedDevices() } return didSave } @discardableResult func addBatteryCheckpoint(percent: Double, label: String?, for sessionID: UUID) -> Bool { let didSave = chargeInsightsStore?.addBatteryCheckpoint( percent: percent, label: label, for: sessionID ) ?? false if didSave { reloadChargedDevices() } return didSave } func batteryCheckpointPlausibilityWarning( percent: Double, for sessionID: UUID ) -> BatteryCheckpointPlausibilityWarning? { guard let session = chargeSessionSummary(id: sessionID) else { return nil } return batteryCheckpointPlausibilityWarning(percent: percent, for: session) } @discardableResult func deleteBatteryCheckpoint(checkpointID: UUID, for sessionID: UUID) -> Bool { let didDelete = chargeInsightsStore?.deleteBatteryCheckpoint( id: checkpointID, from: sessionID ) ?? false if didDelete { reloadChargedDevices() } return didDelete } @discardableResult func flushChargeInsights() -> Bool { let didSave = chargeInsightsStore?.flushPendingChanges() ?? false reloadChargedDevices() return didSave } @discardableResult func setTargetBatteryPercent(_ percent: Double?, for meter: Meter) -> Bool { guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else { return false } return setTargetBatteryPercent(percent, for: activeSession.id) } @discardableResult func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool { if percent != nil { chargeNotificationCoordinator.ensureAuthorizationIfNeeded() } let didSave = chargeInsightsStore?.setTargetBatteryPercent(percent, for: sessionID) ?? false if didSave { reloadChargedDevices() } return didSave } @discardableResult func confirmChargeSessionCompletion(sessionID: UUID) -> Bool { let didSave = chargeInsightsStore?.confirmCompletion(for: sessionID) ?? false if didSave { reloadChargedDevices() } return didSave } @discardableResult func continueChargeSessionMonitoring(sessionID: UUID) -> Bool { let didSave = chargeInsightsStore?.continueMonitoringDespiteCompletionContradiction(for: sessionID) ?? false if didSave { reloadChargedDevices() } return didSave } @discardableResult func deleteChargeSession(sessionID: UUID) -> Bool { let deletedSession = chargedDevices .flatMap(\.sessions) .first(where: { $0.id == sessionID }) let didDelete = chargeInsightsStore?.deleteChargeSession(id: sessionID) ?? false guard didDelete else { return false } if deletedSession?.status.isOpen == true, let meterMACAddress = deletedSession?.meterMACAddress, let liveMeter = meter(for: meterMACAddress) { liveMeter.resetChargeRecord() } reloadChargedDevices() return true } @discardableResult func deleteChargedDevice(id: UUID) -> Bool { let deletedDevice = chargedDeviceSummary(id: id) let didDelete = chargeInsightsStore?.deleteChargedDevice(id: id) ?? false guard didDelete else { return false } if deletedDevice?.isCharger == false, deletedDevice?.activeSession?.status.isOpen == true, let meterMACAddress = deletedDevice?.activeSession?.meterMACAddress, let liveMeter = meter(for: meterMACAddress) { liveMeter.resetChargeRecord() } reloadChargedDevices() return true } @discardableResult func createKnownMeter( macAddress: String, customName: String?, modelName: String, advertisedName: String? ) -> Bool { let normalizedMAC = Self.normalizedMACAddress(macAddress) guard Self.isValidMACAddress(normalizedMAC) else { return false } registerMeter(macAddress: normalizedMAC, modelName: modelName, advertisedName: advertisedName) if let customName = customName?.trimmingCharacters(in: .whitespacesAndNewlines), !customName.isEmpty { setMeterName(customName, for: normalizedMAC) } noteMeterSeen(at: Date(), macAddress: normalizedMAC) return true } @discardableResult func deleteMeter(macAddress: String) -> Bool { let normalizedMAC = Self.normalizedMACAddress(macAddress) guard Self.isValidMACAddress(normalizedMAC) else { return false } for meter in meters.values where meter.btSerial.macAddress.description == normalizedMAC { meter.disconnect() } meters = meters.filter { element in element.value.btSerial.macAddress.description != normalizedMAC } let didDelete = meterStore.remove(macAddress: normalizedMAC) if didDelete { scheduleObjectWillChange() } return didDelete } var meterSummaries: [MeterSummary] { let liveMetersByMAC = Dictionary(uniqueKeysWithValues: meters.values.map { ($0.btSerial.macAddress.description, $0) }) let recordsByMAC = Dictionary(uniqueKeysWithValues: meterStore.allRecords().map { ($0.macAddress, $0) }) let macAddresses = Set(recordsByMAC.keys).union(liveMetersByMAC.keys) return macAddresses.map { macAddress in let liveMeter = liveMetersByMAC[macAddress] let record = recordsByMAC[macAddress] return MeterSummary( macAddress: macAddress, displayName: Self.friendlyDisplayName(liveMeter: liveMeter, record: record), modelSummary: liveMeter?.deviceModelSummary ?? record?.modelName ?? "Meter", advertisedName: liveMeter?.modelString ?? record?.advertisedName, lastSeen: liveMeter?.lastSeen ?? record?.lastSeen, lastConnected: liveMeter?.lastConnectedAt ?? record?.lastConnected, meter: liveMeter ) } .sorted { lhs, rhs in if lhs.meter != nil && rhs.meter == nil { return true } if lhs.meter == nil && rhs.meter != nil { return false } let byName = lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) if byName != .orderedSame { return byName == .orderedAscending } return lhs.macAddress < rhs.macAddress } } private func scheduleObjectWillChange() { DispatchQueue.main.async { [weak self] in self?.objectWillChange.send() } } private func reloadChargedDevices() { chargedDevices = chargeInsightsStore?.fetchChargedDeviceSummaries() ?? [] chargeNotificationCoordinator.process(chargedDevices: deviceSummaries) for meter in meters.values { restoreChargeMonitoringStateIfNeeded(for: meter) } } private func meter(for meterMACAddress: String) -> Meter? { meters.values.first { meter in meter.btSerial.macAddress.description == meterMACAddress } } private func refreshMeterMetadata() { DispatchQueue.main.async { [weak self] in guard let self else { return } var didUpdateAnyMeter = false for meter in self.meters.values { let mac = meter.btSerial.macAddress.description let displayName = self.meterName(for: mac) ?? mac if meter.name != displayName { meter.updateNameFromStore(displayName) didUpdateAnyMeter = true } let previousTemperaturePreference = meter.tc66TemperatureUnitPreference meter.reloadTemperatureUnitPreference() if meter.tc66TemperatureUnitPreference != previousTemperaturePreference { didUpdateAnyMeter = true } } if didUpdateAnyMeter { self.scheduleObjectWillChange() } } } private func batteryCheckpointPlausibilityWarning( percent: Double, for session: ChargeSessionSummary ) -> BatteryCheckpointPlausibilityWarning? { guard percent.isFinite, percent >= 0, percent <= 100 else { return nil } let sortedCheckpoints = session.checkpoints.sorted { lhs, rhs in if lhs.timestamp != rhs.timestamp { return lhs.timestamp < rhs.timestamp } if lhs.measuredEnergyWh != rhs.measuredEnergyWh { return lhs.measuredEnergyWh < rhs.measuredEnergyWh } return lhs.id.uuidString < rhs.id.uuidString } if let lastCheckpoint = sortedCheckpoints.last, percent < lastCheckpoint.batteryPercent - 1.5 { return BatteryCheckpointPlausibilityWarning( title: "Checkpoint Goes Backwards", message: "The latest checkpoint is \(lastCheckpoint.batteryPercent.format(decimalDigits: 0))% at \(lastCheckpoint.timestamp.format()). A new value of \(percent.format(decimalDigits: 0))% is unexpectedly lower while the session is still charging." ) } guard let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID), let prediction = chargedDevice.batteryLevelPrediction(for: session) else { return nil } let predictionGap = percent - prediction.predictedPercent guard abs(predictionGap) >= 4 else { return nil } let direction = predictionGap > 0 ? "above" : "below" let gapText = abs(predictionGap).format(decimalDigits: 0) let predictedText = prediction.predictedPercent.format(decimalDigits: 0) if let lastCheckpoint = sortedCheckpoints.last { let effectiveEnergyWh = session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0) return BatteryCheckpointPlausibilityWarning( title: "Checkpoint Looks Implausible", message: "This value is about \(gapText) percentage points \(direction) the predicted \(predictedText)% based on \(prediction.anchorDescription). Since the last checkpoint at \(lastCheckpoint.batteryPercent.format(decimalDigits: 0))%, only \(energyDeltaWh.format(decimalDigits: 2)) Wh were added." ) } return BatteryCheckpointPlausibilityWarning( title: "Checkpoint Looks Implausible", message: "This value is about \(gapText) percentage points \(direction) the predicted \(predictedText)% based on \(prediction.anchorDescription). Save it only if the device gauge really jumped that much." ) } private func displayedSessionEnergyWh(for session: ChargeSessionSummary, on meter: Meter) -> Double { let storedEnergyWh = session.effectiveOrMeasuredEnergyWh guard session.status.isOpen else { return storedEnergyWh } guard session.meterMACAddress == meter.btSerial.macAddress.description else { return storedEnergyWh } if let baselineEnergyWh = session.meterEnergyBaselineWh { return max(storedEnergyWh, max(meter.recordedWH - baselineEnergyWh, 0)) } return storedEnergyWh } private func displayedSessionChargeAh(for session: ChargeSessionSummary, on meter: Meter) -> Double { let storedChargeAh = session.measuredChargeAh guard session.status.isOpen else { return storedChargeAh } guard session.meterMACAddress == meter.btSerial.macAddress.description else { return storedChargeAh } if let baselineChargeAh = session.meterChargeBaselineAh { return max(storedChargeAh, max(meter.recordedAH - baselineChargeAh, 0)) } return storedChargeAh } } extension AppData.MeterSummary { var tint: Color { switch modelSummary { case "UM25C": return .blue case "UM34C": return .yellow case "TC66C": return Model.TC66C.color default: return .secondary } } } extension AppData { static func friendlyDisplayName(liveMeter: Meter?, record: MeterNameStore.Record?) -> String { if let liveName = liveMeter?.name.trimmingCharacters(in: .whitespacesAndNewlines), !liveName.isEmpty { return liveName } if let customName = record?.customName { return customName } if let advertisedName = record?.advertisedName { return advertisedName } if let recordModel = record?.modelName { return recordModel } if let liveModel = liveMeter?.deviceModelSummary { return liveModel } return "Meter" } static func normalizedMACAddress(_ macAddress: String) -> String { macAddress .trimmingCharacters(in: .whitespacesAndNewlines) .uppercased() } static func isValidMACAddress(_ macAddress: String) -> Bool { macAddress.range( of: #"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$"#, options: .regularExpression ) != nil } } private final class ChargeNotificationCoordinator { private struct Payload { let id: String let title: String let body: String let threadIdentifier: String } private let notificationCenter = UNUserNotificationCenter.current() private let deliveredIDsDefaultsKey = "ChargeNotificationCoordinator.deliveredIDs" private let eventRecencyWindow: TimeInterval = 24 * 60 * 60 private var inFlightEventIDs: Set = [] func ensureAuthorizationIfNeeded() { notificationCenter.getNotificationSettings { [weak self] settings in guard settings.authorizationStatus == .notDetermined else { return } self?.notificationCenter.requestAuthorization(options: [.alert, .badge, .sound]) { _, error in if let error { track("Notification authorization request failed: \(error.localizedDescription)") } } } } func process(chargedDevices: [ChargedDeviceSummary]) { let now = Date() let pendingPayloads = chargedDevices.flatMap { chargedDevice in payloads(for: chargedDevice, now: now) } for payload in pendingPayloads { scheduleIfNeeded(payload) } } private func payloads(for chargedDevice: ChargedDeviceSummary, now: Date) -> [Payload] { chargedDevice.sessions.compactMap { session in if let triggeredAt = session.targetBatteryAlertTriggeredAt, now.timeIntervalSince(triggeredAt) <= eventRecencyWindow, let targetBatteryPercent = session.targetBatteryPercent { let estimatedPercent = chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent ?? session.endBatteryPercent ?? targetBatteryPercent return Payload( id: "target-\(session.id.uuidString)-\(Int(triggeredAt.timeIntervalSince1970))", title: "Battery target reached", body: "\(chargedDevice.name) reached about \(estimatedPercent.format(decimalDigits: 0))% on \(session.meterName ?? "USB Meter") (target \(targetBatteryPercent.format(decimalDigits: 0))%).", threadIdentifier: session.id.uuidString ) } if session.requiresCompletionConfirmation, let requestedAt = session.completionConfirmationRequestedAt, now.timeIntervalSince(requestedAt) <= eventRecencyWindow { let estimatedPercent = session.completionContradictionPercent ?? chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent let bodyPrefix = "\(chargedDevice.name) may have stopped too early on \(session.meterName ?? "USB Meter")." let detail = estimatedPercent.map { " Estimated battery is only \($0.format(decimalDigits: 0))%." } ?? "" return Payload( id: "completion-\(session.id.uuidString)-\(Int(requestedAt.timeIntervalSince1970))", title: "Confirm charge completion", body: bodyPrefix + detail, threadIdentifier: session.id.uuidString ) } return nil } } private func scheduleIfNeeded(_ payload: Payload) { guard deliveredEventIDs().contains(payload.id) == false else { return } guard inFlightEventIDs.contains(payload.id) == false else { return } inFlightEventIDs.insert(payload.id) notificationCenter.getNotificationSettings { [weak self] settings in guard let self else { return } guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else { DispatchQueue.main.async { self.inFlightEventIDs.remove(payload.id) } return } let content = UNMutableNotificationContent() content.title = payload.title content.body = payload.body content.sound = .default content.threadIdentifier = payload.threadIdentifier let request = UNNotificationRequest( identifier: payload.id, content: content, trigger: nil ) self.notificationCenter.add(request) { error in DispatchQueue.main.async { self.inFlightEventIDs.remove(payload.id) if let error { track("Failed scheduling local notification: \(error.localizedDescription)") return } self.storeDeliveredEventID(payload.id) } } } } private func deliveredEventIDs() -> Set { let values = UserDefaults.standard.stringArray(forKey: deliveredIDsDefaultsKey) ?? [] return Set(values) } private func storeDeliveredEventID(_ id: String) { var values = deliveredEventIDs() values.insert(id) UserDefaults.standard.set(Array(values), forKey: deliveredIDsDefaultsKey) } }