Newer Older
1633 lines | 60.113kb
Bogdan Timofte authored 2 months ago
1
//
2
//  DataStore.swift
3
//  USB Meter
4
//
5
//  Created by Bogdan Timofte on 03/03/2020.
6
//  Copyright © 2020 Bogdan Timofte. All rights reserved.
7
//
8

            
9
import SwiftUI
10
import Combine
11
import CoreBluetooth
Bogdan Timofte authored a month ago
12
import CoreData
13
import UserNotifications
Bogdan Timofte authored 2 months ago
14

            
Bogdan Timofte authored a month ago
15
struct BatteryCheckpointPlausibilityWarning: Identifiable, Hashable {
16
    let title: String
17
    let message: String
18

            
19
    var id: String {
20
        "\(title)\n\(message)"
21
    }
22
}
23

            
Bogdan Timofte authored 2 months ago
24
final class AppData : ObservableObject {
Bogdan Timofte authored 2 months ago
25
    struct MeterSummary: Identifiable {
Bogdan Timofte authored 2 months ago
26
        let macAddress: String
27
        let displayName: String
28
        let modelSummary: String
29
        let advertisedName: String?
30
        let lastSeen: Date?
31
        let lastConnected: Date?
32
        let meter: Meter?
33

            
34
        var id: String {
35
            macAddress
36
        }
37
    }
38

            
Bogdan Timofte authored 2 months ago
39
    private var bluetoothManagerNotification: AnyCancellable?
Bogdan Timofte authored 2 months ago
40
    private var meterStoreObserver: AnyCancellable?
41
    private var meterStoreCloudObserver: AnyCancellable?
Bogdan Timofte authored a month ago
42
    private var chargeInsightsStoreObserver: AnyCancellable?
43
    private var chargeInsightsRemoteObserver: AnyCancellable?
Bogdan Timofte authored a month ago
44
    private var chargerStandbyPowerStoreObserver: AnyCancellable?
Bogdan Timofte authored a month ago
45
    private var consumptionMonitorStoreObserver: AnyCancellable?
Bogdan Timofte authored a month ago
46
    private var pendingChargedDevicesReloadWorkItem: DispatchWorkItem?
Bogdan Timofte authored a month ago
47
    private var chargeInsightsReadStore: ChargeInsightsStore?
Bogdan Timofte authored a month ago
48
    private var pendingChargeObservationSnapshots: [String: ChargingMonitorSnapshot] = [:]
49
    private var pendingChargeObservationWorkItems: [String: DispatchWorkItem] = [:]
Bogdan Timofte authored a month ago
50
    private let chargedDevicesReloadQueue = DispatchQueue(
51
        label: "ro.xdev.usb-meter.charged-devices-reload",
52
        qos: .userInitiated
53
    )
Bogdan Timofte authored a month ago
54
    private var chargedDevicesReloadInFlight = false
55
    private var chargedDevicesReloadPending = false
Bogdan Timofte authored a month ago
56
    private let chargeObservationPersistInterval: TimeInterval = 30
57
    private let meterPresencePersistInterval: TimeInterval = 15
Bogdan Timofte authored 2 months ago
58
    private let meterStore = MeterNameStore.shared
Bogdan Timofte authored a month ago
59
    private var chargeInsightsStore: ChargeInsightsStore?
Bogdan Timofte authored a month ago
60
    private let chargerStandbyPowerStore = ChargerStandbyPowerStore()
Bogdan Timofte authored a month ago
61
    private let consumptionMonitorStore = ConsumptionMonitorStore()
Bogdan Timofte authored a month ago
62
    private let chargeNotificationCoordinator = ChargeNotificationCoordinator()
Bogdan Timofte authored a month ago
63
    private var meterSummariesCache: (version: Int, summaries: [MeterSummary])?
64
    private var meterSummariesVersion: Int = 0
Bogdan Timofte authored 2 months ago
65

            
Bogdan Timofte authored 2 months ago
66
    init() {
Bogdan Timofte authored 2 months ago
67
        bluetoothManagerNotification = bluetoothManager.objectWillChange.sink { [weak self] _ in
Bogdan Timofte authored 2 months ago
68
            self?.scheduleObjectWillChange()
Bogdan Timofte authored 2 months ago
69
        }
Bogdan Timofte authored 2 months ago
70
        meterStoreObserver = NotificationCenter.default.publisher(for: .meterNameStoreDidChange)
71
            .receive(on: DispatchQueue.main)
72
            .sink { [weak self] _ in
Bogdan Timofte authored a month ago
73
                self?.invalidateMeterSummaries()
Bogdan Timofte authored 2 months ago
74
                self?.refreshMeterMetadata()
Bogdan Timofte authored a month ago
75
                self?.scheduleObjectWillChange()
Bogdan Timofte authored 2 months ago
76
            }
77
        meterStoreCloudObserver = NotificationCenter.default.publisher(for: .meterNameStoreCloudStatusDidChange)
78
            .receive(on: DispatchQueue.main)
79
            .sink { [weak self] _ in
80
                self?.scheduleObjectWillChange()
81
            }
Bogdan Timofte authored a month ago
82
        chargerStandbyPowerStoreObserver = NotificationCenter.default.publisher(for: .chargerStandbyPowerStoreDidChange)
83
            .receive(on: DispatchQueue.main)
84
            .sink { [weak self] _ in
85
                self?.reloadChargedDevices()
86
            }
Bogdan Timofte authored a month ago
87
        consumptionMonitorStoreObserver = NotificationCenter.default.publisher(for: .consumptionMonitorStoreDidChange)
88
            .receive(on: DispatchQueue.main)
89
            .sink { [weak self] _ in
90
                self?.reloadChargedDevices()
91
            }
Bogdan Timofte authored 2 months ago
92
    }
Bogdan Timofte authored 2 months ago
93

            
Bogdan Timofte authored 2 months ago
94
    let bluetoothManager = BluetoothManager()
Bogdan Timofte authored 2 months ago
95

            
Bogdan Timofte authored 2 months ago
96
    @Published var enableRecordFeature: Bool = true
Bogdan Timofte authored 2 months ago
97

            
Bogdan Timofte authored a month ago
98
    @Published var meters: [UUID:Meter] = [UUID:Meter]() {
99
        didSet {
100
            invalidateMeterSummaries()
101
        }
102
    }
Bogdan Timofte authored a month ago
103
    @Published private(set) var chargedDevices: [ChargedDeviceSummary] = []
Bogdan Timofte authored a month ago
104
    @Published private(set) var powerbanks: [PowerbankSummary] = []
Bogdan Timofte authored a month ago
105
    @Published private(set) var activeChargerStandbySessions: [String: ChargerStandbyPowerMonitorSession] = [:]
Bogdan Timofte authored a month ago
106
    @Published private(set) var activeConsumptionSessions: [String: ConsumptionMonitorLiveSession] = [:]
Bogdan Timofte authored a month ago
107

            
108
    var deviceSummaries: [ChargedDeviceSummary] {
109
        chargedDevices.filter { !$0.isCharger }
110
    }
111

            
112
    var chargerSummaries: [ChargedDeviceSummary] {
113
        chargedDevices.filter { $0.isCharger }
114
    }
Bogdan Timofte authored 2 months ago
115

            
Bogdan Timofte authored a month ago
116
    var powerbankSummaries: [PowerbankSummary] {
117
        powerbanks
118
    }
119

            
Bogdan Timofte authored 2 months ago
120
    var cloudAvailability: MeterNameStore.CloudAvailability {
121
        meterStore.currentCloudAvailability
122
    }
123

            
Bogdan Timofte authored a month ago
124
    func activateChargeInsights(context: NSManagedObjectContext) {
125
        guard chargeInsightsStore == nil else {
126
            return
127
        }
128

            
129
        context.automaticallyMergesChangesFromParent = true
Bogdan Timofte authored a month ago
130
        context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
Bogdan Timofte authored a month ago
131
        if let coordinator = context.persistentStoreCoordinator {
Bogdan Timofte authored a month ago
132
            let writeContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
133
            writeContext.persistentStoreCoordinator = coordinator
134
            writeContext.automaticallyMergesChangesFromParent = false
135
            writeContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
136
            chargeInsightsStore = ChargeInsightsStore(context: writeContext)
137

            
Bogdan Timofte authored a month ago
138
            let readContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
139
            readContext.persistentStoreCoordinator = coordinator
140
            readContext.automaticallyMergesChangesFromParent = true
141
            readContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
142
            chargeInsightsReadStore = ChargeInsightsStore(context: readContext)
143

            
Bogdan Timofte authored a month ago
144
            chargeInsightsStoreObserver = NotificationCenter.default.publisher(
145
                for: .NSManagedObjectContextDidSave,
146
                object: writeContext
147
            )
148
            .sink { [weak self, weak context] notification in
149
                guard let self, let context else { return }
150
                context.perform {
151
                    context.mergeChanges(fromContextDidSave: notification)
152
                    DispatchQueue.main.async {
153
                        self.scheduleChargedDevicesReload()
154
                    }
155
                }
156
            }
157
        } else {
158
            chargeInsightsStore = ChargeInsightsStore(context: context)
159
            chargeInsightsReadStore = ChargeInsightsStore(context: context)
160

            
161
            chargeInsightsStoreObserver = NotificationCenter.default.publisher(
162
                for: .NSManagedObjectContextDidSave,
163
                object: context
164
            )
165
            .receive(on: DispatchQueue.main)
166
            .sink { [weak self] _ in
167
                self?.scheduleChargedDevicesReload()
168
            }
Bogdan Timofte authored a month ago
169
        }
170

            
171
        chargeInsightsRemoteObserver = NotificationCenter.default.publisher(
172
            for: .NSPersistentStoreRemoteChange,
173
            object: nil
174
        )
175
        .receive(on: DispatchQueue.main)
176
        .sink { [weak self] _ in
Bogdan Timofte authored a month ago
177
            self?.scheduleChargedDevicesReload()
Bogdan Timofte authored a month ago
178
        }
179

            
180
        chargeNotificationCoordinator.ensureAuthorizationIfNeeded()
Bogdan Timofte authored a month ago
181
        seedDeviceProfilesCatalogIfNeeded()
182
        migrateDeviceProfilesIfNeeded()
Bogdan Timofte authored a month ago
183
        reloadChargedDevices()
184
    }
185

            
Bogdan Timofte authored a month ago
186
    private static let cloudProfileSeedVersionKey = "cloudProfileSeedVersion"
187
    private static let currentCloudProfileSeedVersion: Int = 1
188
    private static let cloudDeviceProfileMigrationVersionKey = "cloudDeviceProfileMigrationVersion"
189
    private static let currentCloudDeviceProfileMigrationVersion: Int = 1
190

            
191
    private func seedDeviceProfilesCatalogIfNeeded() {
192
        let defaults = UserDefaults.standard
193
        let installed = defaults.integer(forKey: AppData.cloudProfileSeedVersionKey)
194
        guard installed < AppData.currentCloudProfileSeedVersion else { return }
195

            
196
        let catalog = DeviceProfileCatalog.shared.profiles
197
        guard catalog.isEmpty == false else { return }
198

            
199
        if chargeInsightsStore?.seedDeviceProfilesCatalog(catalog) == true {
200
            defaults.set(AppData.currentCloudProfileSeedVersion, forKey: AppData.cloudProfileSeedVersionKey)
201
        }
202
    }
203

            
204
    private func migrateDeviceProfilesIfNeeded() {
205
        let defaults = UserDefaults.standard
206
        let installed = defaults.integer(forKey: AppData.cloudDeviceProfileMigrationVersionKey)
207
        guard installed < AppData.currentCloudDeviceProfileMigrationVersion else { return }
208

            
209
        if chargeInsightsStore?.migrateDevicesToProfiles() == true {
210
            defaults.set(AppData.currentCloudDeviceProfileMigrationVersion, forKey: AppData.cloudDeviceProfileMigrationVersionKey)
211
        }
212
    }
213

            
Bogdan Timofte authored 2 months ago
214
    func meterName(for macAddress: String) -> String? {
215
        meterStore.name(for: macAddress)
216
    }
217

            
218
    func setMeterName(_ name: String, for macAddress: String) {
219
        meterStore.upsert(macAddress: macAddress, name: name, temperatureUnitRawValue: nil)
220
    }
221

            
222
    func temperatureUnitPreference(for macAddress: String) -> TemperatureUnitPreference {
223
        let rawValue = meterStore.temperatureUnitRawValue(for: macAddress) ?? TemperatureUnitPreference.celsius.rawValue
224
        return TemperatureUnitPreference(rawValue: rawValue) ?? .celsius
225
    }
226

            
227
    func setTemperatureUnitPreference(_ preference: TemperatureUnitPreference, for macAddress: String) {
228
        meterStore.upsert(macAddress: macAddress, name: nil, temperatureUnitRawValue: preference.rawValue)
Bogdan Timofte authored 2 months ago
229
    }
Bogdan Timofte authored 2 months ago
230

            
Bogdan Timofte authored 2 months ago
231
    func registerMeter(macAddress: String, modelName: String?, advertisedName: String?) {
232
        meterStore.registerMeter(macAddress: macAddress, modelName: modelName, advertisedName: advertisedName)
Bogdan Timofte authored 2 months ago
233
    }
234

            
235
    func noteMeterSeen(at date: Date, macAddress: String) {
Bogdan Timofte authored a month ago
236
        if let persistedLastSeen = meterStore.lastSeen(for: macAddress),
237
           date.timeIntervalSince(persistedLastSeen) < meterPresencePersistInterval {
238
            return
239
        }
Bogdan Timofte authored 2 months ago
240
        meterStore.noteLastSeen(date, for: macAddress)
241
    }
242

            
243
    func noteMeterConnected(at date: Date, macAddress: String) {
244
        meterStore.noteLastConnected(date, for: macAddress)
245
    }
246

            
247
    func lastSeen(for macAddress: String) -> Date? {
248
        meterStore.lastSeen(for: macAddress)
249
    }
250

            
251
    func lastConnected(for macAddress: String) -> Date? {
252
        meterStore.lastConnected(for: macAddress)
253
    }
254

            
Bogdan Timofte authored a month ago
255
    func chargedDeviceSummary(id: UUID) -> ChargedDeviceSummary? {
256
        chargedDevices.first(where: { $0.id == id })
257
    }
258

            
Bogdan Timofte authored a month ago
259
    func chargeSessionSummary(id: UUID) -> ChargeSessionSummary? {
260
        for chargedDevice in chargedDevices {
261
            if let session = chargedDevice.sessions.first(where: { $0.id == id }) {
262
                return session
263
            }
264
        }
Bogdan Timofte authored a month ago
265
        for powerbank in powerbanks {
266
            if let session = (powerbank.sessionsAsSubject + powerbank.sessionsAsSource).first(where: { $0.id == id }) {
267
                return session
268
            }
269
        }
Bogdan Timofte authored a month ago
270
        return nil
271
    }
272

            
Bogdan Timofte authored a month ago
273
    func chargedDevices(for meterMACAddress: String) -> [ChargedDeviceSummary] {
274
        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
275
        return chargedDevices.filter { chargedDevice in
276
            guard chargedDevice.isCharger == false else {
277
                return false
278
            }
Bogdan Timofte authored a month ago
279
            return chargedDevice.sessions.contains(where: { $0.meterMACAddress == normalizedMAC })
Bogdan Timofte authored a month ago
280
        }
281
    }
282

            
283
    func chargers(for meterMACAddress: String) -> [ChargedDeviceSummary] {
284
        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
285
        return chargedDevices.filter { chargedDevice in
286
            guard chargedDevice.isCharger else {
287
                return false
288
            }
Bogdan Timofte authored a month ago
289
            return chargedDevice.sessions.contains(where: { $0.meterMACAddress == normalizedMAC })
Bogdan Timofte authored a month ago
290
        }
291
    }
292

            
293
    func activeChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? {
Bogdan Timofte authored a month ago
294
        let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
295

            
Bogdan Timofte authored a month ago
296
        if expireOverlongChargeSessionsIfNeeded() {
297
            reloadChargedDevices()
298
            return chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: normalizedMAC)
299
        }
300

            
Bogdan Timofte authored a month ago
301
        if let cachedSummary = cachedActiveChargeSessionSummary(for: normalizedMAC) {
Bogdan Timofte authored a month ago
302
            if let persistedSummary = chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: normalizedMAC),
303
               persistedSummary.aggregatedSamples.count > cachedSummary.aggregatedSamples.count {
304
                return persistedSummary
305
            }
Bogdan Timofte authored a month ago
306
            return cachedSummary
307
        }
