USB-Meter / USB Meter / Model / MeterNameStore.swift
Newer Older
325 lines | 11.667kb
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?
14

            
15
        var id: String {
16
            macAddress
17
        }
18
    }
19

            
20
    enum CloudAvailability: Equatable {
21
        case unknown
22
        case available
23
        case noAccount
24
        case error(String)
25

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

            
39
        var helpMessage: String {
40
            switch self {
41
            case .unknown:
42
                return "The app is still checking whether iCloud sync is available on this device."
43
            case .available:
44
                return "iCloud sync is available for meter names and TC66 temperature preferences."
45
            case .noAccount:
46
                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."
47
            case .error(let description):
48
                return "The app keeps local values, but iCloud sync reported an error: \(description)"
49
            }
50
        }
51
    }
52

            
53
    static let shared = MeterNameStore()
54

            
55
    private enum Keys {
56
        static let localMeterNames = "MeterNameStore.localMeterNames"
57
        static let localTemperatureUnits = "MeterNameStore.localTemperatureUnits"
58
        static let cloudMeterNames = "MeterNameStore.cloudMeterNames"
59
        static let cloudTemperatureUnits = "MeterNameStore.cloudTemperatureUnits"
60
    }
61

            
62
    private let defaults = UserDefaults.standard
63
    private let ubiquitousStore = NSUbiquitousKeyValueStore.default
64
    private let workQueue = DispatchQueue(label: "MeterNameStore.Queue")
65
    private var cloudAvailability: CloudAvailability = .unknown
66
    private var ubiquitousObserver: NSObjectProtocol?
67
    private var ubiquityIdentityObserver: NSObjectProtocol?
68

            
69
    private init() {
70
        ubiquitousObserver = NotificationCenter.default.addObserver(
71
            forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
72
            object: ubiquitousStore,
73
            queue: nil
74
        ) { [weak self] notification in
75
            self?.handleUbiquitousStoreChange(notification)
76
        }
77

            
78
        ubiquityIdentityObserver = NotificationCenter.default.addObserver(
79
            forName: NSNotification.Name.NSUbiquityIdentityDidChange,
80
            object: nil,
81
            queue: nil
82
        ) { [weak self] _ in
83
            self?.refreshCloudAvailability(reason: "identity-changed")
84
            self?.syncLocalValuesToCloudIfPossible(reason: "identity-changed")
85
        }
86

            
87
        refreshCloudAvailability(reason: "startup")
88
        ubiquitousStore.synchronize()
89
        syncLocalValuesToCloudIfPossible(reason: "startup")
90
    }
91

            
92
    var currentCloudAvailability: CloudAvailability {
93
        workQueue.sync {
94
            cloudAvailability
95
        }
96
    }
97

            
98
    func name(for macAddress: String) -> String? {
99
        let normalizedMAC = normalizedMACAddress(macAddress)
100
        guard !normalizedMAC.isEmpty else { return nil }
101
        return mergedDictionary(localKey: Keys.localMeterNames, cloudKey: Keys.cloudMeterNames)[normalizedMAC]
102
    }
103

            
104
    func temperatureUnitRawValue(for macAddress: String) -> String? {
105
        let normalizedMAC = normalizedMACAddress(macAddress)
106
        guard !normalizedMAC.isEmpty else { return nil }
107
        return mergedDictionary(localKey: Keys.localTemperatureUnits, cloudKey: Keys.cloudTemperatureUnits)[normalizedMAC]
108
    }
