Newer Older
753 lines | 26.909kb
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

            
15
final class AppData : ObservableObject {
Bogdan Timofte authored 2 months ago
16
    struct MeterSummary: Identifiable {
Bogdan Timofte authored 2 months ago
17
        let macAddress: String
18
        let displayName: String
19
        let modelSummary: String
20
        let advertisedName: String?
21
        let lastSeen: Date?
22
        let lastConnected: Date?
23
        let meter: Meter?
24

            
25
        var id: String {
26
            macAddress
27
        }
28
    }
29

            
Bogdan Timofte authored 2 months ago
30
    private var bluetoothManagerNotification: AnyCancellable?
Bogdan Timofte authored 2 months ago
31
    private var meterStoreObserver: AnyCancellable?
32
    private var meterStoreCloudObserver: AnyCancellable?
Bogdan Timofte authored a month ago
33
    private var chargeInsightsStoreObserver: AnyCancellable?
34
    private var chargeInsightsRemoteObserver: AnyCancellable?
Bogdan Timofte authored 2 months ago
35
    private let meterStore = MeterNameStore.shared
Bogdan Timofte authored a month ago
36
    private var chargeInsightsStore: ChargeInsightsStore?
37
    private let chargeNotificationCoordinator = ChargeNotificationCoordinator()
Bogdan Timofte authored 2 months ago
38

            
Bogdan Timofte authored 2 months ago
39
    init() {
Bogdan Timofte authored 2 months ago
40
        bluetoothManagerNotification = bluetoothManager.objectWillChange.sink { [weak self] _ in
Bogdan Timofte authored 2 months ago
41
            self?.scheduleObjectWillChange()
Bogdan Timofte authored 2 months ago
42
        }
Bogdan Timofte authored 2 months ago
43
        meterStoreObserver = NotificationCenter.default.publisher(for: .meterNameStoreDidChange)
44
            .receive(on: DispatchQueue.main)
45
            .sink { [weak self] _ in
46
                self?.refreshMeterMetadata()
47
            }
48
        meterStoreCloudObserver = NotificationCenter.default.publisher(for: .meterNameStoreCloudStatusDidChange)
49
            .receive(on: DispatchQueue.main)
50
            .sink { [weak self] _ in
51
                self?.scheduleObjectWillChange()
52
            }
Bogdan Timofte authored 2 months ago
53
    }
Bogdan Timofte authored 2 months ago
54

            
Bogdan Timofte authored 2 months ago
55
    let bluetoothManager = BluetoothManager()
Bogdan Timofte authored 2 months ago
56

            
Bogdan Timofte authored 2 months ago
57
    @Published var enableRecordFeature: Bool = true
Bogdan Timofte authored 2 months ago
58

            
Bogdan Timofte authored 2 months ago
59
    @Published var meters: [UUID:Meter] = [UUID:Meter]()
Bogdan Timofte authored a month ago
60
    @Published private(set) var chargedDevices: [ChargedDeviceSummary] = []
61

            
62
    var deviceSummaries: [ChargedDeviceSummary] {
63
        chargedDevices.filter { !$0.isCharger }
64
    }
65

            
66
    var chargerSummaries: [ChargedDeviceSummary] {
67
        chargedDevices.filter { $0.isCharger }
68
    }
Bogdan Timofte authored 2 months ago
69

            
70
    var cloudAvailability: MeterNameStore.CloudAvailability {
71
        meterStore.currentCloudAvailability
72
    }
73

            
Bogdan Timofte authored a month ago
74
    func activateChargeInsights(context: NSManagedObjectContext) {
75
        guard chargeInsightsStore == nil else {
76
            return
77
        }
78

            
79
        context.automaticallyMergesChangesFromParent = true
80
        context.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy
81
        chargeInsightsStore = ChargeInsightsStore(context: context)
82

            
83
        chargeInsightsStoreObserver = NotificationCenter.default.publisher(
84
            for: .NSManagedObjectContextObjectsDidChange,
85
            object: context
86
        )
87
        .receive(on: DispatchQueue.main)
88
        .sink { [weak self] _ in
89
            self?.reloadChargedDevices()
90
        }
91

            
92
        chargeInsightsRemoteObserver = NotificationCenter.default.publisher(
93
            for: .NSPersistentStoreRemoteChange,
94
            object: nil
95
        )
96
        .receive(on: DispatchQueue.main)
97
        .sink { [weak self] _ in
98
            self?.reloadChargedDevices()
99
        }
100

            
101
        chargeNotificationCoordinator.ensureAuthorizationIfNeeded()
102
        reloadChargedDevices()
103
    }
104

            
Bogdan Timofte authored 2 months ago
105
    func meterName(for macAddress: String) -> String? {
106
        meterStore.name(for: macAddress)
107
    }
108

            
109
    func setMeterName(_ name: String, for macAddress: String) {
110
        meterStore.upsert(macAddress: macAddress, name: name, temperatureUnitRawValue: nil)
111
    }
112

            
113
    func temperatureUnitPreference(for macAddress: String) -> TemperatureUnitPreference {
114
        let rawValue = meterStore.temperatureUnitRawValue(for: macAddress) ?? TemperatureUnitPreference.celsius.rawValue
115
        return TemperatureUnitPreference(rawValue: rawValue) ?? .celsius
116
    }
117

            
118
    func setTemperatureUnitPreference(_ preference: TemperatureUnitPreference, for macAddress: String) {
119
        meterStore.upsert(macAddress: macAddress, name: nil, temperatureUnitRawValue: preference.rawValue)
Bogdan Timofte authored 2 months ago
120
    }
Bogdan Timofte authored 2 months ago
121

            
Bogdan Timofte authored 2 months ago
122
    func registerMeter(macAddress: String, modelName: String?, advertisedName: String?) {
123
        meterStore.registerMeter(macAddress: macAddress, modelName: modelName, advertisedName: advertisedName)
Bogdan Timofte authored 2 months ago
124
    }
125

            
126
    func noteMeterSeen(at date: Date, macAddress: String) {
127
        meterStore.noteLastSeen(date, for: macAddress)
128
    }
129

            
130
    func noteMeterConnected(at date: Date, macAddress: String) {
131
        meterStore.noteLastConnected(date, for: macAddress)
132
    }
133

            
134
    func lastSeen(for macAddress: String) -> Date? {
135
        meterStore.lastSeen(for: macAddress)
136
    }
137

            
138
    func lastConnected(for macAddress: String) -> Date? {
139
        meterStore.lastConnected(for: macAddress)
140
    }
141

            
Bogdan Timofte authored a month ago
142
    func chargedDeviceSummary(id: UUID) -> ChargedDeviceSummary? {
143
        chargedDevices.first(where: { $0.id == id })
144
    }
145

            
146
    func chargedDevices(for meterMACAddress: String) -> [ChargedDeviceSummary] {
147
        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
148
        return chargedDevices.filter { chargedDevice in
149
            guard chargedDevice.isCharger == false else {
150
                return false
151
            }
152
            return chargedDevice.lastAssociatedMeterMAC == normalizedMAC
153
                || chargedDevice.sessions.contains(where: { $0.meterMACAddress == normalizedMAC })
154
        }
155
    }
156

            
157
    func chargers(for meterMACAddress: String) -> [ChargedDeviceSummary] {
158
        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
159
        return chargedDevices.filter { chargedDevice in
160
            guard chargedDevice.isCharger else {
161
                return false
162
            }
163
            return chargedDevice.lastAssociatedMeterMAC == normalizedMAC
164
                || chargedDevice.sessions.contains(where: { $0.meterMACAddress == normalizedMAC })
165
        }
166
    }
167

            
168
    func currentChargedDeviceSummary(for meterMACAddress: String) -> ChargedDeviceSummary? {
169
        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
170

            
171
        if let activeSession = activeChargeSessionSummary(for: normalizedMAC),
172
           let liveDevice = chargedDevices.first(where: {
173
               $0.id == activeSession.chargedDeviceID && $0.isCharger == false
174
           }) {
175
            return liveDevice
176
        }
177

            
178
        return chargedDevices.first(where: {
179
            $0.isCharger == false && $0.lastAssociatedMeterMAC == normalizedMAC
180
        })
181
    }
182

            
183
    func currentChargerSummary(for meterMACAddress: String) -> ChargedDeviceSummary? {
184
        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
185

            
186
        if let activeSession = activeChargeSessionSummary(for: normalizedMAC),
187
           let chargerID = activeSession.chargerID,
188
           let liveCharger = chargedDevices.first(where: {
189
               $0.id == chargerID && $0.isCharger
190
           }) {
191
            return liveCharger
192
        }
193

            
194
        return chargedDevices.first(where: {
195
            $0.isCharger && $0.lastAssociatedMeterMAC == normalizedMAC
196
        })
197
    }
198

            
199
    func activeChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? {
200
        chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: meterMACAddress)
201
    }