Bogdan Timofte authored a month ago
308
        return chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: normalizedMAC)
Bogdan Timofte authored a month ago
309
    }
310

            
Bogdan Timofte authored a month ago
311
    func chargerStandbyMeasurementSession(for meterMACAddress: String) -> ChargerStandbyPowerMonitorSession? {
312
        activeChargerStandbySessions[Self.normalizedMACAddress(meterMACAddress)]
313
    }
314

            
315
    @discardableResult
316
    func startChargerStandbyMeasurement(for chargerID: UUID, on meter: Meter) -> Bool {
317
        guard chargedDeviceSummary(id: chargerID)?.isCharger == true else {
318
            return false
319
        }
320

            
321
        let normalizedMAC = Self.normalizedMACAddress(meter.btSerial.macAddress.description)
322
        if let existingSession = activeChargerStandbySessions[normalizedMAC] {
323
            return existingSession.chargerID == chargerID
324
        }
325

            
326
        let session = ChargerStandbyPowerMonitorSession(chargerID: chargerID, meter: meter)
327
        session.onChange = { [weak self] in
328
            self?.scheduleObjectWillChange()
329
        }
330
        session.onStabilized = { [weak self, weak session] in
331
            guard let self, let session else { return }
332
            self.notifyChargerStandbyMeasurementReady(for: session)
333
        }
334

            
335
        activeChargerStandbySessions[normalizedMAC] = session
336
        session.start()
337

            
338
        // Starting a standby run on an available meter should also initiate the BLE link.
339
        if meter.operationalState == .peripheralNotConnected {
340
            meter.connect()
341
        }
342

            
343
        scheduleObjectWillChange()
344
        return true
345
    }
346

            
347
    @discardableResult