109

            
110
    func upsert(macAddress: String, name: String?, temperatureUnitRawValue: String?) {
111
        let normalizedMAC = normalizedMACAddress(macAddress)
112
        guard !normalizedMAC.isEmpty else {
113
            track("MeterNameStore ignored upsert with invalid MAC '\(macAddress)'")
114
            return
115
        }
116

            
117
        var didChange = false
118

            
119
        if let name {
120
            didChange = updateDictionaryValue(
121
                for: normalizedMAC,
122
                value: normalizedName(name),
123
                localKey: Keys.localMeterNames,
124
                cloudKey: Keys.cloudMeterNames
125
            ) || didChange
126
        }
127

            
128
        if let temperatureUnitRawValue {
129
            didChange = updateDictionaryValue(
130
                for: normalizedMAC,
131
                value: normalizedTemperatureUnit(temperatureUnitRawValue),
132
                localKey: Keys.localTemperatureUnits,
133
                cloudKey: Keys.cloudTemperatureUnits
134
            ) || didChange
135
        }
136

            
137
        if didChange {
138
            notifyChange()
139
        }
140
    }
141

            
142
    func allRecords() -> [Record] {
143
        let names = mergedDictionary(localKey: Keys.localMeterNames, cloudKey: Keys.cloudMeterNames)
144
        let temperatureUnits = mergedDictionary(localKey: Keys.localTemperatureUnits, cloudKey: Keys.cloudTemperatureUnits)
145
        let macAddresses = Set(names.keys).union(temperatureUnits.keys)
146

            
147
        return macAddresses.sorted().map { macAddress in
148
            Record(
149
                macAddress: macAddress,
150
                customName: names[macAddress],
151
                temperatureUnit: temperatureUnits[macAddress]
152
            )
153
        }
154
    }
155

            
156
    private func normalizedMACAddress(_ macAddress: String) -> String {
157
        macAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
158
    }
159

            
160
    private func normalizedName(_ name: String?) -> String? {
161
        guard let trimmed = name?.trimmingCharacters(in: .whitespacesAndNewlines),
162
              !trimmed.isEmpty else {
163
            return nil
164
        }
165
        return trimmed
166
    }
167

            
168
    private func normalizedTemperatureUnit(_ value: String?) -> String? {
169
        guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
170
              !trimmed.isEmpty else {
171
            return nil
172
        }
173
        return trimmed
174
    }
175

            
176
    private func dictionary(for key: String, store: KeyValueReading) -> [String: String] {
177
        (store.object(forKey: key) as? [String: String]) ?? [:]
178
    }
179

            
180
    private func mergedDictionary(localKey: String, cloudKey: String) -> [String: String] {
181
        let localValues = dictionary(for: localKey, store: defaults)
182
        let cloudValues = dictionary(for: cloudKey, store: ubiquitousStore)
183
        return localValues.merging(cloudValues) { _, cloudValue in
184
            cloudValue
185
        }
186
    }
187

            
188
    @discardableResult
189
    private func updateDictionaryValue(
190
        for macAddress: String,
191
        value: String?,
192
        localKey: String,
193
        cloudKey: String
194
    ) -> Bool {
195
        var localValues = dictionary(for: localKey, store: defaults)
196
        let didChangeLocal = setDictionaryValue(&localValues, for: macAddress, value: value)
197
        if didChangeLocal {
198
            defaults.set(localValues, forKey: localKey)
199
        }
200

            
201
        var didChangeCloud = false
202
        if isICloudDriveAvailable {
203
            var cloudValues = dictionary(for: cloudKey, store: ubiquitousStore)
204
            didChangeCloud = setDictionaryValue(&cloudValues, for: macAddress, value: value)
205
            if didChangeCloud {
206
                ubiquitousStore.set(cloudValues, forKey: cloudKey)
207
                ubiquitousStore.synchronize()
208
            }
209
        }
210

            
211
        return didChangeLocal || didChangeCloud
212
    }
213

            
214
    @discardableResult
215
    private func setDictionaryValue(
216
        _ dictionary: inout [String: String],
217
        for macAddress: String,
218
        value: String?
219
    ) -> Bool {
220
        let currentValue = dictionary[macAddress]
221
        guard currentValue != value else { return false }
222
        if let value {
223
            dictionary[macAddress] = value
224
        } else {
225
            dictionary.removeValue(forKey: macAddress)
226
        }
227
        return true
228
    }