202

            
203
    @discardableResult
204
    func createChargedDevice(
205
        name: String,
206
        deviceClass: ChargedDeviceClass,
207
        supportsChargingWhileOff: Bool,
208
        supportsWiredCharging: Bool,
209
        supportsWirelessCharging: Bool,
210
        preferredChargingTransportMode: ChargingTransportMode,
211
        wirelessChargingProfile: WirelessChargingProfile,
212
        wiredChargeCompletionCurrentAmps: Double?,
213
        wirelessChargeCompletionCurrentAmps: Double?,
214
        notes: String?,
215
        meterMACAddress: String?
216
    ) -> Bool {
217
        let didSave = chargeInsightsStore?.createChargedDevice(
218
            name: name,
219
            deviceClass: deviceClass,
220
            supportsChargingWhileOff: supportsChargingWhileOff,
221
            supportsWiredCharging: supportsWiredCharging,
222
            supportsWirelessCharging: supportsWirelessCharging,
223
            preferredChargingTransportMode: preferredChargingTransportMode,
224
            wirelessChargingProfile: wirelessChargingProfile,
225
            wiredChargeCompletionCurrentAmps: wiredChargeCompletionCurrentAmps,
226
            wirelessChargeCompletionCurrentAmps: wirelessChargeCompletionCurrentAmps,
227
            notes: notes,
228
            assignTo: meterMACAddress
229
        ) ?? false
230

            
231
        if didSave {
232
            reloadChargedDevices()
233
        }
234

            
235
        return didSave
236
    }