348
    func finishChargerStandbyMeasurement(for meterMACAddress: String, save: Bool) -> Bool {
349
        let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
350
        guard let session = activeChargerStandbySessions[normalizedMAC] else {
351
            return false
352
        }
353

            
354
        session.stop()
355

            
356
        guard save else {
357
            activeChargerStandbySessions[normalizedMAC] = nil
358
            scheduleObjectWillChange()
359
            return true
360
        }
361

            
362
        guard let summary = session.makeSummary() else {
363
            scheduleObjectWillChange()
364
            return false
365
        }
366

            
367
        let didSave = chargerStandbyPowerStore.save(summary)
368
        if didSave {
369
            activeChargerStandbySessions[normalizedMAC] = nil
370
            reloadChargedDevices()
371
        } else {
372
            scheduleObjectWillChange()
373
        }
374

            
375
        return didSave
376
    }
377

            
Bogdan Timofte authored a month ago
378
    @discardableResult
379
    func deleteChargerStandbyMeasurement(id: UUID, chargerID: UUID) -> Bool {
380
        let didDelete = chargerStandbyPowerStore.removeMeasurement(id: id, chargerID: chargerID)
381
        if didDelete {
382
            reloadChargedDevices()
383
        } else {
384
            scheduleObjectWillChange()
385
        }
386
        return didDelete
387
    }
388

            
Bogdan Timofte authored a month ago
389
    // MARK: - Consumption Monitor
390

            
391
    func consumptionMonitorSession(for meterMACAddress: String) -> ConsumptionMonitorLiveSession? {
392
        activeConsumptionSessions[Self.normalizedMACAddress(meterMACAddress)]
393
    }
394

            
395
    @discardableResult
396
    func startConsumptionMonitor(for deviceID: UUID, on meter: Meter) -> Bool {
397
        let normalizedMAC = Self.normalizedMACAddress(meter.btSerial.macAddress.description)
398
        if let existing = activeConsumptionSessions[normalizedMAC] {
399
            return existing.chargedDeviceID == deviceID
400
        }
401

            
402
        let sessionID = UUID()
403
        let now = Date()
404
        let session = ConsumptionMonitorLiveSession(
405
            sessionID: sessionID,
406
            chargedDeviceID: deviceID,
407
            meterMACAddress: meter.btSerial.macAddress.description,
408
            startedAt: now
409
        )
410

            
411
        let meterSummary = meterSummaries.first { $0.macAddress == meter.btSerial.macAddress.description }
412
        session.meterName = meterSummary?.displayName
413
        session.meterModel = meterSummary?.modelSummary
414

            
415
        session.onChange = { [weak self] in
416
            self?.scheduleObjectWillChange()
417
        }
418
        session.onSample = { [weak self, weak session] sample in
419
            guard let self, let session else { return }
420
            self.consumptionMonitorStore.appendSample(sample, to: session.sessionID)
421
        }
422

            
423
        let initialRecord = ConsumptionMonitorSessionSummary(
424
            id: sessionID,
425
            chargedDeviceID: deviceID,
426
            meterMACAddress: meter.btSerial.macAddress.description,
427
            meterName: session.meterName,
428
            meterModel: session.meterModel,
429
            startedAt: now,
430
            endedAt: nil,
431
            samples: []
432
        )
433
        consumptionMonitorStore.save(initialRecord)
434

            
435
        activeConsumptionSessions[normalizedMAC] = session
436
        session.start()
437

            
438
        if meter.operationalState == .peripheralNotConnected {
439
            meter.connect()
440
        }
441

            
442
        reloadChargedDevices()
443
        return true
444
    }
445

            
446
    @discardableResult
447
    func stopConsumptionMonitor(for meterMACAddress: String, save: Bool) -> Bool {
448
        let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
449
        guard let session = activeConsumptionSessions[normalizedMAC] else { return false }
450

            
451
        session.stop()
452
        activeConsumptionSessions[normalizedMAC] = nil
453

            
454
        if save {
455
            consumptionMonitorStore.completeSession(id: session.sessionID, endedAt: Date())
456
        } else {
457
            consumptionMonitorStore.removeSession(id: session.sessionID, deviceID: session.chargedDeviceID)
458
        }
459

            
460
        reloadChargedDevices()
461
        return true
462
    }
463

            
464
    @discardableResult
465
    func deleteConsumptionSession(id: UUID, deviceID: UUID) -> Bool {
466
        let didDelete = consumptionMonitorStore.removeSession(id: id, deviceID: deviceID)
467
        if didDelete { reloadChargedDevices() }
468
        return didDelete
469
    }
470

            
Bogdan Timofte authored a month ago
471
    @discardableResult
Bogdan Timofte authored a month ago
472
    func createDevice(
Bogdan Timofte authored a month ago
473
        name: String,
474
        deviceClass: ChargedDeviceClass,
Bogdan Timofte authored a month ago
475
        templateID: String?,
Bogdan Timofte authored a month ago
476
        profileID: String? = nil,
477
        hasInternalSubject: Bool = false,
Bogdan Timofte authored a month ago
478
        chargingStateAvailability: ChargingStateAvailability,
Bogdan Timofte authored a month ago
479
        supportsWiredCharging: Bool,
480
        supportsWirelessCharging: Bool,
481
        wirelessChargingProfile: WirelessChargingProfile,
Bogdan Timofte authored a month ago
482
        configuredCompletionCurrents: [ChargeSessionKind: Double],
Bogdan Timofte authored a month ago
483
        notes: String?
Bogdan Timofte authored a month ago
484
    ) -> Bool {
Bogdan Timofte authored a month ago
485
        let didSave = chargeInsightsStore?.createDevice(
Bogdan Timofte authored a month ago
486
            name: name,
487
            deviceClass: deviceClass,
Bogdan Timofte authored a month ago
488
            templateID: templateID,
Bogdan Timofte authored a month ago
489
            profileID: profileID,
490
            hasInternalSubject: hasInternalSubject,
Bogdan Timofte authored a month ago
491
            chargingStateAvailability: chargingStateAvailability,
Bogdan Timofte authored a month ago
492
            supportsWiredCharging: supportsWiredCharging,
493
            supportsWirelessCharging: supportsWirelessCharging,
494
            wirelessChargingProfile: wirelessChargingProfile,
Bogdan Timofte authored a month ago
495
            configuredCompletionCurrents: configuredCompletionCurrents,
Bogdan Timofte authored a month ago
496
            notes: notes
Bogdan Timofte authored a month ago
497
        ) ?? false
498

            
499
        if didSave {
500
            reloadChargedDevices()
501
        }
502

            
503
        return didSave
504
    }
505

            
506
    @discardableResult
Bogdan Timofte authored a month ago
507
    func createCharger(
508
        name: String,
Bogdan Timofte authored a month ago
509
        chargerType: ChargerType,
Bogdan Timofte authored a month ago
510
        notes: String?
Bogdan Timofte authored a month ago
511
    ) -> Bool {
512
        let didSave = chargeInsightsStore?.createCharger(
513
            name: name,
Bogdan Timofte authored a month ago
514
            chargerType: chargerType,
Bogdan Timofte authored a month ago
515
            notes: notes
Bogdan Timofte authored a month ago
516
        ) ?? false
517

            
518
        if didSave {
519
            reloadChargedDevices()
520
        }
521

            
522
        return didSave
523
    }
524

            
Bogdan Timofte authored a month ago
525
    @discardableResult
526
    func createPowerbank(
527
        name: String,
528
        templateID: String?,
529
        batteryLevelReporting: BatteryLevelReporting,
530
        batteryBarsCount: Int,
531
        notes: String?
532
    ) -> Bool {
533
        let didSave = chargeInsightsStore?.createPowerbank(
534
            name: name,
535
            templateID: templateID,
536
            batteryLevelReporting: batteryLevelReporting,
537
            batteryBarsCount: batteryBarsCount,
538
            notes: notes
539
        ) ?? false
540

            
541
        if didSave {
542
            reloadChargedDevices()
543
        }
544
        return didSave
545
    }
546

            
547
    @discardableResult
548
    func updatePowerbank(
549
        id: UUID,
550
        name: String,
551
        templateID: String?,
552
        batteryLevelReporting: BatteryLevelReporting,
553
        batteryBarsCount: Int,
554
        notes: String?
555
    ) -> Bool {
556
        let didSave = chargeInsightsStore?.updatePowerbank(
557
            id: id,
558
            name: name,
559
            templateID: templateID,
560
            batteryLevelReporting: batteryLevelReporting,
561
            batteryBarsCount: batteryBarsCount,
562
            notes: notes
563
        ) ?? false
564

            
565
        if didSave {
566
            reloadChargedDevices()
567
        }
568
        return didSave
569
    }
570

            
571
    @discardableResult
572
    func deletePowerbank(id: UUID) -> Bool {
573
        let didSave = chargeInsightsStore?.deletePowerbank(id: id) ?? false
574
        if didSave {
575
            reloadChargedDevices()
576
        }
577
        return didSave
578
    }
579

            
Bogdan Timofte authored a month ago
580
    @discardableResult
