USB-Meter / USB Meter / Model / MeterNameStore.swift
1 contributor
431 lines | 16.047kb
//  MeterNameStore.swift
//  USB Meter
//
//  Created by Codex on 2026.
//

import Foundation

final class MeterNameStore {
    struct Record: Identifiable {
        let macAddress: String
        let customName: String?
        let temperatureUnit: String?
        let modelName: String?
        let advertisedName: String?
        let lastSeen: Date?
        let lastConnected: Date?

        var id: String {
            macAddress
        }
    }

    enum CloudAvailability: Equatable {
        case unknown
        case available
        case noAccount
        case error(String)

        var helpTitle: String {
            switch self {
            case .unknown:
                return "Cloud Sync Status Unknown"
            case .available:
                return "Cloud Sync Ready"
            case .noAccount:
                return "Enable iCloud Drive"
            case .error:
                return "Cloud Sync Error"
            }
        }

        var helpMessage: String {
            switch self {
            case .unknown:
                return "The app is still checking whether iCloud sync is available on this device."
            case .available:
                return "iCloud sync is available for meter names and TC66 temperature preferences."
            case .noAccount:
                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."
            case .error(let description):
                return "The app keeps local values, but iCloud sync reported an error: \(description)"
            }
        }
    }

    static let shared = MeterNameStore()

    private enum Keys {
        static let meters = "MeterNameStore.meters"
        static let localMeterNames = "MeterNameStore.localMeterNames"
        static let localTemperatureUnits = "MeterNameStore.localTemperatureUnits"
        static let localModelNames = "MeterNameStore.localModelNames"
        static let localAdvertisedNames = "MeterNameStore.localAdvertisedNames"
        static let localLastSeen = "MeterNameStore.localLastSeen"
        static let localLastConnected = "MeterNameStore.localLastConnected"
        static let cloudMeterNames = "MeterNameStore.cloudMeterNames"
        static let cloudTemperatureUnits = "MeterNameStore.cloudTemperatureUnits"
    }

    private let defaults = UserDefaults.standard
    private let ubiquitousStore = NSUbiquitousKeyValueStore.default
    private let workQueue = DispatchQueue(label: "MeterNameStore.Queue")
    private var cloudAvailability: CloudAvailability = .unknown
    private var ubiquitousObserver: NSObjectProtocol?
    private var ubiquityIdentityObserver: NSObjectProtocol?

    private init() {
        ubiquitousObserver = NotificationCenter.default.addObserver(
            forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
            object: ubiquitousStore,
            queue: nil
        ) { [weak self] notification in
            self?.handleUbiquitousStoreChange(notification)
        }

        ubiquityIdentityObserver = NotificationCenter.default.addObserver(
            forName: NSNotification.Name.NSUbiquityIdentityDidChange,
            object: nil,
            queue: nil
        ) { [weak self] _ in
            self?.refreshCloudAvailability(reason: "identity-changed")
            self?.syncLocalValuesToCloudIfPossible(reason: "identity-changed")
        }

        refreshCloudAvailability(reason: "startup")
        ubiquitousStore.synchronize()
        syncLocalValuesToCloudIfPossible(reason: "startup")
    }

    var currentCloudAvailability: CloudAvailability {
        workQueue.sync {
            cloudAvailability
        }
    }

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

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

    func lastSeen(for macAddress: String) -> Date? {
        let normalizedMAC = normalizedMACAddress(macAddress)
        guard !normalizedMAC.isEmpty else { return nil }
        return dateDictionary(for: Keys.localLastSeen)[normalizedMAC]
    }

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