237

            
238
    @discardableResult
239
    func updateChargedDevice(
240
        id: UUID,
241
        name: String,
242
        deviceClass: ChargedDeviceClass,
243
        supportsChargingWhileOff: Bool,
244
        supportsWiredCharging: Bool,
245
        supportsWirelessCharging: Bool,
246
        preferredChargingTransportMode: ChargingTransportMode,
247
        wirelessChargingProfile: WirelessChargingProfile,
248
        wiredChargeCompletionCurrentAmps: Double?,
249
        wirelessChargeCompletionCurrentAmps: Double?,
250
        notes: String?
251
    ) -> Bool {
252
        let didSave = chargeInsightsStore?.updateChargedDevice(
253
            id: id,
254
            name: name,
255
            deviceClass: deviceClass,
256
            supportsChargingWhileOff: supportsChargingWhileOff,
257
            supportsWiredCharging: supportsWiredCharging,
258
            supportsWirelessCharging: supportsWirelessCharging,
259
            preferredChargingTransportMode: preferredChargingTransportMode,
260
            wirelessChargingProfile: wirelessChargingProfile,
261
            wiredChargeCompletionCurrentAmps: wiredChargeCompletionCurrentAmps,
262
            wirelessChargeCompletionCurrentAmps: wirelessChargeCompletionCurrentAmps,
263
            notes: notes
264
        ) ?? false
265

            
266
        if didSave {
267
            reloadChargedDevices()
268
        }
269

            
270
        return didSave
271
    }
272

            
273
    @discardableResult
274
    func setChargingTransportMode(_ chargingTransportMode: ChargingTransportMode, for meter: Meter) -> Bool {
275
        let didSave = chargeInsightsStore?.setChargingTransportMode(
276
            chargingTransportMode,
277
            for: meter.btSerial.macAddress.description
278
        ) ?? false
279

            
280
        if didSave {
281
            reloadChargedDevices()
282
        }
283

            
284
        return didSave
285
    }
286

            
287
    @discardableResult
288
    func assignChargedDevice(_ chargedDeviceID: UUID, to meterMACAddress: String) -> Bool {
289
        let didSave = chargeInsightsStore?.assignChargedDevice(id: chargedDeviceID, to: meterMACAddress) ?? false
290
        if didSave {
291
            reloadChargedDevices()
292
        }
293
        return didSave
294
    }
295

            
296
    @discardableResult
297
    func assignCharger(_ chargerID: UUID, to meterMACAddress: String) -> Bool {
298
        let didSave = chargeInsightsStore?.assignCharger(id: chargerID, to: meterMACAddress) ?? false
299
        if didSave {
300
            reloadChargedDevices()
301
        }
302
        return didSave
303
    }