581
    func updateDevice(
Bogdan Timofte authored a month ago
582
        id: UUID,
583
        name: String,
584
        deviceClass: ChargedDeviceClass,
Bogdan Timofte authored a month ago
585
        templateID: String?,
Bogdan Timofte authored a month ago
586
        profileID: String? = nil,
587
        hasInternalSubject: Bool = false,
Bogdan Timofte authored a month ago
588
        chargingStateAvailability: ChargingStateAvailability,
Bogdan Timofte authored a month ago
589
        supportsWiredCharging: Bool,
590
        supportsWirelessCharging: Bool,
591
        wirelessChargingProfile: WirelessChargingProfile,
Bogdan Timofte authored a month ago
592
        configuredCompletionCurrents: [ChargeSessionKind: Double],
Bogdan Timofte authored a month ago
593
        notes: String?
594
    ) -> Bool {
Bogdan Timofte authored a month ago
595
        let didSave = chargeInsightsStore?.updateDevice(
Bogdan Timofte authored a month ago
596
            id: id,
597
            name: name,
598
            deviceClass: deviceClass,
Bogdan Timofte authored a month ago
599
            templateID: templateID,
Bogdan Timofte authored a month ago
600
            profileID: profileID,
601
            hasInternalSubject: hasInternalSubject,
Bogdan Timofte authored a month ago
602
            chargingStateAvailability: chargingStateAvailability,
Bogdan Timofte authored a month ago
603
            supportsWiredCharging: supportsWiredCharging,
604
            supportsWirelessCharging: supportsWirelessCharging,
605
            wirelessChargingProfile: wirelessChargingProfile,
Bogdan Timofte authored a month ago
606
            configuredCompletionCurrents: configuredCompletionCurrents,
Bogdan Timofte authored a month ago
607
            notes: notes
608
        ) ?? false
609

            
610
        if didSave {
611
            reloadChargedDevices()
612
        }
613

            
614
        return didSave
615
    }
616

            
617
    @discardableResult
Bogdan Timofte authored a month ago
618
    func updateCharger(
619
        id: UUID,
620
        name: String,
Bogdan Timofte authored a month ago
621
        chargerType: ChargerType,
Bogdan Timofte authored a month ago
622
        notes: String?
623
    ) -> Bool {
624
        let didSave = chargeInsightsStore?.updateCharger(
625
            id: id,
626
            name: name,
Bogdan Timofte authored a month ago
627
            chargerType: chargerType,
Bogdan Timofte authored a month ago
628
            notes: notes
Bogdan Timofte authored a month ago
629
        ) ?? false
630

            
631
        if didSave {
632
            reloadChargedDevices()
633
        }
634

            
635
        return didSave
636
    }
637

            
Bogdan Timofte authored a month ago
638
    func restoreChargeMonitoringStateIfNeeded(for meter: Meter) {
639
        guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
640
            return
641
        }
Bogdan Timofte authored a month ago
642
        guard activeSession.status.isOpen else {
Bogdan Timofte authored a month ago
643
            return
644
        }
645
        meter.restoreChargeMonitoringIfNeeded(from: activeSession)
646
    }
647

            
Bogdan Timofte authored a month ago
648
    @discardableResult
Bogdan Timofte authored a month ago
649
    func startChargeSession(
650
        for meter: Meter,
651
        chargedDeviceID: UUID,
652
        chargerID: UUID?,
Bogdan Timofte authored a month ago
653
        sourcePowerbankID: UUID? = nil,
Bogdan Timofte authored a month ago
654
        chargingTransportMode: ChargingTransportMode,
655
        chargingStateMode: ChargingStateMode,
656
        autoStopEnabled: Bool,
Bogdan Timofte authored a month ago
657
        initialBatteryPercent: Double?,
658
        startsFromFlatBattery: Bool
Bogdan Timofte authored a month ago
659
    ) -> Bool {
Bogdan Timofte authored a month ago
660
        meter.resetMeterCountersForNewSession()
661

            
Bogdan Timofte authored a month ago
662
        guard let snapshot = meter.chargingMonitorSnapshot else {
663
            return false
664
        }
665

            
Bogdan Timofte authored a month ago
666
        let didSave = chargeInsightsStore?.startSession(
667
            for: snapshot,
668
            chargedDeviceID: chargedDeviceID,
669
            chargerID: chargerID,
Bogdan Timofte authored a month ago
670
            sourcePowerbankID: sourcePowerbankID,
Bogdan Timofte authored a month ago
671
            chargingTransportMode: chargingTransportMode,
672
            chargingStateMode: chargingStateMode,
673
            autoStopEnabled: autoStopEnabled,
Bogdan Timofte authored a month ago
674
            initialBatteryPercent: initialBatteryPercent,
675
            startsFromFlatBattery: startsFromFlatBattery
Bogdan Timofte authored a month ago
676
        ) ?? false
677
        if didSave {
Bogdan Timofte authored a month ago
678
            meter.resetChargeRecordGraph()
Bogdan Timofte authored a month ago
679
            let activeSession = chargeInsightsStore?.activeChargeSessionSummary(
680
                forMeterMACAddress: meter.btSerial.macAddress.description
681
            )
682
            if let activeSession,
Bogdan Timofte authored a month ago
683
               meter.supportsRecordingThreshold,
684
               activeSession.stopThresholdAmps > 0 {
685
                meter.recordingTreshold = activeSession.stopThresholdAmps
686
            }
Bogdan Timofte authored a month ago
687
            if let activeSession {
688
                meter.restoreChargeMonitoringIfNeeded(from: activeSession)
689
            }
690
            reloadChargedDevices()
Bogdan Timofte authored a month ago
691
        }
692
        return didSave
693
    }
694

            
Bogdan Timofte authored a month ago
695
    @discardableResult
696
    func startPowerbankChargeSession(
697
        for meter: Meter,
698
        powerbankID: UUID,
699
        sourcePowerbankID: UUID? = nil,
700
        initialBatteryPercent: Double?,
701
        startsFromFlatBattery: Bool
702
    ) -> Bool {
703
        meter.resetMeterCountersForNewSession()
704

            
705
        guard let snapshot = meter.chargingMonitorSnapshot else {
706
            return false
707
        }
708

            
709
        let didSave = chargeInsightsStore?.startPowerbankSession(
710
            for: snapshot,
711
            powerbankID: powerbankID,
712
            sourcePowerbankID: sourcePowerbankID,
713
            autoStopEnabled: false,
714
            initialBatteryPercent: initialBatteryPercent,
715
            startsFromFlatBattery: startsFromFlatBattery
716
        ) ?? false
717
        if didSave {
718
            meter.resetChargeRecordGraph()
719
            if let activeSession = chargeInsightsStore?.activeChargeSessionSummary(
720
                forMeterMACAddress: meter.btSerial.macAddress.description
721
            ) {
722
                meter.restoreChargeMonitoringIfNeeded(from: activeSession)
723
            }
724
            reloadChargedDevices()
725
        }
726
        return didSave
727
    }
728

            
Bogdan Timofte authored a month ago
729
    @discardableResult
730
    func pauseChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool {
731
        let observedAt = meter?.chargingMonitorSnapshot?.observedAt ?? Date()
Bogdan Timofte authored a month ago
732

            
733
        if let meter {
734
            _ = persistChargeSnapshot(from: meter, observedAt: observedAt)
735
        } else if let meterMACAddress = chargeSessionSummary(id: sessionID)?.meterMACAddress {
736
            _ = flushPendingChargeObservation(for: meterMACAddress)
737
        }
738

            
Bogdan Timofte authored a month ago
739
        let didSave = chargeInsightsStore?.pauseSession(id: sessionID, observedAt: observedAt) ?? false
740
        if didSave {
741
            reloadChargedDevices()
742
        }
743
        return didSave
744
    }
745

            
746
    @discardableResult
747
    func resumeChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool {
748
        let snapshot = meter?.chargingMonitorSnapshot
749
        let didSave = chargeInsightsStore?.resumeSession(id: sessionID, snapshot: snapshot) ?? false
750
        if didSave {
751
            reloadChargedDevices()
752
        }
753
        return didSave
754
    }
755

            
756
    @discardableResult
Bogdan Timofte authored a month ago
757
    func stopChargeSession(sessionID: UUID, finalBatteryPercent: Double? = nil, from meter: Meter? = nil) -> Bool {
758
        if let meter {
759
            _ = persistChargeSnapshot(from: meter)
760
        } else if let meterMACAddress = chargeSessionSummary(id: sessionID)?.meterMACAddress {
Bogdan Timofte authored a month ago
761
            _ = flushPendingChargeObservation(for: meterMACAddress)
762
        }
763

            
Bogdan Timofte authored a month ago
764
        let didSave = chargeInsightsStore?.stopSession(
765
            id: sessionID,
Bogdan Timofte authored a month ago
766
            finalBatteryPercent: finalBatteryPercent
Bogdan Timofte authored a month ago
767
        ) ?? false
Bogdan Timofte authored a month ago
768
        reloadChargedDevices()
Bogdan Timofte authored a month ago
769
        return didSave
770
    }
771

            
772
    func observeChargeSnapshot(from meter: Meter, observedAt: Date = Date()) {
773
        guard let snapshot = meter.chargingMonitorSnapshot(at: observedAt) else {
774
            return
775
        }
776

            
Bogdan Timofte authored a month ago
777
        stageChargeObservation(snapshot)
Bogdan Timofte authored a month ago
778

            
779
        let normalizedMAC = Self.normalizedMACAddress(meter.btSerial.macAddress.description)
780
        if let consumptionSession = activeConsumptionSessions[normalizedMAC] {
781
            consumptionSession.observe(
782
                powerWatts: snapshot.powerWatts,
783
                currentAmps: snapshot.currentAmps,
784
                voltageVolts: snapshot.voltageVolts,
785
                observedAt: observedAt
786
            )
787
        }
Bogdan Timofte authored a month ago
788
    }