    func registerMeter(macAddress: String, modelName: String?, advertisedName: String?) {
        let normalizedMAC = normalizedMACAddress(macAddress)
        guard !normalizedMAC.isEmpty else {
            track("MeterNameStore ignored meter registration with invalid MAC '\(macAddress)'")
            return
        }

        var didChange = false
        didChange = updateMetersSet(normalizedMAC) || didChange
        didChange = updateDictionaryValue(
            for: normalizedMAC,
            value: normalizedName(modelName),
            localKey: Keys.localModelNames,
            cloudKey: nil
        ) || didChange
        didChange = updateDictionaryValue(
            for: normalizedMAC,
            value: normalizedName(advertisedName),
            localKey: Keys.localAdvertisedNames,
            cloudKey: nil
        ) || didChange

        if didChange {
            notifyChange()
        }
    }

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

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

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

        var didChange = false
        didChange = updateMetersSet(normalizedMAC) || didChange

        if let name {
            didChange = updateDictionaryValue(
                for: normalizedMAC,
                value: normalizedName(name),
                localKey: Keys.localMeterNames,
                cloudKey: Keys.cloudMeterNames
            ) || didChange
        }

        if let temperatureUnitRawValue {
            didChange = updateDictionaryValue(
                for: normalizedMAC,
                value: normalizedTemperatureUnit(temperatureUnitRawValue),
                localKey: Keys.localTemperatureUnits,
                cloudKey: Keys.cloudTemperatureUnits
            ) || didChange
        }