304

            
305
    @discardableResult
306
    func ensureChargeSession(for meter: Meter) -> Bool {
307
        guard let snapshot = meter.chargingMonitorSnapshot else {
308
            return false
309
        }
310

            
311
        let didSave = chargeInsightsStore?.ensureSession(for: snapshot, forceStart: true) ?? false
312
        if didSave {
313
            reloadChargedDevices()
314
        }
315
        return didSave
316
    }
317

            
318
    func observeChargeSnapshot(from meter: Meter, observedAt: Date = Date()) {
319
        guard let snapshot = meter.chargingMonitorSnapshot(at: observedAt) else {
320
            return
321
        }
322

            
323
        if chargeInsightsStore?.observe(snapshot: snapshot) == true {
324
            reloadChargedDevices()
325
        }
326
    }
327

            
328
    @discardableResult
329
    func addBatteryCheckpoint(percent: Double, label: String?, for meter: Meter) -> Bool {
330
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
331
            percent: percent,
332
            label: label,
333
            for: meter.btSerial.macAddress.description
334
        ) ?? false
335

            
336
        if didSave {
337
            reloadChargedDevices()
338
        }
339

            
340
        return didSave
341
    }
342

            
343
    @discardableResult
344
    func addBatteryCheckpoint(percent: Double, label: String?, for sessionID: UUID) -> Bool {
345
        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
346
            percent: percent,
347
            label: label,
348
            for: sessionID
349
        ) ?? false
350

            
351
        if didSave {
352
            reloadChargedDevices()
353
        }
354

            
355
        return didSave
356
    }
357

            
358
    @discardableResult
359
    func flushChargeInsights() -> Bool {
360
        let didSave = chargeInsightsStore?.flushPendingChanges() ?? false
361
        reloadChargedDevices()
362
        return didSave
363
    }
364

            
365
    @discardableResult
366
    func setTargetBatteryPercent(_ percent: Double?, for meter: Meter) -> Bool {
367
        guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
368
            return false
369
        }
370
        return setTargetBatteryPercent(percent, for: activeSession.id)
371
    }
372

            
373
    @discardableResult
374
    func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
375
        if percent != nil {
376
            chargeNotificationCoordinator.ensureAuthorizationIfNeeded()
377
        }
378

            
379
        let didSave = chargeInsightsStore?.setTargetBatteryPercent(percent, for: sessionID) ?? false
380
        if didSave {
381
            reloadChargedDevices()
382
        }
383
        return didSave
384
    }
385

            
386
    @discardableResult
387
    func confirmChargeSessionCompletion(sessionID: UUID) -> Bool {
388
        let didSave = chargeInsightsStore?.confirmCompletion(for: sessionID) ?? false
389
        if didSave {
390
            reloadChargedDevices()
391
        }
392
        return didSave
393
    }
394

            
395
    @discardableResult
396
    func continueChargeSessionMonitoring(sessionID: UUID) -> Bool {
397
        let didSave = chargeInsightsStore?.continueMonitoringDespiteCompletionContradiction(for: sessionID) ?? false
398
        if didSave {
399
            reloadChargedDevices()
400
        }
401
        return didSave
402
    }
403

            
404
    @discardableResult
405
    func deleteChargeSession(sessionID: UUID) -> Bool {
406
        let deletedSession = chargedDevices
407
            .flatMap(\.sessions)
408
            .first(where: { $0.id == sessionID })
409

            
410
        let didDelete = chargeInsightsStore?.deleteChargeSession(id: sessionID) ?? false
411
        guard didDelete else {
412
            return false
413
        }
414

            
415
        if deletedSession?.status == .active,
416
           let meterMACAddress = deletedSession?.meterMACAddress,
417
           let liveMeter = meter(for: meterMACAddress) {
418
            liveMeter.resetChargeRecord()
419
        }
420

            
421
        reloadChargedDevices()
422
        return true
423
    }
424

            
425
    @discardableResult