789

            
790
    @discardableResult
Bogdan Timofte authored a month ago
791
    func addBatteryCheckpoint(percent: Double, for meter: Meter) -> Bool {
Bogdan Timofte authored a month ago
792
        _ = persistChargeSnapshot(from: meter)
Bogdan Timofte authored a month ago
793

            
794
        let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description)
795
        let checkpointEnergyWh = activeSession.map { displayedSessionEnergyWh(for: $0, on: meter) }
796

            
Bogdan Timofte authored a month ago
797
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
798
            percent: percent,
Bogdan Timofte authored a month ago
799
            for: meter.btSerial.macAddress.description,
Bogdan Timofte authored a month ago
800
            measuredEnergyWh: checkpointEnergyWh
Bogdan Timofte authored a month ago
801
        ) ?? false
802

            
803
        if didSave {
804
            reloadChargedDevices()
805
        }
806

            
807
        return didSave
808
    }
809

            
810
    @discardableResult
Bogdan Timofte authored a month ago
811
    func addBatteryCheckpoint(percent: Double, for sessionID: UUID) -> Bool {
812
        guard canAddBatteryCheckpoint(to: sessionID) else {
813
            return false
814
        }
815

            
Bogdan Timofte authored a month ago
816
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
817
            percent: percent,
818
            for: sessionID
819
        ) ?? false
820

            
821
        if didSave {
822
            reloadChargedDevices()
823
        }
824

            
825
        return didSave
826
    }
827

            
Bogdan Timofte authored a month ago
828
    @discardableResult
829
    func addBatteryCheckpoint(
830
        percent: Double,
831
        for sessionID: UUID,
Bogdan Timofte authored a month ago
832
        measuredEnergyWh: Double?,
833
        subject: CheckpointSubject = .chargedDevice,
834
        barsValue: Int = 0
Bogdan Timofte authored a month ago
835
    ) -> Bool {
Bogdan Timofte authored a month ago
836
        guard canAddBatteryCheckpoint(to: sessionID) else {
837
            return false
838
        }
839

            
Bogdan Timofte authored a month ago
840
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
841
            percent: percent,
842
            for: sessionID,
Bogdan Timofte authored a month ago
843
            measuredEnergyWh: measuredEnergyWh,
844
            subject: subject,
845
            barsValue: barsValue
Bogdan Timofte authored a month ago
846
        ) ?? false
847

            
848
        if didSave {
849
            reloadChargedDevices()
850
        }
851

            
852
        return didSave
853
    }
854

            
Bogdan Timofte authored a month ago
855
    func canAddBatteryCheckpoint(to sessionID: UUID) -> Bool {
856
        guard let session = chargeSessionSummary(id: sessionID),
857
              session.status.isOpen,
858
              let meterMACAddress = session.meterMACAddress else {
859
            return false
860
        }
861

            
862
        return meter(for: meterMACAddress) != nil
863
    }
864

            
865
    func batteryCheckpointCaptureRequirementMessage(for sessionID: UUID) -> String? {
866
        guard let session = chargeSessionSummary(id: sessionID) else {
867
            return "Battery checkpoints are available only while the charge session is still active."
868
        }
869

            
870
        guard session.status.isOpen else {
871
            return "Battery checkpoints are available only while the charge session is still active."
872
        }
873

            
874
        guard let meterMACAddress = session.meterMACAddress,
875
              meter(for: meterMACAddress) != nil else {
876
            return "Add battery checkpoints only on the device that is actively monitoring this charging session. Devices following the session through iCloud may not have data that is fresh or precise enough."
877
        }
878

            
879
        return nil
880
    }
881

            
Bogdan Timofte authored a month ago
882
    func batteryCheckpointPlausibilityWarning(
883
        percent: Double,
Bogdan Timofte authored a month ago
884
        for sessionID: UUID,
885
        effectiveEnergyWhOverride: Double? = nil
Bogdan Timofte authored a month ago
886
    ) -> BatteryCheckpointPlausibilityWarning? {
887
        guard let session = chargeSessionSummary(id: sessionID) else {
888
            return nil
889
        }
Bogdan Timofte authored a month ago
890
        return batteryCheckpointPlausibilityWarning(
891
            percent: percent,
892
            for: session,
893
            effectiveEnergyWhOverride: effectiveEnergyWhOverride
894
        )
Bogdan Timofte authored a month ago
895
    }
896

            
897
    @discardableResult
898
    func deleteBatteryCheckpoint(checkpointID: UUID, for sessionID: UUID) -> Bool {
899
        let didDelete = chargeInsightsStore?.deleteBatteryCheckpoint(
900
            id: checkpointID,
901
            from: sessionID
902
        ) ?? false
903

            
904
        if didDelete {
Bogdan Timofte authored a month ago
905
            reloadChargedDevices()
Bogdan Timofte authored a month ago
906
        }
907

            
908
        return didDelete
909
    }
910

            
Bogdan Timofte authored a month ago
911
    @discardableResult
912
    func setSessionTrim(sessionID: UUID, start: Date?, end: Date?) -> Bool {
913
        let didSave = chargeInsightsStore?.setSessionTrim(
914
            sessionID: sessionID,
915
            start: start,
916
            end: end
917
        ) ?? false
918
        if didSave {
919
            reloadChargedDevices()
920
        }
921
        return didSave
922
    }
923

            
Bogdan Timofte authored a month ago
924
    @discardableResult
925
    func commitSessionTrim(sessionID: UUID) -> Bool {
926
        let didSave = chargeInsightsStore?.commitSessionTrim(sessionID: sessionID) ?? false
927
        if didSave {
928
            reloadChargedDevices()
929
        }
930
        return didSave
931
    }
932

            
Bogdan Timofte authored a month ago
933
    @discardableResult
934
    func flushChargeInsights() -> Bool {
Bogdan Timofte authored a month ago
935
        let didFlushObservations = flushAllPendingChargeObservations()
Bogdan Timofte authored a month ago
936
        let didSave = chargeInsightsStore?.flushPendingChanges() ?? false
Bogdan Timofte authored a month ago
937
        if didFlushObservations || didSave {
938
            reloadChargedDevices()
939
        }
940
        return didFlushObservations || didSave
Bogdan Timofte authored a month ago
941
    }
942

            
943
    @discardableResult
944
    func setTargetBatteryPercent(_ percent: Double?, for meter: Meter) -> Bool {
945
        guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
946
            return false
947
        }
948
        return setTargetBatteryPercent(percent, for: activeSession.id)
949
    }
950

            
951
    @discardableResult
952
    func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
953
        if percent != nil {
954
            chargeNotificationCoordinator.ensureAuthorizationIfNeeded()
955
        }
956

            
957
        let didSave = chargeInsightsStore?.setTargetBatteryPercent(percent, for: sessionID) ?? false
958
        if didSave {
959
            reloadChargedDevices()
960
        }
961
        return didSave
962
    }
963

            
964
    @discardableResult
965
    func confirmChargeSessionCompletion(sessionID: UUID) -> Bool {
966
        let didSave = chargeInsightsStore?.confirmCompletion(for: sessionID) ?? false
967
        if didSave {
968
            reloadChargedDevices()
969
        }
970
        return didSave
971
    }
972

            
973
    @discardableResult
974
    func continueChargeSessionMonitoring(sessionID: UUID) -> Bool {
975
        let didSave = chargeInsightsStore?.continueMonitoringDespiteCompletionContradiction(for: sessionID) ?? false
976
        if didSave {
977
            reloadChargedDevices()
978
        }
979
        return didSave
980
    }
981

            
982
    @discardableResult
983
    func deleteChargeSession(sessionID: UUID) -> Bool {
984
        let deletedSession = chargedDevices
985
            .flatMap(\.sessions)
986
            .first(where: { $0.id == sessionID })
987

            
988
        let didDelete = chargeInsightsStore?.deleteChargeSession(id: sessionID) ?? false
989
        guard didDelete else {
990
            return false
991
        }
992

            
Bogdan Timofte authored a month ago
993
        if deletedSession?.status.isOpen == true,
Bogdan Timofte authored a month ago
994
           let meterMACAddress = deletedSession?.meterMACAddress,
995
           let liveMeter = meter(for: meterMACAddress) {
996
            liveMeter.resetChargeRecord()
997
        }
998

            
999
        reloadChargedDevices()
1000
        return true
1001
    }
1002

            
1003
    @discardableResult
1004
    func deleteChargedDevice(id: UUID) -> Bool {
1005
        let deletedDevice = chargedDeviceSummary(id: id)
1006
        let didDelete = chargeInsightsStore?.deleteChargedDevice(id: id) ?? false
1007
        guard didDelete else {
1008
            return false
1009
        }
1010

            
Bogdan Timofte authored a month ago
1011
        if deletedDevice?.isCharger == true {
1012
            _ = chargerStandbyPowerStore.removeMeasurements(for: id)
1013
            for (meterMACAddress, session) in activeChargerStandbySessions where session.chargerID == id {
1014
                session.stop()
1015
                activeChargerStandbySessions[meterMACAddress] = nil
1016
            }
1017
        }
1018

            
Bogdan Timofte authored a month ago
1019
        if deletedDevice?.isCharger == false,
Bogdan Timofte authored a month ago
1020
           deletedDevice?.activeSession?.status.isOpen == true,
Bogdan Timofte authored a month ago
1021
           let meterMACAddress = deletedDevice?.activeSession?.meterMACAddress,
1022
           let liveMeter = meter(for: meterMACAddress) {
1023
            liveMeter.resetChargeRecord()
1024
        }
1025

            
1026
        reloadChargedDevices()
1027
        return true
1028
    }
