Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Move to SwiftData for paired devices
Browse files Browse the repository at this point in the history
Supereg committed Jun 28, 2024
1 parent bce9494 commit e123dfe
Showing 4 changed files with 75 additions and 105 deletions.
47 changes: 6 additions & 41 deletions Sources/SpeziDevices/Model/PairedDeviceInfo.swift
Original file line number Diff line number Diff line change
@@ -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 {
117 changes: 68 additions & 49 deletions Sources/SpeziDevices/PairedDevices.swift
Original file line number Diff line number Diff line change
@@ -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<UUID, any PairableDevice> = [:]

@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<PairedDeviceInfo>

@MainActor private var _pairedDevices: OrderedDictionary<UUID, PairedDeviceInfo> = [:]

/// Bluetooth Peripheral instances of paired devices.
@MainActor private(set) var peripherals: [UUID: any PairableDevice] = [:]

@MainActor @ObservationIgnored private var pendingConnectionAttempts: [UUID: Task<Void, Never>] = [:]
@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<Device: PairableDevice>(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<Device: PairableDevice>(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<PairedDeviceInfo>()
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 {
14 changes: 1 addition & 13 deletions Tests/SpeziDevicesTests/PairedDevicesTests.swift
Original file line number Diff line number Diff line change
@@ -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
2 changes: 0 additions & 2 deletions Tests/UITests/TestApp/DevicesTestView.swift
Original file line number Diff line number Diff line change
@@ -55,8 +55,6 @@ struct DevicesTestView: View {
}
}
.onAppear {
pairedDevices.clearStorage() // we clear storage for testing purposes

guard !didRegister else {
return
}

0 comments on commit e123dfe

Please sign in to comment.