426
    func deleteChargedDevice(id: UUID) -> Bool {
427
        let deletedDevice = chargedDeviceSummary(id: id)
428
        let didDelete = chargeInsightsStore?.deleteChargedDevice(id: id) ?? false
429
        guard didDelete else {
430
            return false
431
        }
432

            
433
        if deletedDevice?.isCharger == false,
434
           deletedDevice?.activeSession?.status == .active,
435
           let meterMACAddress = deletedDevice?.activeSession?.meterMACAddress,
436
           let liveMeter = meter(for: meterMACAddress) {
437
            liveMeter.resetChargeRecord()
438
        }
439

            
440
        reloadChargedDevices()
441
        return true
442
    }
443

            
444
    @discardableResult
445
    func createKnownMeter(
446
        macAddress: String,
447
        customName: String?,
448
        modelName: String,
449
        advertisedName: String?
450
    ) -> Bool {
451
        let normalizedMAC = Self.normalizedMACAddress(macAddress)
452
        guard Self.isValidMACAddress(normalizedMAC) else {
453
            return false
454
        }
455

            
456
        registerMeter(macAddress: normalizedMAC, modelName: modelName, advertisedName: advertisedName)
457
        if let customName = customName?.trimmingCharacters(in: .whitespacesAndNewlines), !customName.isEmpty {
458
            setMeterName(customName, for: normalizedMAC)
459
        }
460
        noteMeterSeen(at: Date(), macAddress: normalizedMAC)
461
        return true
462
    }
463

            
464
    @discardableResult
465
    func deleteMeter(macAddress: String) -> Bool {
466
        let normalizedMAC = Self.normalizedMACAddress(macAddress)
467
        guard Self.isValidMACAddress(normalizedMAC) else {
468
            return false
469
        }
470

            
471
        for meter in meters.values where meter.btSerial.macAddress.description == normalizedMAC {
472
            meter.disconnect()
473
        }
474
        meters = meters.filter { element in
475
            element.value.btSerial.macAddress.description != normalizedMAC
476
        }
477

            
478
        let didDelete = meterStore.remove(macAddress: normalizedMAC)
479
        if didDelete {
480
            scheduleObjectWillChange()
481
        }
482
        return didDelete
483
    }
484

            
485
    func restoreChargeMonitoringStateIfNeeded(for meter: Meter) {
486
        guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
487
            return
488
        }
489
        meter.restoreChargeMonitoringIfNeeded(from: activeSession)
490
    }
491

            
Bogdan Timofte authored 2 months ago
492
    var meterSummaries: [MeterSummary] {
Bogdan Timofte authored 2 months ago
493
        let liveMetersByMAC = Dictionary(uniqueKeysWithValues: meters.values.map { ($0.btSerial.macAddress.description, $0) })
494
        let recordsByMAC = Dictionary(uniqueKeysWithValues: meterStore.allRecords().map { ($0.macAddress, $0) })
495
        let macAddresses = Set(recordsByMAC.keys).union(liveMetersByMAC.keys)
496

            
497
        return macAddresses.map { macAddress in
498
            let liveMeter = liveMetersByMAC[macAddress]
499
            let record = recordsByMAC[macAddress]
500

            
Bogdan Timofte authored 2 months ago
501
            return MeterSummary(
Bogdan Timofte authored 2 months ago
502
                macAddress: macAddress,
Bogdan Timofte authored 2 months ago
503
                displayName: Self.friendlyDisplayName(liveMeter: liveMeter, record: record),
Bogdan Timofte authored 2 months ago
504
                modelSummary: liveMeter?.deviceModelSummary ?? record?.modelName ?? "Meter",
Bogdan Timofte authored 2 months ago
505
                advertisedName: liveMeter?.modelString ?? record?.advertisedName,
506
                lastSeen: liveMeter?.lastSeen ?? record?.lastSeen,
507
                lastConnected: liveMeter?.lastConnectedAt ?? record?.lastConnected,
508
                meter: liveMeter
509
            )
510
        }
511
        .sorted { lhs, rhs in
Bogdan Timofte authored 2 months ago
512
            if lhs.meter != nil && rhs.meter == nil {
513
                return true
514
            }
515
            if lhs.meter == nil && rhs.meter != nil {
516
                return false
517
            }
Bogdan Timofte authored 2 months ago
518
            let byName = lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName)
519
            if byName != .orderedSame {
520
                return byName == .orderedAscending
521
            }
522
            return lhs.macAddress < rhs.macAddress
523
        }
524
    }
525

            
Bogdan Timofte authored 2 months ago
526
    private func scheduleObjectWillChange() {
527
        DispatchQueue.main.async { [weak self] in
528
            self?.objectWillChange.send()
529
        }
530
    }