1029

            
1030
    @discardableResult
1031
    func createKnownMeter(
1032
        macAddress: String,
1033
        customName: String?,
1034
        modelName: String,
1035
        advertisedName: String?
1036
    ) -> Bool {
1037
        let normalizedMAC = Self.normalizedMACAddress(macAddress)
1038
        guard Self.isValidMACAddress(normalizedMAC) else {
1039
            return false
1040
        }
1041

            
1042
        registerMeter(macAddress: normalizedMAC, modelName: modelName, advertisedName: advertisedName)
1043
        if let customName = customName?.trimmingCharacters(in: .whitespacesAndNewlines), !customName.isEmpty {
1044
            setMeterName(customName, for: normalizedMAC)
1045
        }
1046
        noteMeterSeen(at: Date(), macAddress: normalizedMAC)
1047
        return true
1048
    }
1049

            
1050
    @discardableResult
1051
    func deleteMeter(macAddress: String) -> Bool {
1052
        let normalizedMAC = Self.normalizedMACAddress(macAddress)
1053
        guard Self.isValidMACAddress(normalizedMAC) else {
1054
            return false
1055
        }
1056

            
1057
        for meter in meters.values where meter.btSerial.macAddress.description == normalizedMAC {
1058
            meter.disconnect()
1059
        }
1060
        meters = meters.filter { element in
1061
            element.value.btSerial.macAddress.description != normalizedMAC
1062
        }
1063

            
1064
        let didDelete = meterStore.remove(macAddress: normalizedMAC)
1065
        if didDelete {
1066
            scheduleObjectWillChange()
1067
        }
1068
        return didDelete
1069
    }
1070

            
Bogdan Timofte authored 2 months ago
1071
    var meterSummaries: [MeterSummary] {
Bogdan Timofte authored a month ago
1072
        if let meterSummariesCache, meterSummariesCache.version == meterSummariesVersion {
1073
            return meterSummariesCache.summaries
1074
        }
1075

            
Bogdan Timofte authored 2 months ago
1076
        let liveMetersByMAC = Dictionary(uniqueKeysWithValues: meters.values.map { ($0.btSerial.macAddress.description, $0) })
1077
        let recordsByMAC = Dictionary(uniqueKeysWithValues: meterStore.allRecords().map { ($0.macAddress, $0) })
1078
        let macAddresses = Set(recordsByMAC.keys).union(liveMetersByMAC.keys)
1079

            
Bogdan Timofte authored a month ago
1080
        let summaries = macAddresses.map { macAddress in
Bogdan Timofte authored 2 months ago
1081
            let liveMeter = liveMetersByMAC[macAddress]
1082
            let record = recordsByMAC[macAddress]
1083

            
Bogdan Timofte authored 2 months ago
1084
            return MeterSummary(
Bogdan Timofte authored 2 months ago
1085
                macAddress: macAddress,
Bogdan Timofte authored 2 months ago
1086
                displayName: Self.friendlyDisplayName(liveMeter: liveMeter, record: record),
Bogdan Timofte authored 2 months ago
1087
                modelSummary: liveMeter?.deviceModelSummary ?? record?.modelName ?? "Meter",
Bogdan Timofte authored 2 months ago
1088
                advertisedName: liveMeter?.modelString ?? record?.advertisedName,
1089
                lastSeen: liveMeter?.lastSeen ?? record?.lastSeen,
1090
                lastConnected: liveMeter?.lastConnectedAt ?? record?.lastConnected,
1091
                meter: liveMeter
1092
            )
1093
        }
1094
        .sorted { lhs, rhs in
Bogdan Timofte authored 2 months ago
1095
            if lhs.meter != nil && rhs.meter == nil {
1096
                return true
1097
            }
1098
            if lhs.meter == nil && rhs.meter != nil {
1099
                return false
1100
            }
Bogdan Timofte authored 2 months ago
1101
            let byName = lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName)
1102
            if byName != .orderedSame {
1103
                return byName == .orderedAscending
1104
            }
1105
            return lhs.macAddress < rhs.macAddress
1106
        }
Bogdan Timofte authored a month ago
1107

            
1108
        meterSummariesCache = (version: meterSummariesVersion, summaries: summaries)
1109
        return summaries
Bogdan Timofte authored 2 months ago
1110
    }
1111

            
Bogdan Timofte authored 2 months ago
1112
    private func scheduleObjectWillChange() {
1113
        DispatchQueue.main.async { [weak self] in
1114
            self?.objectWillChange.send()
1115
        }
1116
    }
Bogdan Timofte authored 2 months ago
1117

            
Bogdan Timofte authored a month ago
1118
    private func invalidateMeterSummaries() {
1119
        meterSummariesVersion += 1
1120
        meterSummariesCache = nil
1121
    }
1122

            
Bogdan Timofte authored a month ago
1123
    private func scheduleChargedDevicesReload(delay: TimeInterval = 0.15) {
1124
        pendingChargedDevicesReloadWorkItem?.cancel()
1125

            
1126
        let workItem = DispatchWorkItem { [weak self] in
1127
            self?.reloadChargedDevices()
1128
        }
1129
        pendingChargedDevicesReloadWorkItem = workItem
1130
        DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
1131
    }
1132

            
Bogdan Timofte authored a month ago
1133
    private func stageChargeObservation(_ snapshot: ChargingMonitorSnapshot, scheduleFlush: Bool = true) {
1134
        let normalizedMAC = Self.normalizedMACAddress(snapshot.meterMACAddress)
1135
        guard !normalizedMAC.isEmpty else {
1136
            return
1137
        }
1138

            
1139
        pendingChargeObservationSnapshots[normalizedMAC] = snapshot
1140

            
1141
        guard scheduleFlush else {
1142
            return
1143
        }
1144

            
1145
        guard pendingChargeObservationWorkItems[normalizedMAC] == nil else {
1146
            return
1147
        }
1148

            
1149
        let workItem = DispatchWorkItem { [weak self] in
1150
            guard let self else { return }
1151
            self.pendingChargeObservationWorkItems[normalizedMAC] = nil
1152
            guard let snapshot = self.pendingChargeObservationSnapshots.removeValue(forKey: normalizedMAC) else {
1153
                return
1154
            }
1155
            // CoreData write on background — DidSave observer handles the reload
1156
            let store = self.chargeInsightsStore
1157
            DispatchQueue.global(qos: .utility).async {
1158
                store?.observe(snapshot: snapshot)
1159
            }
1160
        }
1161
        pendingChargeObservationWorkItems[normalizedMAC] = workItem
1162
        DispatchQueue.main.asyncAfter(deadline: .now() + chargeObservationPersistInterval, execute: workItem)
1163
    }
1164

            
1165
    @discardableResult
1166
    private func persistChargeSnapshot(from meter: Meter, observedAt: Date = Date()) -> Bool {
1167
        guard let snapshot = meter.chargingMonitorSnapshot(at: observedAt) else {
1168
            return false
1169
        }
1170

            
1171
        stageChargeObservation(snapshot, scheduleFlush: false)
1172
        return flushPendingChargeObservation(for: snapshot.meterMACAddress)
1173
    }
1174

            
1175
    @discardableResult
1176
    private func flushPendingChargeObservation(for meterMACAddress: String) -> Bool {
1177
        let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
1178
        guard !normalizedMAC.isEmpty else {
1179
            return false
1180
        }
1181

            
1182
        pendingChargeObservationWorkItems[normalizedMAC]?.cancel()
1183
        pendingChargeObservationWorkItems[normalizedMAC] = nil
1184

            
1185
        guard let snapshot = pendingChargeObservationSnapshots.removeValue(forKey: normalizedMAC) else {
1186
            return false
1187
        }
1188

            
1189
        let didSave = chargeInsightsStore?.observe(snapshot: snapshot) ?? false
1190
        return didSave
1191
    }
1192

            
1193
    @discardableResult
1194
    private func flushAllPendingChargeObservations() -> Bool {
1195
        let pendingMeterMACAddresses = Array(pendingChargeObservationSnapshots.keys)
1196
        var didSave = false
1197

            
1198
        for meterMACAddress in pendingMeterMACAddresses {
1199
            if flushPendingChargeObservation(for: meterMACAddress) {
1200
                didSave = true
1201
            }
1202
        }
1203

            
1204
        return didSave
1205
    }
1206

            
Bogdan Timofte authored a month ago
1207
    private func cachedActiveChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? {
1208
        let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
1209
        guard !normalizedMAC.isEmpty else {
1210
            return nil
1211
        }
1212

            
1213
        return chargedDevices
1214
            .lazy
1215
            .compactMap(\.activeSession)
Bogdan Timofte authored a month ago
1216
            .first(where: { $0.status.isOpen && $0.meterMACAddress == normalizedMAC })
Bogdan Timofte authored a month ago
1217
    }
1218

            
Bogdan Timofte authored a month ago
1219
    @discardableResult
