USB-Meter / USB Meter / Model / MeterNameStore.swift
Newer Older
497 lines | 18.586kb
Bogdan Timofte authored 2 months ago
1
//  MeterNameStore.swift
2
//  USB Meter
3
//
4
//  Created by Codex on 2026.
5
//
6

            
7
import Foundation
8

            
9
final class MeterNameStore {
10
    struct Record: Identifiable {
11
        let macAddress: String
12
        let customName: String?
13
        let temperatureUnit: String?
Bogdan Timofte authored 2 months ago
14
        let modelName: String?
15
        let advertisedName: String?
16
        let lastSeen: Date?
17
        let lastConnected: Date?
Bogdan Timofte authored 2 months ago
18

            
19
        var id: String {
20
            macAddress
21
        }
22
    }
23

            
24
    enum CloudAvailability: Equatable {
25
        case unknown
26
        case available
27
        case noAccount
28
        case error(String)
29

            
30
        var helpTitle: String {
31
            switch self {
32
            case .unknown:
33
                return "Cloud Sync Status Unknown"
34
            case .available:
35
                return "Cloud Sync Ready"
36
            case .noAccount:
37
                return "Enable iCloud Drive"
38
            case .error:
39
                return "Cloud Sync Error"
40
            }
41
        }
42

            
43
        var helpMessage: String {
44
            switch self {
45
            case .unknown:
46
                return "The app is still checking whether iCloud sync is available on this device."
47
            case .available:
48
                return "iCloud sync is available for meter names and TC66 temperature preferences."
49
            case .noAccount:
50
                return "Meter names and TC66 temperature preferences sync through iCloud Drive. The app keeps a local copy too, but cross-device sync stays off until iCloud Drive is available."
51
            case .error(let description):
52
                return "The app keeps local values, but iCloud sync reported an error: \(description)"
53
            }
54
        }
55
    }
56

            
57
    static let shared = MeterNameStore()
58

            
59
    private enum Keys {
Bogdan Timofte authored 2 months ago
60
        static let meters = "MeterNameStore.meters"
Bogdan Timofte authored 2 months ago
61
        static let localMeterNames = "MeterNameStore.localMeterNames"
62
        static let localTemperatureUnits = "MeterNameStore.localTemperatureUnits"
Bogdan Timofte authored 2 months ago
63
        static let localModelNames = "MeterNameStore.localModelNames"
64
        static let localAdvertisedNames = "MeterNameStore.localAdvertisedNames"
65
        static let localLastSeen = "MeterNameStore.localLastSeen"
66
        static let localLastConnected = "MeterNameStore.localLastConnected"
Bogdan Timofte authored 2 months ago
67
        static let cloudMeterNames = "MeterNameStore.cloudMeterNames"
68
        static let cloudTemperatureUnits = "MeterNameStore.cloudTemperatureUnits"
69
    }
70

            
71
    private let defaults = UserDefaults.standard
72
    private let ubiquitousStore = NSUbiquitousKeyValueStore.default
73
    private let workQueue = DispatchQueue(label: "MeterNameStore.Queue")
74
    private var cloudAvailability: CloudAvailability = .unknown
75
    private var ubiquitousObserver: NSObjectProtocol?
76
    private var ubiquityIdentityObserver: NSObjectProtocol?
77

            
78
    private init() {
79
        ubiquitousObserver = NotificationCenter.default.addObserver(
80
            forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
81
            object: ubiquitousStore,
82
            queue: nil
83
        ) { [weak self] notification in
84
            self?.handleUbiquitousStoreChange(notification)
85
        }
86

            
87
        ubiquityIdentityObserver = NotificationCenter.default.addObserver(
88
            forName: NSNotification.Name.NSUbiquityIdentityDidChange,
89
            object: nil,
90
            queue: nil
91
        ) { [weak self] _ in
92
            self?.refreshCloudAvailability(reason: "identity-changed")
93
            self?.syncLocalValuesToCloudIfPossible(reason: "identity-changed")
94
        }
95

            
96
        refreshCloudAvailability(reason: "startup")
97
        ubiquitousStore.synchronize()
98
        syncLocalValuesToCloudIfPossible(reason: "startup")
99
    }
100

            
101
    var currentCloudAvailability: CloudAvailability {
102
        workQueue.sync {
103
            cloudAvailability
104
        }
105
    }
106

            
107
    func name(for macAddress: String) -> String? {
108
        let normalizedMAC = normalizedMACAddress(macAddress)
109
        guard !normalizedMAC.isEmpty else { return nil }
110
        return mergedDictionary(localKey: Keys.localMeterNames, cloudKey: Keys.cloudMeterNames)[normalizedMAC]
111
    }
112

            
113
    func temperatureUnitRawValue(for macAddress: String) -> String? {
114
        let normalizedMAC = normalizedMACAddress(macAddress)
115
        guard !normalizedMAC.isEmpty else { return nil }
116
        return mergedDictionary(localKey: Keys.localTemperatureUnits, cloudKey: Keys.cloudTemperatureUnits)[normalizedMAC]
117
    }
118

            
Bogdan Timofte authored 2 months ago
119
    func lastSeen(for macAddress: String) -> Date? {
120
        let normalizedMAC = normalizedMACAddress(macAddress)
121
        guard !normalizedMAC.isEmpty else { return nil }
122
        return dateDictionary(for: Keys.localLastSeen)[normalizedMAC]
123
    }
124

            
125
    func lastConnected(for macAddress: String) -> Date? {
126
        let normalizedMAC = normalizedMACAddress(macAddress)
127
        guard !normalizedMAC.isEmpty else { return nil }
128
        return dateDictionary(for: Keys.localLastConnected)[normalizedMAC]
129
    }
130

            
Bogdan Timofte authored 2 months ago
131
    func registerMeter(macAddress: String, modelName: String?, advertisedName: String?) {
Bogdan Timofte authored 2 months ago
132
        let normalizedMAC = normalizedMACAddress(macAddress)
133
        guard !normalizedMAC.isEmpty else {
Bogdan Timofte authored 2 months ago
134
            track("MeterNameStore ignored meter registration with invalid MAC '\(macAddress)'")
Bogdan Timofte authored 2 months ago
135
            return
136
        }
137

            
138
        var didChange = false
Bogdan Timofte authored 2 months ago
139
        didChange = updateMetersSet(normalizedMAC) || didChange
Bogdan Timofte authored 2 months ago
140
        didChange = updateDictionaryValue(
141
            for: normalizedMAC,
142
            value: normalizedName(modelName),
143
            localKey: Keys.localModelNames,
144
            cloudKey: nil
145
        ) || didChange
146
        didChange = updateDictionaryValue(
147
            for: normalizedMAC,
148
            value: normalizedName(advertisedName),
149
            localKey: Keys.localAdvertisedNames,
150
            cloudKey: nil
151
        ) || didChange
152

            
153
        if didChange {
154
            notifyChange()
155
        }
156
    }
157

            
158
    func noteLastSeen(_ date: Date, for macAddress: String) {
159
        updateDate(date, for: macAddress, key: Keys.localLastSeen)
160
    }
161

            
162
    func noteLastConnected(_ date: Date, for macAddress: String) {
163
        updateDate(date, for: macAddress, key: Keys.localLastConnected)
164
    }
165

            
Bogdan Timofte authored 2 months ago
166
    func upsert(macAddress: String, name: String?, temperatureUnitRawValue: String?) {
167
        let normalizedMAC = normalizedMACAddress(macAddress)
168
        guard !normalizedMAC.isEmpty else {
169
            track("MeterNameStore ignored upsert with invalid MAC '\(macAddress)'")
170
            return
171
        }
172

            
173
        var didChange = false
Bogdan Timofte authored 2 months ago
174
        didChange = updateMetersSet(normalizedMAC) || didChange
Bogdan Timofte authored 2 months ago
175

            
176
        if let name {
177
            didChange = updateDictionaryValue(
178
                for: normalizedMAC,
179
                value: normalizedName(name),
180
                localKey: Keys.localMeterNames,
181
                cloudKey: Keys.cloudMeterNames
182
            ) || didChange
183
        }
184

            
185
        if let temperatureUnitRawValue {
186
            didChange = updateDictionaryValue(
187
                for: normalizedMAC,
188
                value: normalizedTemperatureUnit(temperatureUnitRawValue),
189
                localKey: Keys.localTemperatureUnits,
190
                cloudKey: Keys.cloudTemperatureUnits
191
            ) || didChange
192
        }
193

            
194
        if didChange {
195
            notifyChange()
196
        }
197
    }
198

            
Bogdan Timofte authored a month ago
199
    @discardableResult
200
    func remove(macAddress: String) -> Bool {
201
        let normalizedMAC = normalizedMACAddress(macAddress)
202
        guard !normalizedMAC.isEmpty else {
203
            track("MeterNameStore ignored remove with invalid MAC '\(macAddress)'")
204
            return false
205
        }
206

            
207
        var didChange = false
208

            
209
        var knownMeters = meters()
210
        if knownMeters.remove(normalizedMAC) != nil {
211
            defaults.set(Array(knownMeters).sorted(), forKey: Keys.meters)
212
            didChange = true
213
        }
214

            
215
        didChange = removeDictionaryEntry(for: normalizedMAC, localKey: Keys.localMeterNames, cloudKey: Keys.cloudMeterNames) || didChange
216
        didChange = removeDictionaryEntry(for: normalizedMAC, localKey: Keys.localTemperatureUnits, cloudKey: Keys.cloudTemperatureUnits) || didChange
217
        didChange = removeDictionaryEntry(for: normalizedMAC, localKey: Keys.localModelNames, cloudKey: nil) || didChange
218
        didChange = removeDictionaryEntry(for: normalizedMAC, localKey: Keys.localAdvertisedNames, cloudKey: nil) || didChange
219
        didChange = removeDateEntry(for: normalizedMAC, key: Keys.localLastSeen) || didChange
220
        didChange = removeDateEntry(for: normalizedMAC, key: Keys.localLastConnected) || didChange
221

            
222
        if didChange {
223
            notifyChange()
224
        }
225

            
226
        return didChange
227
    }
228

            
Bogdan Timofte authored 2 months ago
229
    func allRecords() -> [Record] {
230
        let names = mergedDictionary(localKey: Keys.localMeterNames, cloudKey: Keys.cloudMeterNames)
231
        let temperatureUnits = mergedDictionary(localKey: Keys.localTemperatureUnits, cloudKey: Keys.cloudTemperatureUnits)
Bogdan Timofte authored 2 months ago
232
        let modelNames = dictionary(for: Keys.localModelNames, store: defaults)
233
        let advertisedNames = dictionary(for: Keys.localAdvertisedNames, store: defaults)
234
        let lastSeenValues = dateDictionary(for: Keys.localLastSeen)
235
        let lastConnectedValues = dateDictionary(for: Keys.localLastConnected)
Bogdan Timofte authored 2 months ago
236
        let macAddresses = meters()
Bogdan Timofte authored 2 months ago
237
            .union(names.keys)
238
            .union(temperatureUnits.keys)
239
            .union(modelNames.keys)
240
            .union(advertisedNames.keys)
241
            .union(lastSeenValues.keys)
242
            .union(lastConnectedValues.keys)
Bogdan Timofte authored 2 months ago
243

            
244
        return macAddresses.sorted().map { macAddress in
245
            Record(
246
                macAddress: macAddress,
247
                customName: names[macAddress],
Bogdan Timofte authored 2 months ago
248
                temperatureUnit: temperatureUnits[macAddress],
249
                modelName: modelNames[macAddress],
250
                advertisedName: advertisedNames[macAddress],
251
                lastSeen: lastSeenValues[macAddress],
252
                lastConnected: lastConnectedValues[macAddress]
Bogdan Timofte authored 2 months ago
253
            )
254
        }
255
    }
256

            
257
    private func normalizedMACAddress(_ macAddress: String) -> String {
258
        macAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
259
    }
260

            
261
    private func normalizedName(_ name: String?) -> String? {
262
        guard let trimmed = name?.trimmingCharacters(in: .whitespacesAndNewlines),
263
              !trimmed.isEmpty else {
264
            return nil
265
        }
266
        return trimmed
267
    }
268

            
269
    private func normalizedTemperatureUnit(_ value: String?) -> String? {
270
        guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
271
              !trimmed.isEmpty else {
272
            return nil
273
        }
274
        return trimmed
275
    }
276

            
277
    private func dictionary(for key: String, store: KeyValueReading) -> [String: String] {
278
        (store.object(forKey: key) as? [String: String]) ?? [:]
279
    }
280

            
Bogdan Timofte authored 2 months ago
281
    private func dateDictionary(for key: String) -> [String: Date] {
282
        let rawValues = (defaults.object(forKey: key) as? [String: TimeInterval]) ?? [:]
283
        return rawValues.mapValues(Date.init(timeIntervalSince1970:))
284
    }
285

            
Bogdan Timofte authored 2 months ago
286
    private func meters() -> Set<String> {
287
        Set((defaults.array(forKey: Keys.meters) as? [String]) ?? [])
Bogdan Timofte authored 2 months ago
288
    }
289

            
Bogdan Timofte authored 2 months ago
290
    private func mergedDictionary(localKey: String, cloudKey: String) -> [String: String] {
291
        let localValues = dictionary(for: localKey, store: defaults)
292
        let cloudValues = dictionary(for: cloudKey, store: ubiquitousStore)
293
        return localValues.merging(cloudValues) { _, cloudValue in
294
            cloudValue
295
        }
296
    }
297

            
Bogdan Timofte authored 2 months ago
298
    @discardableResult
Bogdan Timofte authored 2 months ago
299
    private func updateMetersSet(_ macAddress: String) -> Bool {
300
        var known = meters()
Bogdan Timofte authored 2 months ago
301
        let initialCount = known.count
302
        known.insert(macAddress)
303
        guard known.count != initialCount else { return false }
Bogdan Timofte authored 2 months ago
304
        defaults.set(Array(known).sorted(), forKey: Keys.meters)
Bogdan Timofte authored 2 months ago
305
        return true
306
    }
307

            
Bogdan Timofte authored 2 months ago
308
    @discardableResult
309
    private func updateDictionaryValue(
310
        for macAddress: String,
311
        value: String?,
312
        localKey: String,
Bogdan Timofte authored 2 months ago
313
        cloudKey: String?
Bogdan Timofte authored 2 months ago
314
    ) -> Bool {
315
        var localValues = dictionary(for: localKey, store: defaults)
316
        let didChangeLocal = setDictionaryValue(&localValues, for: macAddress, value: value)
317
        if didChangeLocal {
318
            defaults.set(localValues, forKey: localKey)
319
        }
320

            
321
        var didChangeCloud = false
Bogdan Timofte authored 2 months ago
322
        if let cloudKey, isICloudDriveAvailable {
Bogdan Timofte authored 2 months ago
323
            var cloudValues = dictionary(for: cloudKey, store: ubiquitousStore)
324
            didChangeCloud = setDictionaryValue(&cloudValues, for: macAddress, value: value)
325
            if didChangeCloud {
326
                ubiquitousStore.set(cloudValues, forKey: cloudKey)
327
                ubiquitousStore.synchronize()
328
            }
329
        }
330

            
331
        return didChangeLocal || didChangeCloud
332
    }
333

            
334
    @discardableResult
335
    private func setDictionaryValue(
336
        _ dictionary: inout [String: String],
337
        for macAddress: String,
338
        value: String?
339
    ) -> Bool {
340
        let currentValue = dictionary[macAddress]
341
        guard currentValue != value else { return false }
342
        if let value {
343
            dictionary[macAddress] = value
344
        } else {
345
            dictionary.removeValue(forKey: macAddress)
346
        }
347
        return true
348
    }
349

            
Bogdan Timofte authored 2 months ago
350
    private func updateDate(_ date: Date, for macAddress: String, key: String) {
351
        let normalizedMAC = normalizedMACAddress(macAddress)
352
        guard !normalizedMAC.isEmpty else {
353
            track("MeterNameStore ignored date update with invalid MAC '\(macAddress)'")
354
            return
355
        }
356

            
357
        var values = (defaults.object(forKey: key) as? [String: TimeInterval]) ?? [:]
358
        let timeInterval = date.timeIntervalSince1970
359
        guard values[normalizedMAC] != timeInterval else { return }
360
        values[normalizedMAC] = timeInterval
361
        defaults.set(values, forKey: key)
Bogdan Timofte authored 2 months ago
362
        _ = updateMetersSet(normalizedMAC)
Bogdan Timofte authored 2 months ago
363
        notifyChange()
364
    }
365

            
Bogdan Timofte authored a month ago
366
    @discardableResult
367
    private func removeDictionaryEntry(
368
        for macAddress: String,
369
        localKey: String,
370
        cloudKey: String?
371
    ) -> Bool {
372
        var didChange = false
373

            
374
        var localValues = dictionary(for: localKey, store: defaults)
375
        if localValues.removeValue(forKey: macAddress) != nil {
376
            defaults.set(localValues, forKey: localKey)
377
            didChange = true
378
        }
379

            
380
        if let cloudKey, isICloudDriveAvailable {
381
            var cloudValues = dictionary(for: cloudKey, store: ubiquitousStore)
382
            if cloudValues.removeValue(forKey: macAddress) != nil {
383
                ubiquitousStore.set(cloudValues, forKey: cloudKey)
384
                ubiquitousStore.synchronize()
385
                didChange = true
386
            }
387
        }
388

            
389
        return didChange
390
    }
391

            
392
    @discardableResult
393
    private func removeDateEntry(for macAddress: String, key: String) -> Bool {
394
        var values = (defaults.object(forKey: key) as? [String: TimeInterval]) ?? [:]
395
        guard values.removeValue(forKey: macAddress) != nil else {
396
            return false
397
        }
398
        defaults.set(values, forKey: key)
399
        return true
400
    }
401

            
Bogdan Timofte authored 2 months ago
402
    private var isICloudDriveAvailable: Bool {
403
        FileManager.default.ubiquityIdentityToken != nil
404
    }
405

            
406
    private func refreshCloudAvailability(reason: String) {
407
        let newAvailability: CloudAvailability = isICloudDriveAvailable ? .available : .noAccount
408

            
409
        var shouldNotify = false
410
        workQueue.sync {
411
            guard cloudAvailability != newAvailability else { return }
412
            cloudAvailability = newAvailability
413
            shouldNotify = true
414
        }
415

            
416
        guard shouldNotify else { return }
417
        track("MeterNameStore iCloud availability (\(reason)): \(newAvailability)")
418
        DispatchQueue.main.async {
419
            NotificationCenter.default.post(name: .meterNameStoreCloudStatusDidChange, object: nil)
420
        }
421
    }
422

            
423
    private func handleUbiquitousStoreChange(_ notification: Notification) {
424
        refreshCloudAvailability(reason: "ubiquitous-store-change")
425
        if let changedKeys = notification.userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String], !changedKeys.isEmpty {
426
            track("MeterNameStore received ubiquitous changes for keys: \(changedKeys.joined(separator: ", "))")
427
        }
428
        notifyChange()
429
    }