Bogdan Timofte authored 2 months ago
531

            
Bogdan Timofte authored a month ago
532
    private func reloadChargedDevices() {
533
        chargedDevices = chargeInsightsStore?.fetchChargedDeviceSummaries() ?? []
534
        chargeNotificationCoordinator.process(chargedDevices: deviceSummaries)
535
        for meter in meters.values {
536
            restoreChargeMonitoringStateIfNeeded(for: meter)
537
        }
538
    }
539

            
540
    private func meter(for meterMACAddress: String) -> Meter? {
541
        meters.values.first { meter in
542
            meter.btSerial.macAddress.description == meterMACAddress
543
        }
544
    }
545

            
Bogdan Timofte authored 2 months ago
546
    private func refreshMeterMetadata() {
547
        DispatchQueue.main.async { [weak self] in
548
            guard let self else { return }
549
            var didUpdateAnyMeter = false
550
            for meter in self.meters.values {
551
                let mac = meter.btSerial.macAddress.description
552
                let displayName = self.meterName(for: mac) ?? mac
553
                if meter.name != displayName {
554
                    meter.updateNameFromStore(displayName)
555
                    didUpdateAnyMeter = true
556
                }
557

            
558
                let previousTemperaturePreference = meter.tc66TemperatureUnitPreference
559
                meter.reloadTemperatureUnitPreference()
560
                if meter.tc66TemperatureUnitPreference != previousTemperaturePreference {
561
                    didUpdateAnyMeter = true
562
                }
563
            }
564

            
565
            if didUpdateAnyMeter {
566
                self.scheduleObjectWillChange()
567
            }
568
        }
569
    }