1220
    private func healDuplicateOpenSessions() -> Bool {
1221
        chargeInsightsStore?.healDuplicateOpenSessions() ?? false
1222
    }
1223

            
Bogdan Timofte authored a month ago
1224
    @discardableResult
1225
    private func expireOverlongChargeSessionsIfNeeded(referenceDate: Date = Date()) -> Bool {
1226
        chargeInsightsStore?.completeExpiredOpenSessions(referenceDate: referenceDate) ?? false
1227
    }
1228

            
Bogdan Timofte authored a month ago
1229
    private func reloadChargedDevices() {
Bogdan Timofte authored a month ago
1230
        if Thread.isMainThread == false {
1231
            DispatchQueue.main.async { [weak self] in
1232
                self?.reloadChargedDevices()
1233
            }
1234
            return
1235
        }
1236

            
Bogdan Timofte authored a month ago
1237
        pendingChargedDevicesReloadWorkItem?.cancel()
1238
        pendingChargedDevicesReloadWorkItem = nil
1239

            
Bogdan Timofte authored a month ago
1240
        _ = healDuplicateOpenSessions()
Bogdan Timofte authored a month ago
1241
        _ = expireOverlongChargeSessionsIfNeeded()
1242

            
Bogdan Timofte authored a month ago
1243
        guard chargedDevicesReloadInFlight == false else {
1244
            chargedDevicesReloadPending = true
1245
            return
1246
        }
1247

            
Bogdan Timofte authored a month ago
1248
        let standbyMeasurementsByChargerID = chargerStandbyPowerStore.measurementsByChargerID()
Bogdan Timofte authored a month ago
1249
        let consumptionSessionsByDeviceID = consumptionMonitorStore.sessionsByDeviceID()
Bogdan Timofte authored a month ago
1250
        let readStore = chargeInsightsReadStore ?? chargeInsightsStore
Bogdan Timofte authored a month ago
1251
        chargedDevicesReloadInFlight = true
1252
        chargedDevicesReloadPending = false
Bogdan Timofte authored a month ago
1253

            
1254
        chargedDevicesReloadQueue.async { [weak self] in
1255
            guard let self else { return }
1256

            
1257
            readStore?.resetContext()
1258
            let summaries = (readStore?.fetchChargedDeviceSummaries() ?? []).map { chargedDevice in
Bogdan Timofte authored a month ago
1259
                chargedDevice
1260
                    .withStandbyPowerMeasurements(standbyMeasurementsByChargerID[chargedDevice.id] ?? [])
1261
                    .withConsumptionSessions(consumptionSessionsByDeviceID[chargedDevice.id] ?? [])
Bogdan Timofte authored a month ago
1262
            }
Bogdan Timofte authored a month ago
1263
            let powerbankSummaries = readStore?.fetchPowerbankSummaries() ?? []
Bogdan Timofte authored a month ago
1264

            
1265
            DispatchQueue.main.async { [weak self] in
1266
                guard let self else { return }
1267

            
1268
                self.chargedDevices = summaries
Bogdan Timofte authored a month ago
1269
                self.powerbanks = powerbankSummaries
Bogdan Timofte authored a month ago
1270
                self.chargeNotificationCoordinator.process(chargedDevices: self.deviceSummaries)
1271
                for meter in self.meters.values {
1272
                    self.restoreChargeMonitoringStateIfNeeded(for: meter)
1273
                }
Bogdan Timofte authored a month ago
1274

            
1275
                self.chargedDevicesReloadInFlight = false
1276
                if self.chargedDevicesReloadPending {
1277
                    self.reloadChargedDevices()
1278
                }
Bogdan Timofte authored a month ago
1279
            }
Bogdan Timofte authored a month ago
1280
        }
1281
    }
1282

            
1283
    private func meter(for meterMACAddress: String) -> Meter? {
1284
        meters.values.first { meter in
1285
            meter.btSerial.macAddress.description == meterMACAddress
1286
        }
1287
    }
1288

            
Bogdan Timofte authored 2 months ago
1289
    private func refreshMeterMetadata() {
1290
        DispatchQueue.main.async { [weak self] in
1291
            guard let self else { return }
1292
            var didUpdateAnyMeter = false
1293
            for meter in self.meters.values {
1294
                let mac = meter.btSerial.macAddress.description
1295
                let displayName = self.meterName(for: mac) ?? mac
1296
                if meter.name != displayName {
1297
                    meter.updateNameFromStore(displayName)
1298
                    didUpdateAnyMeter = true
1299
                }
1300

            
1301
                let previousTemperaturePreference = meter.tc66TemperatureUnitPreference
1302
                meter.reloadTemperatureUnitPreference()
1303
                if meter.tc66TemperatureUnitPreference != previousTemperaturePreference {
1304
                    didUpdateAnyMeter = true
1305
                }
1306
            }
1307

            
1308
            if didUpdateAnyMeter {
1309
                self.scheduleObjectWillChange()
1310
            }
1311
        }
1312
    }
Bogdan Timofte authored a month ago
1313

            
Bogdan Timofte authored a month ago
1314
    private func notifyChargerStandbyMeasurementReady(for session: ChargerStandbyPowerMonitorSession) {
1315
        guard let charger = chargedDeviceSummary(id: session.chargerID),
1316
              let statistics = session.statistics else {
1317
            return
1318
        }
1319

            
1320
        let content = UNMutableNotificationContent()
1321
        content.title = "Standby baseline stabilised"
1322
        content.body = "\(charger.name) is holding around \(statistics.averagePowerWatts.format(decimalDigits: 3)) W. You can save the measurement when ready."
1323
        content.sound = .default
1324
        content.threadIdentifier = "charger-standby-\(charger.id.uuidString)"
1325

            
1326
        let request = UNNotificationRequest(
1327
            identifier: "charger-standby-\(session.id.uuidString)",
1328
            content: content,
1329
            trigger: nil
1330
        )
1331
        UNUserNotificationCenter.current().add(request)
1332
        scheduleObjectWillChange()
1333
    }
1334

            
Bogdan Timofte authored a month ago
1335
    private func batteryCheckpointPlausibilityWarning(
1336
        percent: Double,
Bogdan Timofte authored a month ago
1337
        for session: ChargeSessionSummary,
1338
        effectiveEnergyWhOverride: Double? = nil
Bogdan Timofte authored a month ago
1339
    ) -> BatteryCheckpointPlausibilityWarning? {
1340
        guard percent.isFinite, percent >= 0, percent <= 100 else {
1341
            return nil
1342
        }
1343

            
1344
        let sortedCheckpoints = session.checkpoints.sorted { lhs, rhs in
1345
            if lhs.timestamp != rhs.timestamp {
1346
                return lhs.timestamp < rhs.timestamp
1347
            }
1348
            if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
1349
                return lhs.measuredEnergyWh < rhs.measuredEnergyWh
1350
            }
1351
            return lhs.id.uuidString < rhs.id.uuidString
1352
        }
1353

            
1354
        if let lastCheckpoint = sortedCheckpoints.last,
1355
           percent < lastCheckpoint.batteryPercent - 1.5 {
1356
            return BatteryCheckpointPlausibilityWarning(
1357
                title: "Checkpoint Goes Backwards",
1358
                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."
1359
            )
1360
        }
1361

            
Bogdan Timofte authored a month ago
1362
        let effectiveEnergyWh = effectiveEnergyWhOverride
1363
            ?? session.effectiveBatteryEnergyWh
1364
            ?? session.measuredEnergyWh
1365

            
1366
        if let lastCheckpoint = sortedCheckpoints.last,
1367
           let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID) {
1368
            let estimatedCapacityWh = session.capacityEstimateWh
1369
                ?? chargedDevice.estimatedBatteryCapacityWh(for: session.chargingTransportMode)
1370
                ?? chargedDevice.estimatedBatteryCapacityWh
1371

            
1372
            if let estimatedCapacityWh, estimatedCapacityWh > 0 {
1373
                let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0)
1374
                let expectedPercent = min(
1375
                    100,
1376
                    max(
1377
                        lastCheckpoint.batteryPercent,
1378
                        lastCheckpoint.batteryPercent + (energyDeltaWh / estimatedCapacityWh) * 100
1379
                    )
1380
                )
1381
                let predictionGap = percent - expectedPercent
1382
                guard abs(predictionGap) >= 4 else {
1383
                    return nil
1384
                }
1385

            
1386
                let direction = predictionGap > 0 ? "above" : "below"
1387
                let gapText = abs(predictionGap).format(decimalDigits: 0)
1388
                let expectedText = expectedPercent.format(decimalDigits: 0)
1389

            
1390
                return BatteryCheckpointPlausibilityWarning(
1391
                    title: "Checkpoint Looks Implausible",
1392
                    message: "The last checkpoint stored \(lastCheckpoint.batteryPercent.format(decimalDigits: 0))% at \(lastCheckpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh. The current counted energy is \(effectiveEnergyWh.format(decimalDigits: 2)) Wh, which supports about \(expectedText)% based on \(estimatedCapacityWh.format(decimalDigits: 2)) Wh capacity. The entered value is about \(gapText) percentage points \(direction) that."
1393
                )
1394
            }
1395
        }
1396

            
Bogdan Timofte authored a month ago
1397
        guard let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID),
