1 contributor
//
// DataStore.swift
// USB Meter
//
// Created by Bogdan Timofte on 03/03/2020.
// Copyright © 2020 Bogdan Timofte. All rights reserved.
//
import SwiftUI
import Combine
import CoreBluetooth
import CoreData
import UIKit
import Foundation
// MARK: - Device Name Helper
private func getDeviceName() -> String {
#if os(macOS)
// On macOS (Catalyst, iPad App on Mac), use hostname instead of UIDevice.current.name
let hostname = ProcessInfo.processInfo.hostName
return hostname.trimmingCharacters(in: .whitespacesAndNewlines)
#else
// On iOS/iPadOS, use device name
return UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines)
#endif
}
final class AppData : ObservableObject {
static let myDeviceID: String = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString
static let myDeviceName: String = getDeviceName()
private static let cloudStoreRebuildVersion = 3
private var icloudDefaultsNotification: AnyCancellable?
private var bluetoothManagerNotification: AnyCancellable?
private var coreDataSettingsChangeNotification: AnyCancellable?
private var cloudSettingsRefreshTimer: AnyCancellable?
private var cloudDeviceSettingsStore: CloudDeviceSettingsStore?
private var hasMigratedLegacyDeviceSettings = false
private var persistedMeterNames: [String: String] = [:]
private var persistedTC66TemperatureUnits: [String: String] = [:]
init() {
persistedMeterNames = legacyMeterNames
persistedTC66TemperatureUnits = legacyTC66TemperatureUnits
icloudDefaultsNotification = NotificationCenter.default.publisher(for: NSUbiquitousKeyValueStore.didChangeExternallyNotification).sink(receiveValue: handleLegacyICloudDefaultsChange)
bluetoothManagerNotification = bluetoothManager.objectWillChange.sink { [weak self] _ in
self?.scheduleObjectWillChange()
}
}
let bluetoothManager = BluetoothManager()
@Published var enableRecordFeature: Bool = true
@Published var meters: [UUID:Meter] = [UUID:Meter]()
@Published private(set) var knownMetersByMAC: [String: KnownMeterCatalogItem] = [:]
@ICloudDefault(key: "MeterNames", defaultValue: [:]) private var legacyMeterNames: [String:String]
@ICloudDefault(key: "TC66TemperatureUnits", defaultValue: [:]) private var legacyTC66TemperatureUnits: [String:String]
func activateCloudDeviceSync(context: NSManagedObjectContext) {
guard cloudDeviceSettingsStore == nil else {
return
}
context.automaticallyMergesChangesFromParent = true
// Prefer incoming/store values on conflict so frequent local discovery updates
// do not wipe remote connection claims from other devices.
context.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy
cloudDeviceSettingsStore = CloudDeviceSettingsStore(context: context)
cloudDeviceSettingsStore?.rebuildCanonicalStoreIfNeeded(version: Self.cloudStoreRebuildVersion)
cloudDeviceSettingsStore?.compactDuplicateEntriesByMAC()
coreDataSettingsChangeNotification = NotificationCenter.default.publisher(for: .NSManagedObjectContextObjectsDidChange, object: context)
.sink { [weak self] _ in
self?.reloadSettingsFromCloudStore(applyToMeters: true)
}
cloudSettingsRefreshTimer = Timer.publish(every: 10, on: .main, in: .common)
.autoconnect()
.sink { [weak self] _ in
self?.reloadSettingsFromCloudStore(applyToMeters: true)
}
reloadSettingsFromCloudStore(applyToMeters: false)
migrateLegacySettingsIntoCloudIfNeeded()
cloudDeviceSettingsStore?.clearAllConnections(byDeviceID: Self.myDeviceID)
reloadSettingsFromCloudStore(applyToMeters: true)
}
func persistedMeterName(for macAddress: String) -> String? {
persistedMeterNames[macAddress]
}
func publishMeterConnection(macAddress: String, modelType: String) {
cloudDeviceSettingsStore?.setConnection(
macAddress: macAddress,
deviceID: Self.myDeviceID,
deviceName: Self.myDeviceName,
modelType: modelType
)
}
func registerMeterDiscovery(macAddress: String, modelType: String, peripheralName: String?) {
cloudDeviceSettingsStore?.recordDiscovery(
macAddress: macAddress,
modelType: modelType,
peripheralName: peripheralName,
seenByDeviceID: Self.myDeviceID,
seenByDeviceName: Self.myDeviceName
)
}
func clearMeterConnection(macAddress: String) {
cloudDeviceSettingsStore?.clearConnection(macAddress: macAddress, byDeviceID: Self.myDeviceID)
}
func persistMeterName(_ name: String, for macAddress: String) {
let normalized = name.trimmingCharacters(in: .whitespacesAndNewlines)
persistedMeterNames[macAddress] = normalized
var legacyValues = legacyMeterNames
legacyValues[macAddress] = normalized
legacyMeterNames = legacyValues
cloudDeviceSettingsStore?.upsert(macAddress: macAddress, meterName: normalized, tc66TemperatureUnit: nil)
}
func persistedTC66TemperatureUnitRawValue(for macAddress: String) -> String? {
persistedTC66TemperatureUnits[macAddress]
}
func persistTC66TemperatureUnit(rawValue: String, for macAddress: String) {
persistedTC66TemperatureUnits[macAddress] = rawValue
var legacyValues = legacyTC66TemperatureUnits
legacyValues[macAddress] = rawValue
legacyTC66TemperatureUnits = legacyValues
cloudDeviceSettingsStore?.upsert(macAddress: macAddress, meterName: nil, tc66TemperatureUnit: rawValue)
}
private func handleLegacyICloudDefaultsChange(notification: NotificationCenter.Publisher.Output) {
if let changedKeys = notification.userInfo?["NSUbiquitousKeyValueStoreChangedKeysKey"] as? [String] {
var requiresMeterRefresh = false
for changedKey in changedKeys {
switch changedKey {
case "MeterNames":
persistedMeterNames = legacyMeterNames
requiresMeterRefresh = true
case "TC66TemperatureUnits":
persistedTC66TemperatureUnits = legacyTC66TemperatureUnits
requiresMeterRefresh = true
default:
track("Unknown key: '\(changedKey)' changed in iCloud)")
}
}
if requiresMeterRefresh {
migrateLegacySettingsIntoCloudIfNeeded(force: true)
applyPersistedSettingsToKnownMeters()
scheduleObjectWillChange()
}
}
}
private func migrateLegacySettingsIntoCloudIfNeeded(force: Bool = false) {
guard let cloudDeviceSettingsStore else {
return
}
if hasMigratedLegacyDeviceSettings && !force {
return
}
let cloudRecords = cloudDeviceSettingsStore.fetchByMacAddress()
for (macAddress, meterName) in legacyMeterNames {
let cloudName = cloudRecords[macAddress]?.meterName
if cloudName == nil || cloudName?.isEmpty == true {
cloudDeviceSettingsStore.upsert(macAddress: macAddress, meterName: meterName, tc66TemperatureUnit: nil)
}
}
for (macAddress, unitRawValue) in legacyTC66TemperatureUnits {
let cloudUnit = cloudRecords[macAddress]?.tc66TemperatureUnit
if cloudUnit == nil || cloudUnit?.isEmpty == true {
cloudDeviceSettingsStore.upsert(macAddress: macAddress, meterName: nil, tc66TemperatureUnit: unitRawValue)
}
}
hasMigratedLegacyDeviceSettings = true
}
private func reloadSettingsFromCloudStore(applyToMeters: Bool) {
guard let cloudDeviceSettingsStore else {
return
}
let records = cloudDeviceSettingsStore.fetchAll()
var names = persistedMeterNames
var temperatureUnits = persistedTC66TemperatureUnits
var knownMeters: [String: KnownMeterCatalogItem] = [:]
for record in records {
if let meterName = record.meterName, !meterName.isEmpty {
names[record.macAddress] = meterName
}
if let unitRawValue = record.tc66TemperatureUnit, !unitRawValue.isEmpty {
temperatureUnits[record.macAddress] = unitRawValue
}
let displayName: String
if let meterName = record.meterName?.trimmingCharacters(in: .whitespacesAndNewlines), !meterName.isEmpty {
displayName = meterName
} else if let peripheralName = record.lastSeenPeripheralName?.trimmingCharacters(in: .whitespacesAndNewlines), !peripheralName.isEmpty {
displayName = peripheralName
} else {
displayName = record.macAddress
}
knownMeters[record.macAddress] = KnownMeterCatalogItem(
macAddress: record.macAddress,
displayName: displayName,
modelType: record.modelType,
connectedByDeviceID: record.connectedByDeviceID,
connectedByDeviceName: record.connectedByDeviceName,
connectedAt: record.connectedAt,
connectedExpiryAt: record.connectedExpiryAt,
lastSeenByDeviceID: record.lastSeenByDeviceID,
lastSeenByDeviceName: record.lastSeenByDeviceName,
lastSeenAt: record.lastSeenAt,
lastSeenPeripheralName: record.lastSeenPeripheralName
)
}
persistedMeterNames = names
persistedTC66TemperatureUnits = temperatureUnits
knownMetersByMAC = knownMeters
if applyToMeters {
applyPersistedSettingsToKnownMeters()
scheduleObjectWillChange()
}
}
private func applyPersistedSettingsToKnownMeters() {
for meter in meters.values {
let macAddress = meter.btSerial.macAddress.description
if let newName = persistedMeterNames[macAddress], meter.name != newName {
meter.name = newName
}
if meter.supportsManualTemperatureUnitSelection {
meter.reloadTemperatureUnitPreference()
}
}
}
private func scheduleObjectWillChange() {
DispatchQueue.main.async { [weak self] in
self?.objectWillChange.send()
}
}
}
struct KnownMeterCatalogItem: Identifiable, Hashable {
var id: String { macAddress }
let macAddress: String
let displayName: String
let modelType: String?
let connectedByDeviceID: String?
let connectedByDeviceName: String?
let connectedAt: Date?
let connectedExpiryAt: Date?
let lastSeenByDeviceID: String?
let lastSeenByDeviceName: String?
let lastSeenAt: Date?
let lastSeenPeripheralName: String?
}
private struct CloudDeviceSettingsRecord {
let macAddress: String
let meterName: String?
let tc66TemperatureUnit: String?
let modelType: String?
let connectedByDeviceID: String?
let connectedByDeviceName: String?
let connectedAt: Date?
let connectedExpiryAt: Date?
let lastSeenAt: Date?
let lastSeenByDeviceID: String?
let lastSeenByDeviceName: String?
let lastSeenPeripheralName: String?
}
private final class CloudDeviceSettingsStore {
private let entityName = "DeviceSettings"
private let context: NSManagedObjectContext
private static let rebuildDefaultsKeyPrefix = "CloudDeviceSettingsStore.RebuildVersion"
init(context: NSManagedObjectContext) {
self.context = context
}
private func refreshContextObjects() {
context.processPendingChanges()
}
private func normalizedMACAddress(_ value: String) -> String {
value.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
}
private func fetchObjects(for macAddress: String) throws -> [NSManagedObject] {
let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
request.predicate = NSPredicate(format: "macAddress == %@", macAddress)
return try context.fetch(request)
}
private func hasValue(_ value: String?) -> Bool {
guard let value else { return false }
return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private func preferredObject(from objects: [NSManagedObject]) -> NSManagedObject? {
guard !objects.isEmpty else { return nil }
let now = Date()
return objects.max { lhs, rhs in
let lhsHasOwner = hasValue(lhs.value(forKey: "connectedByDeviceID") as? String)
let rhsHasOwner = hasValue(rhs.value(forKey: "connectedByDeviceID") as? String)
if lhsHasOwner != rhsHasOwner {
return !lhsHasOwner && rhsHasOwner
}
let lhsOwnerLive = ((lhs.value(forKey: "connectedExpiryAt") as? Date) ?? .distantPast) > now
let rhsOwnerLive = ((rhs.value(forKey: "connectedExpiryAt") as? Date) ?? .distantPast) > now
if lhsOwnerLive != rhsOwnerLive {
return !lhsOwnerLive && rhsOwnerLive
}
let lhsUpdatedAt = (lhs.value(forKey: "updatedAt") as? Date) ?? .distantPast
let rhsUpdatedAt = (rhs.value(forKey: "updatedAt") as? Date) ?? .distantPast
if lhsUpdatedAt != rhsUpdatedAt {
return lhsUpdatedAt < rhsUpdatedAt
}
let lhsConnectedAt = (lhs.value(forKey: "connectedAt") as? Date) ?? .distantPast
let rhsConnectedAt = (rhs.value(forKey: "connectedAt") as? Date) ?? .distantPast
return lhsConnectedAt < rhsConnectedAt
}
}
private func record(from object: NSManagedObject, macAddress: String) -> CloudDeviceSettingsRecord {
let ownerID = stringValue(object, key: "connectedByDeviceID")
return CloudDeviceSettingsRecord(
macAddress: macAddress,
meterName: object.value(forKey: "meterName") as? String,
tc66TemperatureUnit: object.value(forKey: "tc66TemperatureUnit") as? String,
modelType: object.value(forKey: "modelType") as? String,
connectedByDeviceID: ownerID,
connectedByDeviceName: ownerID == nil ? nil : stringValue(object, key: "connectedByDeviceName"),
connectedAt: ownerID == nil ? nil : dateValue(object, key: "connectedAt"),
connectedExpiryAt: ownerID == nil ? nil : dateValue(object, key: "connectedExpiryAt"),
lastSeenAt: object.value(forKey: "lastSeenAt") as? Date,
lastSeenByDeviceID: object.value(forKey: "lastSeenByDeviceID") as? String,
lastSeenByDeviceName: object.value(forKey: "lastSeenByDeviceName") as? String,
lastSeenPeripheralName: object.value(forKey: "lastSeenPeripheralName") as? String
)
}
private func stringValue(_ object: NSManagedObject, key: String) -> String? {
guard let value = object.value(forKey: key) as? String else { return nil }
let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines)
return normalized.isEmpty ? nil : normalized
}
private func dateValue(_ object: NSManagedObject, key: String) -> Date? {
object.value(forKey: key) as? Date
}
private func mergeBestValues(from source: NSManagedObject, into destination: NSManagedObject) {
if stringValue(destination, key: "meterName") == nil, let value = stringValue(source, key: "meterName") {
destination.setValue(value, forKey: "meterName")
}
if stringValue(destination, key: "tc66TemperatureUnit") == nil, let value = stringValue(source, key: "tc66TemperatureUnit") {
destination.setValue(value, forKey: "tc66TemperatureUnit")
}
if stringValue(destination, key: "modelType") == nil, let value = stringValue(source, key: "modelType") {
destination.setValue(value, forKey: "modelType")
}
let sourceConnectedExpiry = dateValue(source, key: "connectedExpiryAt") ?? .distantPast
let destinationConnectedExpiry = dateValue(destination, key: "connectedExpiryAt") ?? .distantPast
let destinationOwner = stringValue(destination, key: "connectedByDeviceID")
let sourceOwner = stringValue(source, key: "connectedByDeviceID")
if sourceOwner != nil && (destinationOwner == nil || sourceConnectedExpiry > destinationConnectedExpiry) {
destination.setValue(sourceOwner, forKey: "connectedByDeviceID")
destination.setValue(stringValue(source, key: "connectedByDeviceName"), forKey: "connectedByDeviceName")
destination.setValue(dateValue(source, key: "connectedAt"), forKey: "connectedAt")
destination.setValue(dateValue(source, key: "connectedExpiryAt"), forKey: "connectedExpiryAt")
}
let sourceLastSeen = dateValue(source, key: "lastSeenAt") ?? .distantPast
let destinationLastSeen = dateValue(destination, key: "lastSeenAt") ?? .distantPast
if sourceLastSeen > destinationLastSeen {
destination.setValue(dateValue(source, key: "lastSeenAt"), forKey: "lastSeenAt")
destination.setValue(stringValue(source, key: "lastSeenByDeviceID"), forKey: "lastSeenByDeviceID")
destination.setValue(stringValue(source, key: "lastSeenByDeviceName"), forKey: "lastSeenByDeviceName")
destination.setValue(stringValue(source, key: "lastSeenPeripheralName"), forKey: "lastSeenPeripheralName")
}
let sourceUpdatedAt = dateValue(source, key: "updatedAt") ?? .distantPast
let destinationUpdatedAt = dateValue(destination, key: "updatedAt") ?? .distantPast
if sourceUpdatedAt > destinationUpdatedAt {
destination.setValue(sourceUpdatedAt, forKey: "updatedAt")
}
}
func compactDuplicateEntriesByMAC() {
context.performAndWait {
refreshContextObjects()
let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
do {
let allObjects = try context.fetch(request)
var groupedByMAC: [String: [NSManagedObject]] = [:]
for object in allObjects {
guard let macAddress = object.value(forKey: "macAddress") as? String, !macAddress.isEmpty else {
continue
}
groupedByMAC[macAddress, default: []].append(object)
}
var removedDuplicates = 0
for (_, objects) in groupedByMAC {
guard objects.count > 1, let winner = preferredObject(from: objects) else { continue }
for duplicate in objects where duplicate.objectID != winner.objectID {
mergeBestValues(from: duplicate, into: winner)
context.delete(duplicate)
removedDuplicates += 1
}
}
if context.hasChanges {
try context.save()
}
if removedDuplicates > 0 {
track("Compacted \(removedDuplicates) duplicate DeviceSettings row(s)")
}
} catch {
track("Failed compacting duplicate device settings: \(error)")
}
}
}
func fetchAll() -> [CloudDeviceSettingsRecord] {
var results: [CloudDeviceSettingsRecord] = []
context.performAndWait {
refreshContextObjects()
let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
do {
let allObjects = try context.fetch(request)
var groupedByMAC: [String: [NSManagedObject]] = [:]
for object in allObjects {
guard let macAddress = object.value(forKey: "macAddress") as? String, !macAddress.isEmpty else {
continue
}
groupedByMAC[normalizedMACAddress(macAddress), default: []].append(object)
}
results = groupedByMAC.compactMap { macAddress, objects in
guard let preferred = preferredObject(from: objects) else {
return nil
}
return record(from: preferred, macAddress: macAddress)
}
} catch {
track("Failed loading cloud device settings: \(error)")
}
}
return results
}
func fetchByMacAddress() -> [String: CloudDeviceSettingsRecord] {
Dictionary(uniqueKeysWithValues: fetchAll().map { ($0.macAddress, $0) })
}
func upsert(macAddress: String, meterName: String?, tc66TemperatureUnit: String?) {
let macAddress = normalizedMACAddress(macAddress)
guard !macAddress.isEmpty else {
return
}
context.performAndWait {
do {
refreshContextObjects()
let objects = try fetchObjects(for: macAddress)
let object = preferredObject(from: objects) ?? NSManagedObject(entity: NSEntityDescription.entity(forEntityName: entityName, in: context)!, insertInto: context)
for duplicate in objects where duplicate.objectID != object.objectID {
context.delete(duplicate)
}
object.setValue(macAddress, forKey: "macAddress")
if let meterName {
object.setValue(meterName, forKey: "meterName")
}
if let tc66TemperatureUnit {
object.setValue(tc66TemperatureUnit, forKey: "tc66TemperatureUnit")
}
object.setValue(Date(), forKey: "updatedAt")
if context.hasChanges {
try context.save()
}
} catch {
track("Failed persisting cloud device settings for \(macAddress): \(error)")
}
}
}
func setConnection(macAddress: String, deviceID: String, deviceName: String, modelType: String) {
let macAddress = normalizedMACAddress(macAddress)
guard !macAddress.isEmpty, !deviceID.isEmpty else { return }
context.performAndWait {
do {
refreshContextObjects()
let objects = try fetchObjects(for: macAddress)
let object = preferredObject(from: objects) ?? NSManagedObject(entity: NSEntityDescription.entity(forEntityName: entityName, in: context)!, insertInto: context)
for duplicate in objects where duplicate.objectID != object.objectID {
context.delete(duplicate)
}
object.setValue(macAddress, forKey: "macAddress")
object.setValue(deviceID, forKey: "connectedByDeviceID")
object.setValue(deviceName, forKey: "connectedByDeviceName")
let now = Date()
object.setValue(now, forKey: "connectedAt")
object.setValue(Date().addingTimeInterval(120), forKey: "connectedExpiryAt")
object.setValue(modelType, forKey: "modelType")
object.setValue(now, forKey: "updatedAt")
if context.hasChanges { try context.save() }
} catch {
track("Failed publishing connection for \(macAddress): \(error)")
}
}
}
func recordDiscovery(macAddress: String, modelType: String, peripheralName: String?, seenByDeviceID: String, seenByDeviceName: String) {
let macAddress = normalizedMACAddress(macAddress)
guard !macAddress.isEmpty else { return }
context.performAndWait {
do {
refreshContextObjects()
let objects = try fetchObjects(for: macAddress)
let object = preferredObject(from: objects) ?? NSManagedObject(entity: NSEntityDescription.entity(forEntityName: entityName, in: context)!, insertInto: context)
for duplicate in objects where duplicate.objectID != object.objectID {
context.delete(duplicate)
}
let now = Date()
if let previousSeenAt = object.value(forKey: "lastSeenAt") as? Date,
let previousSeenBy = object.value(forKey: "lastSeenByDeviceID") as? String,
previousSeenBy == seenByDeviceID,
now.timeIntervalSince(previousSeenAt) < 15 {
return
}
object.setValue(macAddress, forKey: "macAddress")
object.setValue(modelType, forKey: "modelType")
object.setValue(now, forKey: "lastSeenAt")
object.setValue(seenByDeviceID, forKey: "lastSeenByDeviceID")
object.setValue(seenByDeviceName, forKey: "lastSeenByDeviceName")
if let peripheralName, !peripheralName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
object.setValue(peripheralName, forKey: "lastSeenPeripheralName")
}
object.setValue(now, forKey: "updatedAt")
if context.hasChanges { try context.save() }
} catch {
track("Failed recording discovery for \(macAddress): \(error)")
}
}
}
func clearConnection(macAddress: String, byDeviceID deviceID: String) {
let macAddress = normalizedMACAddress(macAddress)
guard !macAddress.isEmpty else { return }
context.performAndWait {
let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
request.predicate = NSPredicate(format: "macAddress == %@ AND connectedByDeviceID == %@", macAddress, deviceID)
do {
let objects = try context.fetch(request)
guard !objects.isEmpty else { return }
for object in objects {
context.refresh(object, mergeChanges: true)
object.setValue(nil, forKey: "connectedByDeviceID")
object.setValue(nil, forKey: "connectedByDeviceName")
object.setValue(nil, forKey: "connectedAt")
object.setValue(nil, forKey: "connectedExpiryAt")
object.setValue(Date(), forKey: "updatedAt")
}
if context.hasChanges { try context.save() }
} catch {
track("Failed clearing connection for \(macAddress): \(error)")
}
}
}
func clearAllConnections(byDeviceID deviceID: String) {
guard !deviceID.isEmpty else { return }
context.performAndWait {
refreshContextObjects()
let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
request.predicate = NSPredicate(format: "connectedByDeviceID == %@", deviceID)
do {
let objects = try context.fetch(request)
guard !objects.isEmpty else { return }
for object in objects {
context.refresh(object, mergeChanges: true)
object.setValue(nil, forKey: "connectedByDeviceID")
object.setValue(nil, forKey: "connectedByDeviceName")
object.setValue(nil, forKey: "connectedAt")
object.setValue(nil, forKey: "connectedExpiryAt")
object.setValue(Date(), forKey: "updatedAt")
}
if context.hasChanges { try context.save() }
track("Cleared \(objects.count) stale connection claim(s) for this device")
} catch {
track("Failed clearing stale connections: \(error)")
}
}
}
func rebuildCanonicalStoreIfNeeded(version: Int) {
let defaultsKey = "\(Self.rebuildDefaultsKeyPrefix).\(version)"
if UserDefaults.standard.bool(forKey: defaultsKey) {
return
}
context.performAndWait {
refreshContextObjects()
let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
do {
let allObjects = try context.fetch(request)
var groupedByMAC: [String: [NSManagedObject]] = [:]
for object in allObjects {
guard let rawMAC = object.value(forKey: "macAddress") as? String else { continue }
let macAddress = normalizedMACAddress(rawMAC)
guard !macAddress.isEmpty else { continue }
groupedByMAC[macAddress, default: []].append(object)
}
var removedDuplicates = 0
for (macAddress, objects) in groupedByMAC {
guard let winner = preferredObject(from: objects) else { continue }
winner.setValue(macAddress, forKey: "macAddress")
for duplicate in objects where duplicate.objectID != winner.objectID {
mergeBestValues(from: duplicate, into: winner)
context.delete(duplicate)
removedDuplicates += 1
}
}
if context.hasChanges {
try context.save()
}
UserDefaults.standard.set(true, forKey: defaultsKey)
track("Rebuilt DeviceSettings store in-place: \(removedDuplicates) duplicate(s) removed")
} catch {
track("Failed canonical rebuild for DeviceSettings: \(error)")
}
}
}
}