Bogdan Timofte authored 2 months ago
570
}
Bogdan Timofte authored 2 months ago
571

            
572
extension AppData.MeterSummary {
573
    var tint: Color {
574
        switch modelSummary {
575
        case "UM25C":
576
            return .blue
577
        case "UM34C":
578
            return .yellow
579
        case "TC66C":
580
            return Model.TC66C.color
581
        default:
582
            return .secondary
583
        }
584
    }
585
}
Bogdan Timofte authored 2 months ago
586

            
Bogdan Timofte authored a month ago
587
extension AppData {
Bogdan Timofte authored 2 months ago
588
    static func friendlyDisplayName(liveMeter: Meter?, record: MeterNameStore.Record?) -> String {
589
        if let liveName = liveMeter?.name.trimmingCharacters(in: .whitespacesAndNewlines), !liveName.isEmpty {
590
            return liveName
591
        }
592
        if let customName = record?.customName {
593
            return customName
594
        }
595
        if let advertisedName = record?.advertisedName {
596
            return advertisedName
597
        }
598
        if let recordModel = record?.modelName {
599
            return recordModel
600
        }
601
        if let liveModel = liveMeter?.deviceModelSummary {
602
            return liveModel
603
        }
604
        return "Meter"
605
    }
Bogdan Timofte authored a month ago
606

            
607
    static func normalizedMACAddress(_ macAddress: String) -> String {
608
        macAddress
609
            .trimmingCharacters(in: .whitespacesAndNewlines)
610
            .uppercased()
611
    }
612

            
613
    static func isValidMACAddress(_ macAddress: String) -> Bool {
614
        macAddress.range(
615
            of: #"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$"#,
616
            options: .regularExpression
617
        ) != nil
618
    }
619
}
620

            
621
private final class ChargeNotificationCoordinator {
622
    private struct Payload {
623
        let id: String
624
        let title: String
625
        let body: String
626
        let threadIdentifier: String
627
    }
628

            
629
    private let notificationCenter = UNUserNotificationCenter.current()
630
    private let deliveredIDsDefaultsKey = "ChargeNotificationCoordinator.deliveredIDs"
631
    private let eventRecencyWindow: TimeInterval = 24 * 60 * 60
632
    private var inFlightEventIDs: Set<String> = []
633

            
634
    func ensureAuthorizationIfNeeded() {
635
        notificationCenter.getNotificationSettings { [weak self] settings in
636
            guard settings.authorizationStatus == .notDetermined else {
637
                return
638
            }
639

            
640
            self?.notificationCenter.requestAuthorization(options: [.alert, .badge, .sound]) { _, error in
641
                if let error {
642
                    track("Notification authorization request failed: \(error.localizedDescription)")
643
                }
644
            }
645
        }
646
    }
647

            
648
    func process(chargedDevices: [ChargedDeviceSummary]) {
649
        let now = Date()
650
        let pendingPayloads = chargedDevices.flatMap { chargedDevice in
651
            payloads(for: chargedDevice, now: now)
652
        }
653

            
654
        for payload in pendingPayloads {
655
            scheduleIfNeeded(payload)
656
        }
657
    }
658

            
659
    private func payloads(for chargedDevice: ChargedDeviceSummary, now: Date) -> [Payload] {
660
        chargedDevice.sessions.compactMap { session in
661
            if let triggeredAt = session.targetBatteryAlertTriggeredAt,
662
               now.timeIntervalSince(triggeredAt) <= eventRecencyWindow,
663
               let targetBatteryPercent = session.targetBatteryPercent {
664
                let estimatedPercent = chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
665
                    ?? session.endBatteryPercent
666
                    ?? targetBatteryPercent
667

            
668
                return Payload(
669
                    id: "target-\(session.id.uuidString)-\(Int(triggeredAt.timeIntervalSince1970))",
670
                    title: "Battery target reached",
671
                    body: "\(chargedDevice.name) reached about \(estimatedPercent.format(decimalDigits: 0))% on \(session.meterName ?? "USB Meter") (target \(targetBatteryPercent.format(decimalDigits: 0))%).",
672
                    threadIdentifier: session.id.uuidString
673
                )
674
            }
675

            
676
            if session.requiresCompletionConfirmation,
677
               let requestedAt = session.completionConfirmationRequestedAt,
678
               now.timeIntervalSince(requestedAt) <= eventRecencyWindow {
679
                let estimatedPercent = session.completionContradictionPercent
680
                    ?? chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
681
                let bodyPrefix = "\(chargedDevice.name) may have stopped too early on \(session.meterName ?? "USB Meter")."
682
                let detail = estimatedPercent.map {
683
                    " Estimated battery is only \($0.format(decimalDigits: 0))%."
684
                } ?? ""
685

            
686
                return Payload(
687
                    id: "completion-\(session.id.uuidString)-\(Int(requestedAt.timeIntervalSince1970))",
688
                    title: "Confirm charge completion",
689
                    body: bodyPrefix + detail,
690
                    threadIdentifier: session.id.uuidString
691
                )
692
            }
693

            
694
            return nil
695
        }
696
    }
697

            
698
    private func scheduleIfNeeded(_ payload: Payload) {
699
        guard deliveredEventIDs().contains(payload.id) == false else {
700
            return
701
        }
702

            
703
        guard inFlightEventIDs.contains(payload.id) == false else {
704
            return
705
        }
706

            
707
        inFlightEventIDs.insert(payload.id)
708

            
709
        notificationCenter.getNotificationSettings { [weak self] settings in
710
            guard let self else { return }
711
            guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
712
                DispatchQueue.main.async {
713
                    self.inFlightEventIDs.remove(payload.id)
714
                }
715
                return
716
            }
717

            
718
            let content = UNMutableNotificationContent()
719
            content.title = payload.title
720
            content.body = payload.body
721
            content.sound = .default
722
            content.threadIdentifier = payload.threadIdentifier
723

            
724
            let request = UNNotificationRequest(
725
                identifier: payload.id,
726
                content: content,
727
                trigger: nil
728
            )
729

            
730
            self.notificationCenter.add(request) { error in
731
                DispatchQueue.main.async {
732
                    self.inFlightEventIDs.remove(payload.id)
733
                    if let error {
734
                        track("Failed scheduling local notification: \(error.localizedDescription)")
735
                        return
736
                    }
737
                    self.storeDeliveredEventID(payload.id)
738
                }
739
            }
740
        }
741
    }
742

            
743
    private func deliveredEventIDs() -> Set<String> {
744
        let values = UserDefaults.standard.stringArray(forKey: deliveredIDsDefaultsKey) ?? []
745
        return Set(values)
746
    }
747

            
748
    private func storeDeliveredEventID(_ id: String) {
749
        var values = deliveredEventIDs()
750
        values.insert(id)
751
        UserDefaults.standard.set(Array(values), forKey: deliveredIDsDefaultsKey)
752
    }
Bogdan Timofte authored 2 months ago
753
}