Bogdan Timofte authored a month ago
1398
              let prediction = chargedDevice.batteryLevelPrediction(
1399
                for: session,
1400
                effectiveEnergyWhOverride: effectiveEnergyWh
1401
              )
Bogdan Timofte authored a month ago
1402
        else {
1403
            return nil
1404
        }
1405

            
1406
        let predictionGap = percent - prediction.predictedPercent
1407
        guard abs(predictionGap) >= 4 else {
1408
            return nil
1409
        }
1410

            
1411
        let direction = predictionGap > 0 ? "above" : "below"
1412
        let gapText = abs(predictionGap).format(decimalDigits: 0)
1413
        let predictedText = prediction.predictedPercent.format(decimalDigits: 0)
1414

            
1415
        if let lastCheckpoint = sortedCheckpoints.last {
1416
            let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0)
1417
            return BatteryCheckpointPlausibilityWarning(
1418
                title: "Checkpoint Looks Implausible",
1419
                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."
1420
            )
1421
        }
1422

            
1423
        return BatteryCheckpointPlausibilityWarning(
1424
            title: "Checkpoint Looks Implausible",
1425
            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."
1426
        )
1427
    }
Bogdan Timofte authored a month ago
1428

            
1429
    private func displayedSessionEnergyWh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
1430
        let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
Bogdan Timofte authored a month ago
1431
        guard session.isTrimmed == false else {
1432
            return storedEnergyWh
1433
        }
Bogdan Timofte authored a month ago
1434
        guard session.status.isOpen else {
1435
            return storedEnergyWh
1436
        }
1437

            
1438
        guard session.meterMACAddress == meter.btSerial.macAddress.description else {
1439
            return storedEnergyWh
1440
        }
1441

            
1442
        if let baselineEnergyWh = session.meterEnergyBaselineWh {
1443
            return max(storedEnergyWh, max(meter.recordedWH - baselineEnergyWh, 0))
1444
        }
1445

            
1446
        return storedEnergyWh
1447
    }
1448

            
Bogdan Timofte authored 2 months ago
1449
}
Bogdan Timofte authored 2 months ago
1450

            
Bogdan Timofte authored a month ago
1451

            
Bogdan Timofte authored 2 months ago
1452
extension AppData.MeterSummary {
1453
    var tint: Color {
1454
        switch modelSummary {
1455
        case "UM25C":
1456
            return .blue
1457
        case "UM34C":
1458
            return .yellow
1459
        case "TC66C":
1460
            return Model.TC66C.color
1461
        default:
1462
            return .secondary
1463
        }
1464
    }
1465
}
Bogdan Timofte authored 2 months ago
1466

            
Bogdan Timofte authored a month ago
1467
extension AppData {
Bogdan Timofte authored 2 months ago
1468
    static func friendlyDisplayName(liveMeter: Meter?, record: MeterNameStore.Record?) -> String {
1469
        if let liveName = liveMeter?.name.trimmingCharacters(in: .whitespacesAndNewlines), !liveName.isEmpty {
1470
            return liveName
1471
        }
1472
        if let customName = record?.customName {
1473
            return customName
1474
        }
1475
        if let advertisedName = record?.advertisedName {
1476
            return advertisedName
1477
        }
1478
        if let recordModel = record?.modelName {
1479
            return recordModel
1480
        }
1481
        if let liveModel = liveMeter?.deviceModelSummary {
1482
            return liveModel
1483
        }
1484
        return "Meter"
1485
    }
Bogdan Timofte authored a month ago
1486

            
1487
    static func normalizedMACAddress(_ macAddress: String) -> String {
1488
        macAddress
1489
            .trimmingCharacters(in: .whitespacesAndNewlines)
1490
            .uppercased()
1491
    }
1492

            
1493
    static func isValidMACAddress(_ macAddress: String) -> Bool {
1494
        macAddress.range(
1495
            of: #"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$"#,
1496
            options: .regularExpression
1497
        ) != nil
1498
    }
1499
}
1500

            
1501
private final class ChargeNotificationCoordinator {
1502
    private struct Payload {
1503
        let id: String
1504
        let title: String
1505
        let body: String
1506
        let threadIdentifier: String
1507
    }
1508

            
1509
    private let notificationCenter = UNUserNotificationCenter.current()
1510
    private let deliveredIDsDefaultsKey = "ChargeNotificationCoordinator.deliveredIDs"
1511
    private let eventRecencyWindow: TimeInterval = 24 * 60 * 60
1512
    private var inFlightEventIDs: Set<String> = []
1513

            
1514
    func ensureAuthorizationIfNeeded() {
1515
        notificationCenter.getNotificationSettings { [weak self] settings in
1516
            guard settings.authorizationStatus == .notDetermined else {
1517
                return
1518
            }
1519

            
1520
            self?.notificationCenter.requestAuthorization(options: [.alert, .badge, .sound]) { _, error in
1521
                if let error {
1522
                    track("Notification authorization request failed: \(error.localizedDescription)")
1523
                }
1524
            }
1525
        }
1526
    }
1527

            
1528
    func process(chargedDevices: [ChargedDeviceSummary]) {
1529
        let now = Date()
1530
        let pendingPayloads = chargedDevices.flatMap { chargedDevice in
1531
            payloads(for: chargedDevice, now: now)
1532
        }
1533

            
1534
        for payload in pendingPayloads {
1535
            scheduleIfNeeded(payload)
1536
        }
1537
    }
1538

            
1539
    private func payloads(for chargedDevice: ChargedDeviceSummary, now: Date) -> [Payload] {
1540
        chargedDevice.sessions.compactMap { session in
1541
            if let triggeredAt = session.targetBatteryAlertTriggeredAt,
1542
               now.timeIntervalSince(triggeredAt) <= eventRecencyWindow,
1543
               let targetBatteryPercent = session.targetBatteryPercent {
1544
                let estimatedPercent = chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
1545
                    ?? session.endBatteryPercent
1546
                    ?? targetBatteryPercent
1547

            
1548
                return Payload(
1549
                    id: "target-\(session.id.uuidString)-\(Int(triggeredAt.timeIntervalSince1970))",
1550
                    title: "Battery target reached",
1551
                    body: "\(chargedDevice.name) reached about \(estimatedPercent.format(decimalDigits: 0))% on \(session.meterName ?? "USB Meter") (target \(targetBatteryPercent.format(decimalDigits: 0))%).",
1552
                    threadIdentifier: session.id.uuidString
1553
                )
1554
            }
1555

            
1556
            if session.requiresCompletionConfirmation,
1557
               let requestedAt = session.completionConfirmationRequestedAt,
1558
               now.timeIntervalSince(requestedAt) <= eventRecencyWindow {
1559
                let estimatedPercent = session.completionContradictionPercent
1560
                    ?? chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
1561
                let bodyPrefix = "\(chargedDevice.name) may have stopped too early on \(session.meterName ?? "USB Meter")."
1562
                let detail = estimatedPercent.map {
1563
                    " Estimated battery is only \($0.format(decimalDigits: 0))%."
1564
                } ?? ""
1565

            
1566
                return Payload(
1567
                    id: "completion-\(session.id.uuidString)-\(Int(requestedAt.timeIntervalSince1970))",
1568
                    title: "Confirm charge completion",
1569
                    body: bodyPrefix + detail,
1570
                    threadIdentifier: session.id.uuidString
1571
                )
1572
            }
1573

            
1574
            return nil
1575
        }
1576
    }
1577

            
1578
    private func scheduleIfNeeded(_ payload: Payload) {
1579
        guard deliveredEventIDs().contains(payload.id) == false else {
1580
            return
1581
        }
1582

            
1583
        guard inFlightEventIDs.contains(payload.id) == false else {
1584
            return
1585
        }
1586

            
1587
        inFlightEventIDs.insert(payload.id)
1588

            
1589
        notificationCenter.getNotificationSettings { [weak self] settings in
1590
            guard let self else { return }
1591
            guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
1592
                DispatchQueue.main.async {
1593
                    self.inFlightEventIDs.remove(payload.id)
1594
                }
1595
                return
1596
            }
1597

            
1598
            let content = UNMutableNotificationContent()
1599
            content.title = payload.title
1600
            content.body = payload.body
1601
            content.sound = .default
1602
            content.threadIdentifier = payload.threadIdentifier
1603

            
1604
            let request = UNNotificationRequest(
1605
                identifier: payload.id,
1606
                content: content,
1607
                trigger: nil
1608
            )
1609

            
1610
            self.notificationCenter.add(request) { error in
1611
                DispatchQueue.main.async {
1612
                    self.inFlightEventIDs.remove(payload.id)
1613
                    if let error {
1614
                        track("Failed scheduling local notification: \(error.localizedDescription)")
1615
                        return
1616
                    }
1617
                    self.storeDeliveredEventID(payload.id)
1618
                }
1619
            }
1620
        }
1621
    }
1622

            
1623
    private func deliveredEventIDs() -> Set<String> {
1624
        let values = UserDefaults.standard.stringArray(forKey: deliveredIDsDefaultsKey) ?? []
1625
        return Set(values)
1626
    }
1627

            
1628
    private func storeDeliveredEventID(_ id: String) {
1629
        var values = deliveredEventIDs()
1630
        values.insert(id)
1631
        UserDefaults.standard.set(Array(values), forKey: deliveredIDsDefaultsKey)
1632
    }
Bogdan Timofte authored 2 months ago
1633
}