USB-Meter / USB Meter / Model / MeterNameStore.swift
Newer Older
431 lines | 16.047kb
Bogdan Timofte authored a week 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 a week ago
14
        let modelName: String?
15
        let advertisedName: String?
16
        let lastSeen: Date?
17
        let lastConnected: Date?
Bogdan Timofte authored a week 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 a week ago
60
        static let meters = "MeterNameStore.meters"
Bogdan Timofte authored a week ago
61
        static let localMeterNames = "MeterNameStore.localMeterNames"
62
        static let localTemperatureUnits = "MeterNameStore.localTemperatureUnits"
Bogdan Timofte authored a week 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 a week 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 a week 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 a week ago
131
    func registerMeter(macAddress: String, modelName: String?, advertisedName: String?) {
Bogdan Timofte authored a week ago
132
        let normalizedMAC = normalizedMACAddress(macAddress)
133
        guard !normalizedMAC.isEmpty else {
Bogdan Timofte authored a week ago
134
            track("MeterNameStore ignored meter registration with invalid MAC '\(macAddress)'")
Bogdan Timofte authored a week ago
135
            return
136
        }
137

            
138
        var didChange = false
Bogdan Timofte authored a week ago
139
        didChange = updateMetersSet(normalizedMAC) || didChange
Bogdan Timofte authored a week 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 a week 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 a week ago
174
        didChange = updateMetersSet(normalizedMAC) || didChange
Bogdan Timofte authored a week 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

            
199
    func allRecords() -> [Record] {
200
        let names = mergedDictionary(localKey: Keys.localMeterNames, cloudKey: Keys.cloudMeterNames)
201
        let temperatureUnits = mergedDictionary(localKey: Keys.localTemperatureUnits, cloudKey: Keys.cloudTemperatureUnits)
Bogdan Timofte authored a week ago
202
        let modelNames = dictionary(for: Keys.localModelNames, store: defaults)
203
        let advertisedNames = dictionary(for: Keys.localAdvertisedNames, store: defaults)
204
        let lastSeenValues = dateDictionary(for: Keys.localLastSeen)
205
        let lastConnectedValues = dateDictionary(for: Keys.localLastConnected)
Bogdan Timofte authored a week ago
206
        let macAddresses = meters()
Bogdan Timofte authored a week ago
207
            .union(names.keys)
208
            .union(temperatureUnits.keys)
209
            .union(modelNames.keys)
210
            .union(advertisedNames.keys)
211
            .union(lastSeenValues.keys)
212
            .union(lastConnectedValues.keys)
Bogdan Timofte authored a week ago
213

            
214
        return macAddresses.sorted().map { macAddress in
215
            Record(
216
                macAddress: macAddress,
217
                customName: names[macAddress],
Bogdan Timofte authored a week ago
218
                temperatureUnit: temperatureUnits[macAddress],
219
                modelName: modelNames[macAddress],
220
                advertisedName: advertisedNames[macAddress],
221
                lastSeen: lastSeenValues[macAddress],
222
                lastConnected: lastConnectedValues[macAddress]
Bogdan Timofte authored a week ago
223
            )
224
        }
225
    }
226

            
227
    private func normalizedMACAddress(_ macAddress: String) -> String {
228
        macAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
229
    }
230

            
231
    private func normalizedName(_ name: String?) -> String? {
232
        guard let trimmed = name?.trimmingCharacters(in: .whitespacesAndNewlines),
233
              !trimmed.isEmpty else {
234
            return nil
235
        }
236
        return trimmed
237
    }
238

            
239
    private func normalizedTemperatureUnit(_ value: String?) -> String? {
240
        guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
241
              !trimmed.isEmpty else {
242
            return nil
243
        }
244
        return trimmed
245
    }
246

            
247
    private func dictionary(for key: String, store: KeyValueReading) -> [String: String] {
248
        (store.object(forKey: key) as? [String: String]) ?? [:]
249
    }
250

            
Bogdan Timofte authored a week ago
251
    private func dateDictionary(for key: String) -> [String: Date] {
252
        let rawValues = (defaults.object(forKey: key) as? [String: TimeInterval]) ?? [:]
253
        return rawValues.mapValues(Date.init(timeIntervalSince1970:))
254
    }
255

            
Bogdan Timofte authored a week ago
256
    private func meters() -> Set<String> {
257
        Set((defaults.array(forKey: Keys.meters) as? [String]) ?? [])
Bogdan Timofte authored a week ago
258
    }
259

            
Bogdan Timofte authored a week ago
260
    private func mergedDictionary(localKey: String, cloudKey: String) -> [String: String] {
261
        let localValues = dictionary(for: localKey, store: defaults)
262
        let cloudValues = dictionary(for: cloudKey, store: ubiquitousStore)
263
        return localValues.merging(cloudValues) { _, cloudValue in
264
            cloudValue
265
        }
266
    }
267

            
Bogdan Timofte authored a week ago
268
    @discardableResult
Bogdan Timofte authored a week ago
269
    private func updateMetersSet(_ macAddress: String) -> Bool {
270
        var known = meters()
Bogdan Timofte authored a week ago
271
        let initialCount = known.count
272
        known.insert(macAddress)
273
        guard known.count != initialCount else { return false }
Bogdan Timofte authored a week ago
274
        defaults.set(Array(known).sorted(), forKey: Keys.meters)
Bogdan Timofte authored a week ago
275
        return true
276
    }
277

            
Bogdan Timofte authored a week ago
278
    @discardableResult
279
    private func updateDictionaryValue(
280
        for macAddress: String,
281
        value: String?,
282
        localKey: String,
Bogdan Timofte authored a week ago
283
        cloudKey: String?
Bogdan Timofte authored a week ago
284
    ) -> Bool {
285
        var localValues = dictionary(for: localKey, store: defaults)
286
        let didChangeLocal = setDictionaryValue(&localValues, for: macAddress, value: value)
287
        if didChangeLocal {
288
            defaults.set(localValues, forKey: localKey)
289
        }
290

            
291
        var didChangeCloud = false
Bogdan Timofte authored a week ago
292
        if let cloudKey, isICloudDriveAvailable {
Bogdan Timofte authored a week ago
293
            var cloudValues = dictionary(for: cloudKey, store: ubiquitousStore)
294
            didChangeCloud = setDictionaryValue(&cloudValues, for: macAddress, value: value)
295
            if didChangeCloud {
296
                ubiquitousStore.set(cloudValues, forKey: cloudKey)
297
                ubiquitousStore.synchronize()
298
            }
299
        }
300

            
301
        return didChangeLocal || didChangeCloud
302
    }
303

            
304
    @discardableResult
305
    private func setDictionaryValue(
306
        _ dictionary: inout [String: String],
307
        for macAddress: String,
308
        value: String?
309
    ) -> Bool {
310
        let currentValue = dictionary[macAddress]
311
        guard currentValue != value else { return false }
312
        if let value {
313
            dictionary[macAddress] = value
314
        } else {
315
            dictionary.removeValue(forKey: macAddress)
316
        }
317
        return true
318
    }
319

            
Bogdan Timofte authored a week ago
320
    private func updateDate(_ date: Date, for macAddress: String, key: String) {
321
        let normalizedMAC = normalizedMACAddress(macAddress)
322
        guard !normalizedMAC.isEmpty else {
323
            track("MeterNameStore ignored date update with invalid MAC '\(macAddress)'")
324
            return
325
        }
326

            
327
        var values = (defaults.object(forKey: key) as? [String: TimeInterval]) ?? [:]
328
        let timeInterval = date.timeIntervalSince1970
329
        guard values[normalizedMAC] != timeInterval else { return }
330
        values[normalizedMAC] = timeInterval
331
        defaults.set(values, forKey: key)
Bogdan Timofte authored a week ago
332
        _ = updateMetersSet(normalizedMAC)
Bogdan Timofte authored a week ago
333
        notifyChange()
334
    }
335

            
Bogdan Timofte authored a week ago
336
    private var isICloudDriveAvailable: Bool {
337
        FileManager.default.ubiquityIdentityToken != nil
338
    }
339

            
340
    private func refreshCloudAvailability(reason: String) {
341
        let newAvailability: CloudAvailability = isICloudDriveAvailable ? .available : .noAccount
342

            
343
        var shouldNotify = false
344
        workQueue.sync {
345
            guard cloudAvailability != newAvailability else { return }
346
            cloudAvailability = newAvailability
347
            shouldNotify = true
348
        }
349

            
350
        guard shouldNotify else { return }
351
        track("MeterNameStore iCloud availability (\(reason)): \(newAvailability)")
352
        DispatchQueue.main.async {
353
            NotificationCenter.default.post(name: .meterNameStoreCloudStatusDidChange, object: nil)
354
        }
355
    }
356

            
357
    private func handleUbiquitousStoreChange(_ notification: Notification) {
358
        refreshCloudAvailability(reason: "ubiquitous-store-change")
359
        if let changedKeys = notification.userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String], !changedKeys.isEmpty {
360
            track("MeterNameStore received ubiquitous changes for keys: \(changedKeys.joined(separator: ", "))")
361
        }
362
        notifyChange()
363
    }