229

            
230
    private var isICloudDriveAvailable: Bool {
231
        FileManager.default.ubiquityIdentityToken != nil
232
    }
233

            
234
    private func refreshCloudAvailability(reason: String) {
235
        let newAvailability: CloudAvailability = isICloudDriveAvailable ? .available : .noAccount
236

            
237
        var shouldNotify = false
238
        workQueue.sync {
239
            guard cloudAvailability != newAvailability else { return }
240
            cloudAvailability = newAvailability
241
            shouldNotify = true
242
        }
243

            
244
        guard shouldNotify else { return }
245
        track("MeterNameStore iCloud availability (\(reason)): \(newAvailability)")
246
        DispatchQueue.main.async {
247
            NotificationCenter.default.post(name: .meterNameStoreCloudStatusDidChange, object: nil)
248
        }
249
    }
250

            
251
    private func handleUbiquitousStoreChange(_ notification: Notification) {
252
        refreshCloudAvailability(reason: "ubiquitous-store-change")
253
        if let changedKeys = notification.userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String], !changedKeys.isEmpty {
254
            track("MeterNameStore received ubiquitous changes for keys: \(changedKeys.joined(separator: ", "))")
255
        }
256
        notifyChange()
257
    }
258

            
259
    private func syncLocalValuesToCloudIfPossible(reason: String) {
260
        guard isICloudDriveAvailable else {
261
            refreshCloudAvailability(reason: reason)
262
            return
263
        }
264

            
265
        let localNames = dictionary(for: Keys.localMeterNames, store: defaults)
266
        let localTemperatureUnits = dictionary(for: Keys.localTemperatureUnits, store: defaults)
267

            
268
        var cloudNames = dictionary(for: Keys.cloudMeterNames, store: ubiquitousStore)
269
        var cloudTemperatureUnits = dictionary(for: Keys.cloudTemperatureUnits, store: ubiquitousStore)
270

            
271
        let mergedNames = cloudNames.merging(localNames) { cloudValue, _ in
272
            cloudValue
273
        }
274
        let mergedTemperatureUnits = cloudTemperatureUnits.merging(localTemperatureUnits) { cloudValue, _ in
275
            cloudValue
276
        }
277

            
278
        var didChange = false
279
        if cloudNames != mergedNames {
280
            cloudNames = mergedNames
281
            ubiquitousStore.set(cloudNames, forKey: Keys.cloudMeterNames)
282
            didChange = true
283
        }
284
        if cloudTemperatureUnits != mergedTemperatureUnits {
285
            cloudTemperatureUnits = mergedTemperatureUnits
286
            ubiquitousStore.set(cloudTemperatureUnits, forKey: Keys.cloudTemperatureUnits)
287
            didChange = true
288
        }
289

            
290
        refreshCloudAvailability(reason: reason)
291

            
292
        if didChange {
293
            ubiquitousStore.synchronize()
294
            track("MeterNameStore pushed local fallback values into iCloud KVS (\(reason)).")
295
            notifyChange()
296
        }
297
    }
298

            
299
    private func notifyChange() {
300
        DispatchQueue.main.async {
301
            NotificationCenter.default.post(name: .meterNameStoreDidChange, object: nil)
302
        }
303
    }
304

            
305
    deinit {
306
        if let observer = ubiquitousObserver {
307
            NotificationCenter.default.removeObserver(observer)
308
        }
309
        if let observer = ubiquityIdentityObserver {
310
            NotificationCenter.default.removeObserver(observer)
311
        }
312
    }
313
}
314

            
315
private protocol KeyValueReading {
316
    func object(forKey defaultName: String) -> Any?
317
}
318

            
319
extension UserDefaults: KeyValueReading {}
320
extension NSUbiquitousKeyValueStore: KeyValueReading {}
321

            
322
extension Notification.Name {
323
    static let meterNameStoreDidChange = Notification.Name("MeterNameStoreDidChange")
324
    static let meterNameStoreCloudStatusDidChange = Notification.Name("MeterNameStoreCloudStatusDidChange")
325
}