        if didChange {
            notifyChange()
        }
    }

    func allRecords() -> [Record] {
        let names = mergedDictionary(localKey: Keys.localMeterNames, cloudKey: Keys.cloudMeterNames)
        let temperatureUnits = mergedDictionary(localKey: Keys.localTemperatureUnits, cloudKey: Keys.cloudTemperatureUnits)
        let modelNames = dictionary(for: Keys.localModelNames, store: defaults)
        let advertisedNames = dictionary(for: Keys.localAdvertisedNames, store: defaults)
        let lastSeenValues = dateDictionary(for: Keys.localLastSeen)
        let lastConnectedValues = dateDictionary(for: Keys.localLastConnected)
        let macAddresses = meters()
            .union(names.keys)
            .union(temperatureUnits.keys)
            .union(modelNames.keys)
            .union(advertisedNames.keys)
            .union(lastSeenValues.keys)
            .union(lastConnectedValues.keys)

        return macAddresses.sorted().map { macAddress in
            Record(
                macAddress: macAddress,
                customName: names[macAddress],
                temperatureUnit: temperatureUnits[macAddress],
                modelName: modelNames[macAddress],
                advertisedName: advertisedNames[macAddress],
                lastSeen: lastSeenValues[macAddress],
                lastConnected: lastConnectedValues[macAddress]
            )
        }
    }

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

    private func normalizedName(_ name: String?) -> String? {
        guard let trimmed = name?.trimmingCharacters(in: .whitespacesAndNewlines),
              !trimmed.isEmpty else {
            return nil
        }
        return trimmed
    }

    private func normalizedTemperatureUnit(_ value: String?) -> String? {
        guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
              !trimmed.isEmpty else {
            return nil
        }
        return trimmed
    }

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

    private func dateDictionary(for key: String) -> [String: Date] {
        let rawValues = (defaults.object(forKey: key) as? [String: TimeInterval]) ?? [:]
        return rawValues.mapValues(Date.init(timeIntervalSince1970:))
    }

    private func meters() -> Set<String> {
        Set((defaults.array(forKey: Keys.meters) as? [String]) ?? [])
    }

    private func mergedDictionary(localKey: String, cloudKey: String) -> [String: String] {
        let localValues = dictionary(for: localKey, store: defaults)
        let cloudValues = dictionary(for: cloudKey, store: ubiquitousStore)
        return localValues.merging(cloudValues) { _, cloudValue in
            cloudValue
        }
    }

    @discardableResult
    private func updateMetersSet(_ macAddress: String) -> Bool {
        var known = meters()
        let initialCount = known.count
        known.insert(macAddress)
        guard known.count != initialCount else { return false }
        defaults.set(Array(known).sorted(), forKey: Keys.meters)
        return true
    }

    @discardableResult
    private func updateDictionaryValue(
        for macAddress: String,
        value: String?,
        localKey: String,
        cloudKey: String?
    ) -> Bool {
        var localValues = dictionary(for: localKey, store: defaults)
        let didChangeLocal = setDictionaryValue(&localValues, for: macAddress, value: value)
        if didChangeLocal {
            defaults.set(localValues, forKey: localKey)
        }

        var didChangeCloud = false
        if let cloudKey, isICloudDriveAvailable {
            var cloudValues = dictionary(for: cloudKey, store: ubiquitousStore)
            didChangeCloud = setDictionaryValue(&cloudValues, for: macAddress, value: value)
            if didChangeCloud {
                ubiquitousStore.set(cloudValues, forKey: cloudKey)
                ubiquitousStore.synchronize()
            }
        }

        return didChangeLocal || didChangeCloud
    }

    @discardableResult
    private func setDictionaryValue(
        _ dictionary: inout [String: String],
        for macAddress: String,
        value: String?
    ) -> Bool {
        let currentValue = dictionary[macAddress]
        guard currentValue != value else { return false }
        if let value {
            dictionary[macAddress] = value
        } else {
            dictionary.removeValue(forKey: macAddress)
        }
        return true
    }

    private func updateDate(_ date: Date, for macAddress: String, key: String) {
        let normalizedMAC = normalizedMACAddress(macAddress)
        guard !normalizedMAC.isEmpty else {
            track("MeterNameStore ignored date update with invalid MAC '\(macAddress)'")
            return
        }

        var values = (defaults.object(forKey: key) as? [String: TimeInterval]) ?? [:]
        let timeInterval = date.timeIntervalSince1970
        guard values[normalizedMAC] != timeInterval else { return }
        values[normalizedMAC] = timeInterval
        defaults.set(values, forKey: key)
        _ = updateMetersSet(normalizedMAC)
        notifyChange()
    }

    private var isICloudDriveAvailable: Bool {
        FileManager.default.ubiquityIdentityToken != nil
    }

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

        var shouldNotify = false
        workQueue.sync {
            guard cloudAvailability != newAvailability else { return }
            cloudAvailability = newAvailability
            shouldNotify = true
        }

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

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

    private func syncLocalValuesToCloudIfPossible(reason: String) {
        guard isICloudDriveAvailable else {
            refreshCloudAvailability(reason: reason)
            return
        }

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

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

        let mergedNames = cloudNames.merging(localNames) { cloudValue, _ in
            cloudValue
        }
        let mergedTemperatureUnits = cloudTemperatureUnits.merging(localTemperatureUnits) { cloudValue, _ in
            cloudValue
        }

        var didChange = false
        if cloudNames != mergedNames {
            cloudNames = mergedNames
            ubiquitousStore.set(cloudNames, forKey: Keys.cloudMeterNames)
            didChange = true
        }
        if cloudTemperatureUnits != mergedTemperatureUnits {
            cloudTemperatureUnits = mergedTemperatureUnits
            ubiquitousStore.set(cloudTemperatureUnits, forKey: Keys.cloudTemperatureUnits)
            didChange = true
        }

        refreshCloudAvailability(reason: reason)

        if didChange {
            ubiquitousStore.synchronize()
            track("MeterNameStore pushed local fallback values into iCloud KVS (\(reason)).")
            notifyChange()
        }
    }

    private func notifyChange() {
        DispatchQueue.main.async {
            NotificationCenter.default.post(name: .meterNameStoreDidChange, object: nil)
        }
    }

    deinit {
        if let observer = ubiquitousObserver {
            NotificationCenter.default.removeObserver(observer)
        }
        if let observer = ubiquityIdentityObserver {
            NotificationCenter.default.removeObserver(observer)
        }
    }
}

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

extension UserDefaults: KeyValueReading {}
extension NSUbiquitousKeyValueStore: KeyValueReading {}

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