364

            
365
    private func syncLocalValuesToCloudIfPossible(reason: String) {
366
        guard isICloudDriveAvailable else {
367
            refreshCloudAvailability(reason: reason)
368
            return
369
        }
370

            
371
        let localNames = dictionary(for: Keys.localMeterNames, store: defaults)
372
        let localTemperatureUnits = dictionary(for: Keys.localTemperatureUnits, store: defaults)
373

            
374
        var cloudNames = dictionary(for: Keys.cloudMeterNames, store: ubiquitousStore)
375
        var cloudTemperatureUnits = dictionary(for: Keys.cloudTemperatureUnits, store: ubiquitousStore)
376

            
377
        let mergedNames = cloudNames.merging(localNames) { cloudValue, _ in
378
            cloudValue
379
        }
380
        let mergedTemperatureUnits = cloudTemperatureUnits.merging(localTemperatureUnits) { cloudValue, _ in
381
            cloudValue
382
        }
383

            
384
        var didChange = false
385
        if cloudNames != mergedNames {
386
            cloudNames = mergedNames
387
            ubiquitousStore.set(cloudNames, forKey: Keys.cloudMeterNames)
388
            didChange = true
389
        }
390
        if cloudTemperatureUnits != mergedTemperatureUnits {
391
            cloudTemperatureUnits = mergedTemperatureUnits
392
            ubiquitousStore.set(cloudTemperatureUnits, forKey: Keys.cloudTemperatureUnits)
393
            didChange = true
394
        }
395

            
396
        refreshCloudAvailability(reason: reason)
397

            
398
        if didChange {
399
            ubiquitousStore.synchronize()
400
            track("MeterNameStore pushed local fallback values into iCloud KVS (\(reason)).")
401
            notifyChange()
402
        }
403
    }
404

            
405
    private func notifyChange() {
406
        DispatchQueue.main.async {
407
            NotificationCenter.default.post(name: .meterNameStoreDidChange, object: nil)
408
        }
409
    }
410

            
411
    deinit {
412
        if let observer = ubiquitousObserver {
413
            NotificationCenter.default.removeObserver(observer)
414
        }
415
        if let observer = ubiquityIdentityObserver {
416
            NotificationCenter.default.removeObserver(observer)
417
        }
418
    }
419
}
420

            
421
private protocol KeyValueReading {
422
    func object(forKey defaultName: String) -> Any?
423
}
424

            
425
extension UserDefaults: KeyValueReading {}
426
extension NSUbiquitousKeyValueStore: KeyValueReading {}
427

            
428
extension Notification.Name {
429
    static let meterNameStoreDidChange = Notification.Name("MeterNameStoreDidChange")
430
    static let meterNameStoreCloudStatusDidChange = Notification.Name("MeterNameStoreCloudStatusDidChange")
431
}