430

            
431
    private func syncLocalValuesToCloudIfPossible(reason: String) {
432
        guard isICloudDriveAvailable else {
433
            refreshCloudAvailability(reason: reason)
434
            return
435
        }
436

            
437
        let localNames = dictionary(for: Keys.localMeterNames, store: defaults)
438
        let localTemperatureUnits = dictionary(for: Keys.localTemperatureUnits, store: defaults)
439

            
440
        var cloudNames = dictionary(for: Keys.cloudMeterNames, store: ubiquitousStore)
441
        var cloudTemperatureUnits = dictionary(for: Keys.cloudTemperatureUnits, store: ubiquitousStore)
442

            
443
        let mergedNames = cloudNames.merging(localNames) { cloudValue, _ in
444
            cloudValue
445
        }
446
        let mergedTemperatureUnits = cloudTemperatureUnits.merging(localTemperatureUnits) { cloudValue, _ in
447
            cloudValue
448
        }
449

            
450
        var didChange = false
451
        if cloudNames != mergedNames {
452
            cloudNames = mergedNames
453
            ubiquitousStore.set(cloudNames, forKey: Keys.cloudMeterNames)
454
            didChange = true
455
        }
456
        if cloudTemperatureUnits != mergedTemperatureUnits {
457
            cloudTemperatureUnits = mergedTemperatureUnits
458
            ubiquitousStore.set(cloudTemperatureUnits, forKey: Keys.cloudTemperatureUnits)
459
            didChange = true
460
        }
461

            
462
        refreshCloudAvailability(reason: reason)
463

            
464
        if didChange {
465
            ubiquitousStore.synchronize()
466
            track("MeterNameStore pushed local fallback values into iCloud KVS (\(reason)).")
467
            notifyChange()
468
        }
469
    }
470

            
471
    private func notifyChange() {
472
        DispatchQueue.main.async {
473
            NotificationCenter.default.post(name: .meterNameStoreDidChange, object: nil)
474
        }
475
    }
476

            
477
    deinit {
478
        if let observer = ubiquitousObserver {
479
            NotificationCenter.default.removeObserver(observer)
480
        }
481
        if let observer = ubiquityIdentityObserver {
482
            NotificationCenter.default.removeObserver(observer)
483
        }
484
    }
485
}
486

            
487
private protocol KeyValueReading {
488
    func object(forKey defaultName: String) -> Any?
489
}
490

            
491
extension UserDefaults: KeyValueReading {}
492
extension NSUbiquitousKeyValueStore: KeyValueReading {}
493

            
494
extension Notification.Name {
495
    static let meterNameStoreDidChange = Notification.Name("MeterNameStoreDidChange")
496
    static let meterNameStoreCloudStatusDidChange = Notification.Name("MeterNameStoreCloudStatusDidChange")
497
}