From e123dfe8950507406ee6ea8efcb077b464b86066 Mon Sep 17 00:00:00 2001 From: Andreas Bauer Date: Fri, 28 Jun 2024 11:02:35 +0200 Subject: [PATCH] Move to SwiftData for paired devices --- .../SpeziDevices/Model/PairedDeviceInfo.swift | 47 +------ Sources/SpeziDevices/PairedDevices.swift | 117 ++++++++++-------- .../PairedDevicesTests.swift | 14 +-- Tests/UITests/TestApp/DevicesTestView.swift | 2 - 4 files changed, 75 insertions(+), 105 deletions(-) diff --git a/Sources/SpeziDevices/Model/PairedDeviceInfo.swift b/Sources/SpeziDevices/Model/PairedDeviceInfo.swift index a5f158a..6138a62 100644 --- a/Sources/SpeziDevices/Model/PairedDeviceInfo.swift +++ b/Sources/SpeziDevices/Model/PairedDeviceInfo.swift @@ -7,13 +7,14 @@ // import Foundation +import SwiftData /// Persistent information stored of a paired device. -@Observable +@Model public class PairedDeviceInfo { /// The CoreBluetooth device identifier. - public let id: UUID + @Attribute(.unique) public let id: UUID /// The device type. /// /// Stores the associated ``PairableDevice/deviceTypeIdentifier-9wsed`` device type used to locate the device implementation. @@ -28,12 +29,10 @@ public class PairedDeviceInfo { /// The last reported battery percentage of the device. public internal(set) var lastBatteryPercentage: UInt8? - // NOT STORED ON DISK - /// Could not retrieve the device from the Bluetooth central. - public internal(set) var notLocatable: Bool = false + @Transient public internal(set) var notLocatable: Bool = false /// Visual representation of the device. - public var icon: ImageReference? + @Transient public var icon: ImageReference? /// Create new paired device information. /// - Parameters: @@ -61,44 +60,10 @@ public class PairedDeviceInfo { self.lastSeen = lastSeen self.lastBatteryPercentage = batteryPercentage } - - /// Initialize from decoder. - public required convenience init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - try self.init( - id: container.decode(UUID.self, forKey: .id), - deviceType: container.decode(String.self, forKey: .deviceType), - name: container.decode(String.self, forKey: .name), - model: container.decodeIfPresent(String.self, forKey: .name), - lastSeen: container.decode(Date.self, forKey: .lastSeen), - batteryPercentage: container.decodeIfPresent(UInt8.self, forKey: .batteryPercentage) - ) - } } -extension PairedDeviceInfo: Identifiable, Codable { - fileprivate enum CodingKeys: String, CodingKey { - case id - case deviceType - case name - case model - case lastSeen - case batteryPercentage - } - - public func encode(to encoder: any Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - - try container.encode(id, forKey: .id) - try container.encode(deviceType, forKey: .deviceType) - try container.encode(name, forKey: .name) - try container.encodeIfPresent(model, forKey: .model) - try container.encode(lastSeen, forKey: .lastSeen) - try container.encodeIfPresent(lastBatteryPercentage, forKey: .batteryPercentage) - } -} +extension PairedDeviceInfo: Identifiable {} extension PairedDeviceInfo: Hashable { diff --git a/Sources/SpeziDevices/PairedDevices.swift b/Sources/SpeziDevices/PairedDevices.swift index a81682f..9e37f39 100644 --- a/Sources/SpeziDevices/PairedDevices.swift +++ b/Sources/SpeziDevices/PairedDevices.swift @@ -10,8 +10,9 @@ import OrderedCollections import Spezi import SpeziBluetooth import SpeziBluetoothServices -import SpeziFoundation +@_spi(TestingSupport) import SpeziFoundation import SpeziViews +import SwiftData import SwiftUI @@ -88,24 +89,18 @@ import SwiftUI public final class PairedDevices { /// Determines if the device discovery sheet should be presented. @MainActor public var shouldPresentDevicePairing = false + /// Collection of discovered devices indexed by their Bluetooth identifier. @MainActor public private(set) var discoveredDevices: OrderedDictionary = [:] - - @MainActor private(set) var peripherals: [UUID: any PairableDevice] = [:] - - /// Device Information of paired devices. + /// The collection of paired devices that are persisted on disk. @MainActor public var pairedDevices: [PairedDeviceInfo] { - get { - access(keyPath: \.pairedDevices) - return _pairedDevices.values - } - set { - withMutation(keyPath: \.pairedDevices) { - _pairedDevices = SavableCollection(newValue) - } - } + Array(_pairedDevices.values) } - @AppStorage @MainActor @ObservationIgnored private var _pairedDevices: SavableCollection + + @MainActor private var _pairedDevices: OrderedDictionary = [:] + + /// Bluetooth Peripheral instances of paired devices. + @MainActor private(set) var peripherals: [UUID: any PairableDevice] = [:] @MainActor @ObservationIgnored private var pendingConnectionAttempts: [UUID: Task] = [:] @MainActor @ObservationIgnored private var ongoingPairings: [UUID: PairingContinuation] = [:] @@ -118,6 +113,8 @@ public final class PairedDevices { @Dependency @ObservationIgnored private var bluetooth: Bluetooth? @Dependency @ObservationIgnored private var tipKit: ConfigureTipKit + private var modelContainer: ModelContainer? + /// Determine if Bluetooth is scanning to discovery nearby devices. /// /// Scanning is automatically started if there hasn't been a paired device or if the discovery sheet is presented. @@ -133,27 +130,34 @@ public final class PairedDevices { /// Initialize the Paired Devices Module. - public required convenience init() { - self.init("edu.stanford.spezi.SpeziDevices.PairedDevices.devices-default") - } - - /// Initialize the Paired Devices Module with custom storage key. - /// - Parameter storageKey: The storage key for storing paired device information. - public init(_ storageKey: String) { - self.__pairedDevices = AppStorage(wrappedValue: [], storageKey) - } + public required init() {} /// Configures the Module. @_documentation(visibility: internal) public func configure() { - guard bluetooth != nil else { + if bluetooth != nil { self.logger.warning("PairedDevices Module initialized without Bluetooth dependency!") - return // useful for e.g. previews + } + + var configuration: ModelConfiguration +#if targetEnvironment(simulator) + configuration = ModelConfiguration(isStoredInMemoryOnly: true) +#else + configuration = ModelConfiguration() +#endif + + do { + self.modelContainer = try ModelContainer(for: PairedDeviceInfo.self, configurations: configuration) + } catch { + self.modelContainer = nil + self.logger.error("PairedDevices failed to initialize ModelContainer: \(error)") } // We need to detach to not copy task local values Task.detached { @MainActor in + self.fetchAllPairedInfos() + self.syncDeviceIcons() // make sure assets are up to date guard !self.pairedDevices.isEmpty else { @@ -164,13 +168,6 @@ public final class PairedDevices { } } - /// Clears all currently stored paired devices. - @_spi(TestingSupport) - @MainActor - public func clearStorage() { - pairedDevices.removeAll() - } - /// Determine if a device is currently connected. /// - Parameter device: The Bluetooth device identifier. /// - Returns: Returns `true` if the device for the given identifier is currently connected. @@ -193,8 +190,8 @@ public final class PairedDevices { /// - name: The new name. @MainActor public func updateName(for deviceInfo: PairedDeviceInfo, name: String) { + logger.debug("Updated name for paired device \(deviceInfo.id): \(name) %") deviceInfo.name = name - flush() } /// Configure a device to be managed by this PairedDevices instance. @@ -284,22 +281,20 @@ public final class PairedDevices { @MainActor private func updateBattery(for device: Device, percentage: UInt8) { - guard let index = pairedDevices.firstIndex(where: { $0.id == device.id }) else { + guard let deviceInfo = _pairedDevices[device.id] else { return } logger.debug("Updated battery level for \(device.label): \(percentage) %") - pairedDevices[index].lastBatteryPercentage = percentage - flush() + deviceInfo.lastBatteryPercentage = percentage } @MainActor private func updateLastSeen(for device: Device, lastSeen: Date = .now) { - guard let index = pairedDevices.firstIndex(where: { $0.id == device.id }) else { - return // not paired + guard let deviceInfo = _pairedDevices[device.id] else { + return } logger.debug("Updated lastSeen for \(device.label): \(lastSeen) %") - pairedDevices[index].lastSeen = lastSeen - flush() + deviceInfo.lastSeen = lastSeen } @MainActor @@ -331,11 +326,6 @@ public final class PairedDevices { return task } - @MainActor - private func flush() { - _pairedDevices = _pairedDevices // update app storage - } - deinit { _peripherals.removeAll() stateSubscriptionTask = nil @@ -449,7 +439,13 @@ extension PairedDevices { batteryPercentage: batteryLevel ) - pairedDevices.append(deviceInfo) + _pairedDevices[deviceInfo.id] = deviceInfo + if let modelContainer { + modelContainer.mainContext.insert(deviceInfo) + } else { + logger.warning("PairedDevice \(device.label), \(device.id) could not be persisted on disk due to missing ModelContainer!") + } + discoveredDevices[device.id] = nil @@ -467,12 +463,15 @@ extension PairedDevices { /// - Parameter id: The Bluetooth peripheral identifier of a paired device. @MainActor public func forgetDevice(id: UUID) { - pairedDevices.removeAll { info in - info.id == id + let removed = _pairedDevices.removeValue(forKey: id) + if let removed { + modelContainer?.mainContext.delete(removed) } + discoveredDevices.removeValue(forKey: id) let device = peripherals.removeValue(forKey: id) + if let device { Task { await device.disconnect() @@ -491,6 +490,26 @@ extension PairedDevices { // MARK: - Paired Peripheral Management extension PairedDevices { + @MainActor + private func fetchAllPairedInfos() { + guard let modelContainer else { + return + } + + let context = modelContainer.mainContext + var allPairedDevices = FetchDescriptor() + allPairedDevices.includePendingChanges = true + + do { + let pairedDevices = try context.fetch(allPairedDevices) + self._pairedDevices = pairedDevices.reduce(into: [:]) { partialResult, deviceInfo in + partialResult[deviceInfo.id] = deviceInfo + } + } catch { + logger.error("Failed to fetch paired device info from disk: \(error)") + } + } + @MainActor private func syncDeviceIcons() { guard let bluetooth else { diff --git a/Tests/SpeziDevicesTests/PairedDevicesTests.swift b/Tests/SpeziDevicesTests/PairedDevicesTests.swift index a5d6f67..4cbc909 100644 --- a/Tests/SpeziDevicesTests/PairedDevicesTests.swift +++ b/Tests/SpeziDevicesTests/PairedDevicesTests.swift @@ -17,12 +17,9 @@ import XCTSpezi final class PairedDevicesTests: XCTestCase { @MainActor - func testPairDevice() async throws { // swiftlint:disable:this function_body_length + func testPairDevice() async throws { let device = MockDevice.createMockDevice() let devices = PairedDevices() - defer { - devices.clearStorage() - } // ensure PairedDevices gets injected into the MockDevice @@ -101,9 +98,6 @@ final class PairedDevicesTests: XCTestCase { func testPairingErrors() async throws { let device = MockDevice.createMockDevice() let devices = PairedDevices() - defer { - devices.clearStorage() - } withDependencyResolution { devices @@ -138,9 +132,6 @@ final class PairedDevicesTests: XCTestCase { func testPairingCancellation() async throws { let device = MockDevice.createMockDevice() let devices = PairedDevices() - defer { - devices.clearStorage() - } withDependencyResolution { devices @@ -166,9 +157,6 @@ final class PairedDevicesTests: XCTestCase { func testFailedPairing() async throws { let device = MockDevice.createMockDevice() let devices = PairedDevices() - defer { - devices.clearStorage() - } withDependencyResolution { device diff --git a/Tests/UITests/TestApp/DevicesTestView.swift b/Tests/UITests/TestApp/DevicesTestView.swift index 08c14c9..b43665c 100644 --- a/Tests/UITests/TestApp/DevicesTestView.swift +++ b/Tests/UITests/TestApp/DevicesTestView.swift @@ -55,8 +55,6 @@ struct DevicesTestView: View { } } .onAppear { - pairedDevices.clearStorage() // we clear storage for testing purposes - guard !didRegister else { return }