diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index a62aa6d..0666dd5 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -1,5 +1,5 @@ # -# This source file is part of the TemplatePackage open source project +# This source file is part of the Stanford SpeziDevices open source project # # SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) # @@ -18,107 +18,36 @@ on: jobs: packageios: name: Build and Test Swift Package iOS - uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 + uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 with: - scheme: TemplatePackage - resultBundle: TemplatePackage-iOS.xcresult - artifactname: TemplatePackage-iOS.xcresult - packagewatchos: - name: Build and Test Swift Package watchOS - uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 - with: - scheme: TemplatePackage - destination: 'platform=watchOS Simulator,name=Apple Watch Series 9 (45mm)' - resultBundle: TemplatePackage-watchOS.xcresult - artifactname: TemplatePackage-watchOS.xcresult - packagevisionos: - name: Build and Test Swift Package visionOS - uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 - with: - scheme: TemplatePackage - destination: 'platform=visionOS Simulator,name=Apple Vision Pro' - resultBundle: TemplatePackage-visionOS.xcresult - artifactname: TemplatePackage-visionOS.xcresult - packagetvos: - name: Build and Test Swift Package tvOS - uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 - with: - scheme: TemplatePackage - resultBundle: TemplatePackage-tvOS.xcresult - destination: 'platform=tvOS Simulator,name=Apple TV 4K (3rd generation)' - artifactname: TemplatePackage-tvOS.xcresult - packagemacos: - name: Build and Test Swift Package macOS - uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 - with: - scheme: TemplatePackage - resultBundle: TemplatePackage-macOS.xcresult - destination: 'platform=macOS,arch=arm64' - artifactname: TemplatePackage-macOS.xcresult + runsonlabels: '["macOS", "self-hosted"]' + scheme: SpeziDevices-Package + resultBundle: SpeziDevices-iOS.xcresult + artifactname: SpeziDevices-iOS.xcresult ios: name: Build and Test iOS - uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 + uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 with: runsonlabels: '["macOS", "self-hosted"]' path: 'Tests/UITests' scheme: TestApp resultBundle: TestApp-iOS.xcresult artifactname: TestApp-iOS.xcresult - ipados: - name: Build and Test iPadOS - uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 - with: - runsonlabels: '["macOS", "self-hosted"]' - path: 'Tests/UITests' - scheme: TestApp - destination: 'platform=iOS Simulator,name=iPad Air (5th generation)' - resultBundle: TestApp-iPadOS.xcresult - artifactname: TestApp-iPadOS.xcresult - watchos: - name: Build and Test watchOS - uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 - with: - runsonlabels: '["macOS", "self-hosted"]' - path: 'Tests/UITests' - scheme: TestAppWatchApp - destination: 'platform=watchOS Simulator,name=Apple Watch Series 9 (45mm)' - resultBundle: TestApp-watchOS.xcresult - artifactname: TestApp-watchOS.xcresult - visionos: - name: Build and Test visionOS - uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 - with: - runsonlabels: '["macOS", "self-hosted"]' - path: 'Tests/UITests' - scheme: TestApp - destination: 'platform=visionOS Simulator,name=Apple Vision Pro' - resultBundle: TestApp-visionOS.xcresult - artifactname: TestApp-visionOS.xcresult - tvos: - name: Build and Test tvOS - uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 - with: - runsonlabels: '["macOS", "self-hosted"]' - path: 'Tests/UITests' - scheme: TestApp - destination: 'platform=tvOS Simulator,name=Apple TV 4K (3rd generation)' - resultBundle: TestApp-tvOS.xcresult - artifactname: TestApp-tvOS.xcresult codeql: name: CodeQL - uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 + uses: StanfordSpezi/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2 with: codeql: true test: false - scheme: TemplatePackage + scheme: SpeziDevices-Package permissions: security-events: write actions: read uploadcoveragereport: name: Upload Coverage Report - needs: [packageios, packagewatchos, packagevisionos, packagetvos, packagemacos, ios, ipados, watchos, visionos, tvos] + needs: [packageios, ios] uses: StanfordBDHG/.github/.github/workflows/create-and-upload-coverage-report.yml@v2 with: - coveragereports: TemplatePackage-iOS.xcresult TemplatePackage-watchOS.xcresult TemplatePackage-visionOS.xcresult TemplatePackage-tvOS.xcresult TemplatePackage-macOS.xcresult TestApp-iOS.xcresult TestApp-iPadOS.xcresult TestApp-watchOS.xcresult TestApp-visionOS.xcresult TestApp-tvOS.xcresult + coveragereports: SpeziDevices-iOS.xcresult TestApp-iOS.xcresult secrets: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 45ed630..4d46666 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -1,5 +1,5 @@ # -# This source file is part of the TemplatePackage open source project +# This source file is part of the Stanford SpeziDevices open source project # # SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) # diff --git a/.gitignore b/.gitignore index f9a765f..929b1be 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # -# This source file is part of the TemplatePackage open source project +# This source file is part of the Stanford SpeziDevices open source project # # SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) # diff --git a/.spi.yml b/.spi.yml index 504bb5a..50cfabb 100644 --- a/.spi.yml +++ b/.spi.yml @@ -1,5 +1,5 @@ # -# This source file is part of the TemplatePackage open source project +# This source file is part of the Stanford SpeziDevices open source project # # SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) # @@ -11,4 +11,6 @@ builder: configs: - platform: ios documentation_targets: - - TemplatePackage + - SpeziDevices + - SpeziDevicesUI + - SpeziOmron diff --git a/.swiftlint.yml b/.swiftlint.yml index 3c7bab5..96eebc3 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,5 +1,5 @@ # -# This source file is part of the TemplatePackage open source project +# This source file is part of the Stanford SpeziDevices open source project # # SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) # diff --git a/CITATION.cff b/CITATION.cff index ee53639..8e4bc03 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -1,5 +1,5 @@ # -# This source file is part of the TemplatePackage open source project +# This source file is part of the Stanford SpeziDevices open source project # # SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) # @@ -12,9 +12,9 @@ authors: - family-names: "Schmiedmayer" given-names: "Paul" orcid: "https://orcid.org/0000-0002-8607-9148" -- family-names: "Ravi" - given-names: "Vishnu" - orcid: "https://orcid.org/0000-0003-0359-1275" -title: "TemplatePackage" +- family-names: "Bauer" + given-names: "Andreas" + orcid: "https://orcid.org/0000-0002-1680-237X" +title: "SpeziDevices" doi: 10.5281/zenodo.7538165 -url: "https://github.com/StanfordBDHG/SwiftPackageTemplate" +url: "https://github.com/StanfordSpezi/SpeziDevices" diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 7574c84..5e26835 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -1,7 +1,7 @@ -# TemplatePackage +# SpeziDevices -[![Build and Test](https://github.com/StanfordBDHG/SwiftPackageTemplate/actions/workflows/build-and-test.yml/badge.svg)](https://github.com/StanfordBDHG/SwiftPackageTemplate/actions/workflows/build-and-test.yml) -[![codecov](https://codecov.io/gh/StanfordBDHG/SwiftPackageTemplate/branch/main/graph/badge.svg?token=X7BQYSUKOH)](https://codecov.io/gh/StanfordBDHG/SwiftPackageTemplate) -[![DOI](https://zenodo.org/badge/573230182.svg)](https://zenodo.org/badge/latestdoi/573230182) -[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FStanfordBDHG%2FSwiftPackageTemplate%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/StanfordBDHG/SwiftPackageTemplate) -[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FStanfordBDHG%2FSwiftPackageTemplate%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/StanfordBDHG/SwiftPackageTemplate) +[![Build and Test](https://github.com/StanfordSpezi/SpeziDevices/actions/workflows/build-and-test.yml/badge.svg)](https://github.com/StanfordSpezi/SpeziDevices/actions/workflows/build-and-test.yml) +[![codecov](https://codecov.io/gh/StanfordSpezi/SpeziDevices/graph/badge.svg?token=pZeJyWYhAk)](https://codecov.io/gh/StanfordSpezi/SpeziDevices) + + +Support interactions with Bluetooth Devices. -## How To Use This Template +## Overview -The template repository contains a template Swift Package, including a continuous integration setup. +SpeziDevices provides three different targets: `SpeziDevices`, `SpeziDevicesUI` and `SpeziOmron`. -Follow these steps to customize it to your needs: -1. Rename the Swift Package. Be sure that you update the name in the `build-and-test.yml` GitHub Action accordingly. If you have multiple targets in your Swift Package, you need to pass the name of the Swift Package followed by an `-Package` as the scheme to the GitHub Action, e.g., `StanfordProject-Package` if your Swift Package is named `StanfordProject`. -2. If your Swift Package does not provide any user interface or does not require an iOS application environment to function, you can remove the `UITests` application from the `Tests` folder. You need to update the `build-and-test.yml` GitHub Action accordingly by removing the GitHub Action that builds and tests the application, removing the dependency from the code coverage upload step, and removing the UI test `.xresult` input from the code coverage test. -3. If your Swift Package uses UI test, you need to ... - - ... add it to the scheme editor (*Scheme > Edit Scheme*) and your targets to the "Build" configuration and ensure that it is built before the test app target when building for the "Test" configuration. It is not required to enable building for other configurations like "Analyze", "Run", "Profile", or "Archive". - - ... add it as a linked framework in the main target configuration (In your Xcode project settings, select your *test app target > General > Frameworks, Libraries, and Embedded Comments*). - - ... add ensure that the targets are all added in the code coverage settings of your .xctestplan file in the Xcode Project (*Shared Settings > Code Coverage > Code Coverage*). -4. You will either need to add the [CodeCov GitHub App](https://github.com/apps/codecov) or add a codecov.io token to your [GitHub Actions Secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets#creating-encrypted-secrets-for-an-environment) following the instructions of the [Codecov GitHub Action](https://github.com/marketplace/actions/codecov#usage). The StanfordBDHG organization already has the [CodeCov GitHub App](https://github.com/apps/codecov) installed. If you do not want to cover test coverage data, you can remove the code coverage job in the `build-and-test.yml` GitHub Action. -5. Adjust this README.md to describe your project and adjust the badges at the top to point to the correct GitHub Action of your repository and Codecov badge. -6. The Swift Package template includes a Swift Package Index configuration file to automatically build the package and [host the documentation on the Swift Package Index website](https://blog.swiftpackageindex.com/posts/auto-generating-auto-hosting-and-auto-updating-docc-documentation/). Adjust the `.spi.yml` file to include all targets that you want to build documentation for. You can follow the [instructions of the Swift Package Index](https://swiftpackageindex.com/add-a-package) to include your Swift Package in the Swift Package Index. You can link to the [API documentation](https://swiftpackageindex.com/StanfordBDHG/SwiftPackageTemplate/documentation) from your README file. -7. Adjust the CITATION.cff file to amend information about the new Swift Package ([learn more about CITATION files on GitHub](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-citation-files)) and [register the Swift Package on Zenodo](https://docs.github.com/en/repositories/archiving-a-github-repository/referencing-and-citing-content). +### SpeziDevices +SpeziDevices abstracts common interactions with Bluetooth devices that are implemented using +[SpeziBluetooth](https://swiftpackageindex.com/StanfordSpezi/SpeziBluetooth/documentation/spezibluetooth). +It supports pairing with devices and process health measurements. -## Installation +#### Pairing Devices -The project can be added to your Xcode project or Swift Package using the [Swift Package Manager](https://github.com/apple/swift-package-manager). +Pairing devices is a good way of making sure that your application only connects to fixed set of devices and doesn't accept data from +non-authorized devices. +Further, it might be necessary to ensure certain operations stay secure. -**Xcode:** For an Xcode project, follow the instructions on [adding package dependencies to your app](https://developer.apple.com/documentation/xcode/adding-package-dependencies-to-your-app). +Use the ``PairedDevices`` module to discover and pair ``PairableDevice``s and automatically manage connection establishment +of connected devices. -**Swift Package:** You can follow the [Swift Package Manager documentation about defining dependencies](https://github.com/apple/swift-package-manager/blob/main/Documentation/Usage.md#defining-dependencies) to add this project as a dependency to your Swift Package. +To support `PairedDevices`, you need to adopt the ``PairableDevice`` protocol for your device. +Optionally you can adopt the ``BatteryPoweredDevice`` protocol, if your device supports the +[`BatteryService`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetoothservices/batteryservice). +Once your device is loaded, register it with the `PairedDevices` module by calling the ``PairedDevices/configure(device:accessing:_:_:)`` method. +> [!IMPORTANT] +> Don't forget to configure the `PairedDevices` module in + your [`SpeziAppDelegate`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/speziappdelegate). + +```swift +import SpeziDevices + +class MyDevice: PairableDevice { + @DeviceState(\.id) var id + @DeviceState(\.name) var name + @DeviceState(\.state) var state + @DeviceState(\.advertisementData) var advertisementData + @DeviceState(\.nearby) var nearby + + @Service var deviceInformation = DeviceInformationService() + + @DeviceAction(\.connect) var connect + @DeviceAction(\.disconnect) var disconnect + + var isInPairingMode: Bool { + // determine if a nearby device is in pairing mode + } + + @Dependency private var pairedDevices: PairedDevices? + + required init() {} + + func configure() { + pairedDevices?.configure(device: self, accessing: $state, $advertisementData, $nearby) + } + + func handleSuccessfulPairing() { // called on events where a device can be considered paired (e.g., incoming notifications) + pairedDevices?.signalDevicePaired(self) + } +} +``` + +> [!TIP] +> To display and manage paired devices and support adding new paired devices, you can use the full-featured ``DevicesView`` view. + +#### Health Measurements + +Use the ``HealthMeasurements`` module to collect health measurements from nearby Bluetooth devices like connected weight scales or +blood pressure cuffs. + +To support `HealthMeasurements`, you need to adopt the ``HealthDevice`` protocol for your device. +One your device is loaded, register its measurement service with the `HealthMeasurements` module +by calling a suitable variant of `configureReceivingMeasurements(for:on:)`. + +```swift +import SpeziDevices + +class MyDevice: HealthDevice { + @Service var deviceInformation = DeviceInformationService() + @Service var weightScale = WeightScaleService() + + @Dependency private var measurements: HealthMeasurements? + + required init() {} + + func configure() { + measurements?.configureReceivingMeasurements(for: self, on: weightScale) + } +} +``` + +To display new measurements to the user and save them to your external data store, you can use ``MeasurementsRecordedSheet``. +Below is a short code example. + +```swift +import SpeziDevices +import SpeziDevicesUI + +struct MyHomeView: View { + @Environment(HealthMeasurements.self) private var measurements + + var body: some View { + @Bindable var measurements = measurements + ContentView() + .sheet(isPresented: $measurements.shouldPresentMeasurements) { + MeasurementsRecordedSheet { measurement in + // handle saving the measurement + } + } + } +} +``` + +> [!IMPORTANT] +> Don't forget to configure the `HealthMeasurements` module in + your [`SpeziAppDelegate`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/speziappdelegate). + +### SpeziDevicesUI + +SpeziDevicesUI helps you to visualize Bluetooth device state and communicate interactions to the user. + +#### Displaying paired devices + +When managing paired devices using ``PairedDevices``, SpeziDevicesUI provides reusable View components to display paired devices. + +The ``DevicesView`` provides everything you need to pair and manage paired devices. +It shows already paired devices in a grid layout using the ``DevicesGrid``. Additionally, it places an add button in the toolbar +to discover new devices using the ``AccessorySetupSheet`` view. + +```swift +struct MyHomeView: View { + var body: some View { + TabView { + NavigationStack { + DevicesView(appName: "Example") { + Text("Provide helpful pairing instructions to the user.") + } + } + .tabItem { + Label("Devices", systemImage: "sensor.fill") + } + } + } +} +``` + +#### Displaying Measurements + +When managing measurements using ``HealthMeasurements``, you can use the ``MeasurementsRecordedSheet`` to display pending measurements. +Below is a short code example on how you would configure this view. + +```swift +struct MyHomeView: View { + @Environment(HealthMeasurements.self) private var measurements + + var body: some View { + @Bindable var measurements = measurements + ContentView() + .sheet(isPresented: $measurements.shouldPresentMeasurements) { + MeasurementsRecordedSheet { samples in + // save the array of HKSamples + } + } + } +} +``` + +> [!IMPORTANT] +> Don't forget to configure the `HealthMeasurements` module in + your [`SpeziAppDelegate`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/speziappdelegate). + +### SpeziOmron + +SpeziOmron extends SpeziDevices with support for Omron devices. This includes Omron-specific models, characteristics, services and fully reusable +device support. + +#### Omron Devices + +The ``OmronBloodPressureCuff`` and ``OmronWeightScale`` devices provide reusable device implementations for the Omron `BP5250` blood pressure cuff +and the Omron `SC-150` weight scale. +Both devices automatically integrate with the ``HealthMeasurements`` and ``PairedDevices`` modules of SpeziDevices. +You just need to configure them for use with the [`Bluetooth`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetooth#Configure-the-Bluetooth-Module) +module. + +```swift +import SpeziBluetooth +import SpeziBluetoothServices +import SpeziDevices +import SpeziOmron + +class ExampleAppDelegate: SpeziAppDelegate { + override var configuration: Configuration { + Configuration { + Bluetooth { + Discover(OmronBloodPressureCuff.self, by: .accessory(manufacturer: .omronHealthcareCoLtd, advertising: BloodPressureService.self)) + Discover(OmronWeightScale.self, by: .accessory(manufacturer: .omronHealthcareCoLtd, advertising: WeightScaleService.self)) + } + + // If required, configure the PairedDevices and HealthMeasurements modules + PairedDevices() + HealthMeasurements() + } + } +} +``` + +## Setup + +You need to add the SpeziDevices Swift package to +[your app in Xcode](https://developer.apple.com/documentation/xcode/adding-package-dependencies-to-your-app#) or +[Swift package](https://developer.apple.com/documentation/xcode/creating-a-standalone-swift-package-with-xcode#Add-a-dependency-on-another-Swift-package). + ## License -This project is licensed under the MIT License. See [Licenses](https://github.com/StanfordBDHG/TemplatePackage/tree/main/LICENSES) for more information. +This project is licensed under the MIT License. See [Licenses](https://github.com/StanfordSpezi/SpeziDevices/tree/main/LICENSES) for more information. ## Contributors This project is developed as part of the Stanford Byers Center for Biodesign at Stanford University. -See [CONTRIBUTORS.md](https://github.com/StanfordBDHG/TemplatePackage/tree/main/CONTRIBUTORS.md) for a full list of all TemplatePackage contributors. +See [CONTRIBUTORS.md](https://github.com/StanfordSpezi/SpeziDevices/tree/main/CONTRIBUTORS.md) for a full list of all TemplatePackage contributors. -![Stanford Byers Center for Biodesign Logo](https://raw.githubusercontent.com/StanfordBDHG/.github/main/assets/biodesign-footer-light.png#gh-light-mode-only) -![Stanford Byers Center for Biodesign Logo](https://raw.githubusercontent.com/StanfordBDHG/.github/main/assets/biodesign-footer-dark.png#gh-dark-mode-only) +![Stanford Byers Center for Biodesign Logo](https://raw.githubusercontent.com/StanfordSpezi/.github/main/assets/Footer.png#gh-light-mode-only) +![Stanford Byers Center for Biodesign Logo](https://raw.githubusercontent.com/StanfordSpezi/.github/main/assets/Footer~dark.png#gh-dark-mode-only) diff --git a/Sources/SpeziDevices/Devices/BatteryPoweredDevice.swift b/Sources/SpeziDevices/Devices/BatteryPoweredDevice.swift new file mode 100644 index 0000000..977a4a1 --- /dev/null +++ b/Sources/SpeziDevices/Devices/BatteryPoweredDevice.swift @@ -0,0 +1,23 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziBluetooth +import SpeziBluetoothServices + + +/// A battery powered Bluetooth device. +public protocol BatteryPoweredDevice: BluetoothDevice { + /// The battery service of the peripheral. + /// + /// Use the [`@Service`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/service) property wrapper to + /// declare this property. + /// ```swift + /// @Service var deviceInformation = BatteryService() + /// ``` + var battery: BatteryService { get } +} diff --git a/Sources/SpeziDevices/Devices/GenericBluetoothPeripheral.swift b/Sources/SpeziDevices/Devices/GenericBluetoothPeripheral.swift new file mode 100644 index 0000000..5be7351 --- /dev/null +++ b/Sources/SpeziDevices/Devices/GenericBluetoothPeripheral.swift @@ -0,0 +1,53 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziBluetooth + + +/// A generic bluetooth peripheral. +public protocol GenericBluetoothPeripheral { + /// The user-visible label. + /// + /// This label is used to communicate information about this device to the user. + var label: String { get } + + /// An optional accessibility label. + /// + /// This label is used as the accessibility label within views when + /// communicate information about this device to the user. + var accessibilityLabel: String { get } + + /// The current peripheral state. + var state: PeripheralState { get } + + /// Mark the device to require user attention. + /// + /// Marks the device to require user attention. The user should navigate to the details + /// view to get more information about the device. + var requiresUserAttention: Bool { get } +} + + +extension GenericBluetoothPeripheral { + /// Default implementation using the devices `label`. + public var accessibilityLabel: String { + label + } + + /// By default the peripheral doesn't require user attention. + public var requiresUserAttention: Bool { + false + } +} + + +extension BluetoothPeripheral: GenericBluetoothPeripheral { + public nonisolated var label: String { + name ?? "Generic Peripheral" + } +} diff --git a/Sources/SpeziDevices/Devices/GenericDevice.swift b/Sources/SpeziDevices/Devices/GenericDevice.swift new file mode 100644 index 0000000..7bb98a2 --- /dev/null +++ b/Sources/SpeziDevices/Devices/GenericDevice.swift @@ -0,0 +1,73 @@ +// +// This source file is part of the Stanford Spezi open-project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import SpeziBluetooth +import SpeziBluetoothServices + + +/// A generic Bluetooth device. +/// +/// A generic Bluetooth device that provides access to basic device information. +public protocol GenericDevice: BluetoothDevice, GenericBluetoothPeripheral, Identifiable, Sendable { + /// An icon that is used to visually present the device to the user. + static var icon: ImageReference? { get } + + /// The device identifier. + /// + /// Use the [`DeviceState`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/devicestate) property wrapper to + /// declare this property. + /// ```swift + /// @DeviceState(\.id) var id + /// ``` + var id: UUID { get } + + /// The device name. + /// + /// Use the [`DeviceState`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/devicestate) property wrapper to + /// declare this property. + /// ```swift + /// @DeviceState(\.name) var name + /// ``` + var name: String? { get } + + /// The advertisement data received in the latest advertisement. + /// + /// Use the [`DeviceState`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/devicestate) property wrapper to + /// declare this property. + /// ```swift + /// @DeviceState(\.advertisementData) var advertisementData + /// ``` + var advertisementData: AdvertisementData { get } + + /// The device information service of the peripheral. + /// + /// Use the [`@Service`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/service) property wrapper to + /// declare this property. + /// ```swift + /// @Service var deviceInformation = DeviceInformationService() + /// ``` + var deviceInformation: DeviceInformationService { get } +} + + +extension GenericDevice { + /// Default icon implementation. + /// + /// Returns `nil` by default. Results in a generic icon to be presented. + public static var icon: ImageReference? { + nil + } + + /// Default label implementation. + /// + /// Returns `"Generic Device"` if the peripheral doesn't expose a ``name``. + public var label: String { + name ?? "Generic Device" + } +} diff --git a/Sources/SpeziDevices/Devices/HealthDevice.swift b/Sources/SpeziDevices/Devices/HealthDevice.swift new file mode 100644 index 0000000..c0b0bde --- /dev/null +++ b/Sources/SpeziDevices/Devices/HealthDevice.swift @@ -0,0 +1,35 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import HealthKit + + +/// A generic Bluetooth Health device. +public protocol HealthDevice: GenericDevice { + /// The HealthKit device description. + var hkDevice: HKDevice { get } +} + + +extension HealthDevice { + /// The HealthKit device description. + /// + /// Default implementation using the `DeviceInformationService`. + public var hkDevice: HKDevice { + HKDevice( + name: name, + manufacturer: deviceInformation.manufacturerName, + model: deviceInformation.modelNumber, + hardwareVersion: deviceInformation.hardwareRevision, + firmwareVersion: deviceInformation.firmwareRevision, + softwareVersion: deviceInformation.softwareRevision, + localIdentifier: nil, + udiDeviceIdentifier: nil + ) + } +} diff --git a/Sources/SpeziDevices/Devices/PairableDevice.swift b/Sources/SpeziDevices/Devices/PairableDevice.swift new file mode 100644 index 0000000..0698764 --- /dev/null +++ b/Sources/SpeziDevices/Devices/PairableDevice.swift @@ -0,0 +1,61 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziBluetooth + + +/// A Bluetooth device that is pairable. +public protocol PairableDevice: GenericDevice { + /// Persistent identifier for the device type. + /// + /// This is used to associate pairing information with the implementing device. By default, the type name is used. + static var deviceTypeIdentifier: String { get } + + /// Indicate that the device is nearby. + /// + /// Use the [`DeviceState`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/devicestate) property wrapper to + /// declare this property. + /// ```swift + /// @DeviceState(\.nearby) var nearby + /// ``` + var nearby: Bool { get } + + /// Connect action. + /// + /// Use the [`DeviceAction`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/deviceaction) property wrapper to + /// declare this property. + /// ```swift + /// @DeviceAction(\.connect) var connect + /// ``` + /// + var connect: BluetoothConnectAction { get } + + /// Disconnect action. + /// + /// Use the [`DeviceAction`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/deviceaction) property wrapper to + /// declare this property. + /// ```swift + /// @DeviceAction(\.disconnect) var disconnect + /// ``` + var disconnect: BluetoothDisconnectAction { get } + + /// Determines if the device is currently able to get paired. + /// + /// This might be a value that is reported by the device for example through the manufacturer data in the Bluetooth advertisement. + var isInPairingMode: Bool { get } +} + + +extension PairableDevice { + /// Default persistent identifier for the device type. + /// + /// Defaults to the Swift type name. + public static var deviceTypeIdentifier: String { + "\(Self.self)" + } +} diff --git a/Sources/SpeziDevices/Health/BloodPressureMeasurement+HKSample.swift b/Sources/SpeziDevices/Health/BloodPressureMeasurement+HKSample.swift new file mode 100644 index 0000000..8859727 --- /dev/null +++ b/Sources/SpeziDevices/Health/BloodPressureMeasurement+HKSample.swift @@ -0,0 +1,105 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + + +import HealthKit +import SpeziBluetoothServices + + +extension BloodPressureMeasurement { + /// Convert the blood pressure measurement to the HealthKit representation. + /// + /// Converts the content of the blood pressure measurement to a `HKCorrelation`. + /// - Parameter device: The device information to reference with the `HKCorrelation`. + /// You may use ``HealthDevice/hkDevice`` to retrieve the device information from a ``HealthDevice``. + /// - Returns: Returns the `HKCorrelation` with two samples for systolic and diastolic values. Returns `nil` if either of the blood pressure samples is non-finite. + public func bloodPressureSample(source device: HKDevice?) -> HKCorrelation? { + guard systolicValue.isFinite, diastolicValue.isFinite else { + return nil + } + let unit: HKUnit = unit.hkUnit + + let systolic = HKQuantity(unit: unit, doubleValue: systolicValue.double) + let diastolic = HKQuantity(unit: unit, doubleValue: diastolicValue.double) + + let systolicType = HKQuantityType(.bloodPressureSystolic) + let diastolicType = HKQuantityType(.bloodPressureDiastolic) + let correlationType = HKCorrelationType(.bloodPressure) + + let date = timeStamp?.date ?? .now + + let systolicSample = HKQuantitySample(type: systolicType, quantity: systolic, start: date, end: date, device: device, metadata: nil) + let diastolicSample = HKQuantitySample(type: diastolicType, quantity: diastolic, start: date, end: date, device: device, metadata: nil) + + + let bloodPressure = HKCorrelation( + type: correlationType, + start: date, + end: date, + objects: [systolicSample, diastolicSample], + device: device, + metadata: nil + ) + + return bloodPressure + } +} + + +extension BloodPressureMeasurement { + /// Convert the heart rate measurement to the HealthKit representation. + /// + /// Converts the hear rate measurement of the blood pressure measurement to a `HKQuantitySample`. + /// - Parameter device: The device information to reference with the `HKQuantitySample`. + /// You may use ``HealthDevice/hkDevice`` to retrieve the device information from a ``HealthDevice``. + /// - Returns: Returns the `HKQuantitySample` with the heart rate value. Returns `nil` if no pulse rate is present or contains a non-finite value. + public func heartRateSample(source device: HKDevice?) -> HKQuantitySample? { + guard let pulseRate, pulseRate.isFinite else { + return nil + } + + // beats per minute + let bpm: HKUnit = .count().unitDivided(by: .minute()) + let pulseQuantityType = HKQuantityType(.heartRate) + + let pulse = HKQuantity(unit: bpm, doubleValue: pulseRate.double) + let date = timeStamp?.date ?? .now + + return HKQuantitySample( + type: pulseQuantityType, + quantity: pulse, + start: date, + end: date, + device: device, + metadata: nil + ) + } +} + + +extension HKCorrelation { + /// Retrieve a mock blood pressure sample. + @_spi(TestingSupport) public static var mockBloodPressureSample: HKCorrelation { + let measurement = BloodPressureMeasurement(systolic: 117, diastolic: 76, meanArterialPressure: 67, unit: .mmHg, pulseRate: 68) + guard let sample = measurement.bloodPressureSample(source: nil) else { + preconditionFailure("Mock sample was unexpectedly invalid!") + } + return sample + } +} + +extension HKQuantitySample { + /// Retrieve a mock heart rate sample. + @_spi(TestingSupport) public static var mockHeartRateSample: HKQuantitySample { + let measurement = BloodPressureMeasurement(systolic: 117, diastolic: 76, meanArterialPressure: 67, unit: .mmHg, pulseRate: 68) + guard let sample = measurement.heartRateSample(source: nil) else { + preconditionFailure("Mock sample was unexpectedly invalid!") + } + return sample + } +} diff --git a/Sources/SpeziDevices/Health/BloodPressureMeasurement.Unit+HKUnit.swift b/Sources/SpeziDevices/Health/BloodPressureMeasurement.Unit+HKUnit.swift new file mode 100644 index 0000000..ea97069 --- /dev/null +++ b/Sources/SpeziDevices/Health/BloodPressureMeasurement.Unit+HKUnit.swift @@ -0,0 +1,23 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import HealthKit +import SpeziBluetoothServices + + +extension BloodPressureMeasurement.Unit { + /// The unit represented as a `HKUnit`. + public var hkUnit: HKUnit { + switch self { + case .mmHg: + return .millimeterOfMercury() + case .kPa: + return .pascalUnit(with: .kilo) + } + } +} diff --git a/Sources/SpeziDevices/Health/WeightMeasurement+HKSample.swift b/Sources/SpeziDevices/Health/WeightMeasurement+HKSample.swift new file mode 100644 index 0000000..06ce332 --- /dev/null +++ b/Sources/SpeziDevices/Health/WeightMeasurement+HKSample.swift @@ -0,0 +1,109 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + + +import HealthKit +import SpeziBluetoothServices + + +extension WeightMeasurement { + /// Convert the weight measurement to the HealthKit representation. + /// + /// Converts the weight measurement to a `HKQuantitySample`. + /// - Parameters: + /// - device: The device information to reference with the `HKQuantitySample`. + /// You may use ``HealthDevice/hkDevice`` to retrieve the device information from a ``HealthDevice``. + /// - resolution: The resolution provided by the `WeightScaleFeature` characteristic. Otherwise, assumes default resolution. + /// - Returns: Returns the `HKQuantitySample` with the weight value. + public func weightSample(source device: HKDevice?, resolution: WeightScaleFeature.WeightResolution = .unspecified) -> HKQuantitySample { + let value = weight(of: resolution) + + let quantityType = HKQuantityType(.bodyMass) + let quantity = HKQuantity(unit: unit.massUnit, doubleValue: value) + let date = timeStamp?.date ?? .now + + return HKQuantitySample( + type: quantityType, + quantity: quantity, + start: date, + end: date, + device: device, + metadata: nil + ) + } + + /// Convert the BMI measurement to the HealthKit representation. + /// + /// Converts the BMI measurement to a `HKQuantitySample`. + /// - Parameter device: The device information to reference with the `HKQuantitySample`. + /// You may use ``HealthDevice/hkDevice`` to retrieve the device information from a ``HealthDevice``. + /// - Returns: Returns the `HKQuantitySample` with the BMI value. Returns `nil` if the measurement didn't contain a BMI value. + public func bmiSample(source device: HKDevice?) -> HKQuantitySample? { + guard let bmi = additionalInfo?.bmi else { + return nil + } + + // `bmi` is in units of 0.1 kg/m2 + let bmiValue = Double(bmi) * 0.1 + + let unit: HKUnit = .count() // HealthKit uses count unit for BMI + let quantityType = HKQuantityType(.bodyMassIndex) + let quantity = HKQuantity(unit: unit, doubleValue: bmiValue) + + let date = timeStamp?.date ?? .now + + return HKQuantitySample(type: quantityType, quantity: quantity, start: date, end: date, device: device, metadata: nil) + } + + /// Convert the height measurement to the HealthKit representation. + /// + /// Converts the height measurement to a `HKQuantitySample`. + /// - Parameters: + /// - device: The device information to reference with the `HKQuantitySample`. + /// You may use ``HealthDevice/hkDevice`` to retrieve the device information from a ``HealthDevice``. + /// - resolution: The resolution provided by the `WeightScaleFeature` characteristic. Otherwise, assumes default resolution. + /// - Returns: Returns the `HKQuantitySample` with the height value. Returns `nil` if the measurement didn't contain a height value. + public func heightSample(source device: HKDevice?, resolution: WeightScaleFeature.HeightResolution = .unspecified) -> HKQuantitySample? { + guard let height = height(of: resolution) else { + return nil + } + + let quantityType = HKQuantityType(.height) + let quantity = HKQuantity(unit: unit.lengthUnit, doubleValue: height) + let date = timeStamp?.date ?? .now + + return HKQuantitySample(type: quantityType, quantity: quantity, start: date, end: date, device: device, metadata: nil) + } +} + +extension HKQuantitySample { + /// Retrieve a mock weight sample. + @_spi(TestingSupport) public static var mockWeighSample: HKQuantitySample { + let measurement = WeightMeasurement(weight: 8400, unit: .si) + + return measurement.weightSample(source: nil, resolution: .resolution5g) + } + + /// Retrieve a mock bmi sample. + @_spi(TestingSupport) public static var mockBmiSample: HKQuantitySample { + let measurement = WeightMeasurement(weight: 8400, unit: .si, additionalInfo: .init(bmi: 230, height: 1750)) + guard let sample = measurement.bmiSample(source: nil) else { + preconditionFailure("Mock sample was unexpectedly invalid!") + } + return sample + } + + /// Retrieve a mock height sample: + @_spi(TestingSupport) public static var mockHeightSample: HKQuantitySample { + let measurement = WeightMeasurement(weight: 8400, unit: .si, additionalInfo: .init(bmi: 230, height: 1750)) + guard let sample = measurement.heightSample(source: nil, resolution: .resolution1mm) else { + preconditionFailure("Mock sample was unexpectedly invalid!") + } + return sample + } +} diff --git a/Sources/SpeziDevices/Health/WeightMeasurement.Unit+HKUnit.swift b/Sources/SpeziDevices/Health/WeightMeasurement.Unit+HKUnit.swift new file mode 100644 index 0000000..439a526 --- /dev/null +++ b/Sources/SpeziDevices/Health/WeightMeasurement.Unit+HKUnit.swift @@ -0,0 +1,35 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + + +import HealthKit +import SpeziBluetoothServices + + +extension WeightMeasurement.Unit { + /// The mass unit represented as a `HKUnit`. + public var massUnit: HKUnit { + switch self { + case .si: + return .gramUnit(with: .kilo) + case .imperial: + return .pound() + } + } + + + /// The length unit represented as a `HKUnit`. + public var lengthUnit: HKUnit { + switch self { + case .si: + return .meter() + case .imperial: + return .inch() + } + } +} diff --git a/Sources/SpeziDevices/HealthMeasurements.swift b/Sources/SpeziDevices/HealthMeasurements.swift new file mode 100644 index 0000000..6396394 --- /dev/null +++ b/Sources/SpeziDevices/HealthMeasurements.swift @@ -0,0 +1,342 @@ +// +// This source file is part of the Stanford SpeziDevices open source project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +@preconcurrency import HealthKit +import OSLog +import Spezi +import SpeziBluetooth +import SpeziBluetoothServices +import SwiftData +import SwiftUI + + +/// Manage and process health measurements from nearby Bluetooth Peripherals. +/// +/// Use the `HealthMeasurements` module to collect health measurements from nearby Bluetooth devices like connected weight scales or +/// blood pressure cuffs. +/// - Note: Implement your device as a [`BluetoothDevice`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetoothdevice) +/// using [SpeziBluetooth](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth). +/// +/// To support `HealthMeasurements`, you need to adopt the ``HealthDevice`` protocol for your device. +/// One your device is loaded, register its measurement service with the `HealthMeasurements` module +/// by calling a suitable variant of `configureReceivingMeasurements(for:on:)`. +/// +/// ```swift +/// import SpeziDevices +/// +/// class MyDevice: HealthDevice { +/// @Service var deviceInformation = DeviceInformationService() +/// @Service var weightScale = WeightScaleService() +/// +/// @Dependency private var measurements: HealthMeasurements? +/// +/// required init() {} +/// +/// func configure() { +/// measurements?.configureReceivingMeasurements(for: self, on: weightScale) +/// } +/// } +/// ``` +/// +/// To display new measurements to the user and save them to your external data store, you can use ``MeasurementsRecordedSheet``. +/// Below is a short code example. +/// +/// ```swift +/// import SpeziDevices +/// import SpeziDevicesUI +/// +/// struct MyHomeView: View { +/// @Environment(HealthMeasurements.self) private var measurements +/// +/// var body: some View { +/// @Bindable var measurements = measurements +/// ContentView() +/// .sheet(isPresented: $measurements.shouldPresentMeasurements) { +/// MeasurementsRecordedSheet { samples in +/// // save the array of HKSamples +/// } +/// } +/// } +/// } +/// ``` +/// +/// - Important: Don't forget to configure the `HealthMeasurements` module in +/// your [`SpeziAppDelegate`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/speziappdelegate). +/// +/// ## Topics +/// +/// ### Configuring Health Measurements +/// - ``init()`` +/// +/// ### Register Devices +/// - ``configureReceivingMeasurements(for:on:)-8cbd0`` +/// - ``configureReceivingMeasurements(for:on:)-87sgc`` +/// +/// ### Processing Measurements +/// - ``shouldPresentMeasurements`` +/// - ``pendingMeasurements`` +/// - ``discardMeasurement(_:)`` +@Observable +public final class HealthMeasurements: @unchecked Sendable { + private let logger = Logger(subsystem: "ENGAGEHF", category: "HealthMeasurements") + + /// Determine if UI components displaying pending measurements should be displayed. + @MainActor public var shouldPresentMeasurements = false + /// The current queue of pending measurements. + /// + /// The newest measurement is always prepended. + /// To clear pending measurements call ``discardMeasurement(_:)``. + @MainActor public private(set) var pendingMeasurements: [HealthKitMeasurement] = [] + + @Dependency @ObservationIgnored private var bluetooth: Bluetooth? + + private var modelContainer: ModelContainer? + + /// Initialize the Health Measurements Module. + public required init() {} + + /// Initialize the Health Measurements Module with mock measurements. + /// - Parameter measurements: The list of measurements to inject. + @_spi(TestingSupport) + @MainActor + public convenience init(mock measurements: [HealthKitMeasurement]) { + self.init() + self.pendingMeasurements = measurements + } + + /// Configure the Module. + @_documentation(visibility: internal) + public func configure() { + let configuration: ModelConfiguration +#if targetEnvironment(simulator) + configuration = ModelConfiguration(isStoredInMemoryOnly: true) +#else + let storageUrl = URL.documentsDirectory.appending(path: "edu.stanford.spezidevices.health-measurements.sqlite") + configuration = ModelConfiguration(url: storageUrl) +#endif + + do { + self.modelContainer = try ModelContainer(for: StoredMeasurement.self, configurations: configuration) + } catch { + self.modelContainer = nil + self.logger.error("HealthMeasurements failed to initialize ModelContainer: \(error)") + return + } + + + Task.detached { @MainActor in + self.fetchMeasurements() + } + } + + /// Configure receiving and processing weight measurements from the provided service. + /// + /// Configures the device's weight measurements to be processed by the Health Measurements module. + /// + /// - Parameters: + /// - device: The device on which the service is present. + /// - service: The Weight Scale service to register. + public func configureReceivingMeasurements(for device: Device, on service: WeightScaleService) { + let hkDevice = device.hkDevice + + // make sure to not capture the device + service.$weightMeasurement.onChange { [weak self, weak service] measurement in + guard let self, let service else { + return + } + logger.debug("Received new weight measurement: \(String(describing: measurement))") + await handleNewMeasurement(.weight(measurement, service.features ?? []), from: hkDevice) + } + } + + /// Configure receiving and processing blood pressure measurements form the provided service. + /// + /// Configures the device's blood pressure measurements to be processed by the Health Measurements module. + /// + /// - Parameters: + /// - device: The device on which the service is present. + /// - service: The Blood Pressure service to register. + public func configureReceivingMeasurements(for device: Device, on service: BloodPressureService) { + let hkDevice = device.hkDevice + + // make sure to not capture the device + service.$bloodPressureMeasurement.onChange { [weak self, weak service] measurement in + guard let self, let service else { + return + } + logger.debug("Received new blood pressure measurement: \(String(describing: measurement))") + await handleNewMeasurement(.bloodPressure(measurement, service.features ?? []), from: hkDevice) + } + + logger.debug("Registered device \(device.label), \(device.id) with HealthMeasurements") + } + + @MainActor + private func handleNewMeasurement(_ measurement: BluetoothHealthMeasurement, from source: HKDevice) { + let id = loadMeasurement(measurement, form: source) + guard let id else { + return + } + + if let modelContainer { + let storeMeasurement = StoredMeasurement(associatedMeasurement: id, measurement: measurement, device: source) + modelContainer.mainContext.insert(storeMeasurement) + } else { + logger.warning("Measurement \(id) could not be persisted on disk due to missing ModelContainer!") + } + + shouldPresentMeasurements = true + } + + @MainActor + private func loadMeasurement(_ measurement: BluetoothHealthMeasurement, form source: HKDevice) -> UUID? { + let healthKitMeasurement: HealthKitMeasurement + switch measurement { + case let .weight(measurement, feature): + let sample = measurement.weightSample(source: source, resolution: feature.weightResolution) + let bmiSample = measurement.bmiSample(source: source) + let heightSample = measurement.heightSample(source: source, resolution: feature.heightResolution) + logger.debug("Measurement loaded: \(String(describing: measurement))") + + healthKitMeasurement = .weight(sample, bmi: bmiSample, height: heightSample) + case let .bloodPressure(measurement, _): + let bloodPressureSample = measurement.bloodPressureSample(source: source) + let heartRateSample = measurement.heartRateSample(source: source) + + guard let bloodPressureSample else { + logger.debug("Discarding invalid blood pressure measurement ...") + return nil + } + + logger.debug("Measurement loaded: \(String(describing: measurement))") + + healthKitMeasurement = .bloodPressure(bloodPressureSample, heartRate: heartRateSample) + } + + // prepend to pending measurements + pendingMeasurements.insert(healthKitMeasurement, at: 0) + return healthKitMeasurement.id + } + + /// Discard a pending measurement. + /// + /// Measurements are discarded if they are no longer of interest. Either because the users discarded the measurements contents or + /// if the measurement was processed otherwise (e.g., saved to an external data store). + + /// - Parameter measurement: The pending measurement to discard. + /// - Returns: Returns `true` if the measurement was in the array of pending measurement, `false` if nothing was discarded. + @MainActor + @discardableResult + public func discardMeasurement(_ measurement: HealthKitMeasurement) -> Bool { + guard let index = self.pendingMeasurements.firstIndex(of: measurement) else { + return false + } + let element = self.pendingMeasurements.remove(at: index) + + let id = element.id // we need to capture id, element.id results in #Predicate to not compile + do { + try modelContainer?.mainContext.delete( + model: StoredMeasurement.self, + where: #Predicate { $0.associatedMeasurement == id } + ) + } catch { + logger.error("Failed to remove measurement from storage: \(error)") + } + + return true + } +} + + +extension HealthMeasurements: Module, EnvironmentAccessible, DefaultInitializable {} + + +extension HealthMeasurements { + @MainActor + func refreshFetchingMeasurements() throws { + pendingMeasurements.removeAll() + if let modelContainer, modelContainer.mainContext.hasChanges { + try modelContainer.mainContext.save() + } + fetchMeasurements() + } + + @MainActor + private func fetchMeasurements() { + guard let modelContainer else { + return + } + + var fetchAll = FetchDescriptor( + sortBy: [SortDescriptor(\.storageDate)] + ) + fetchAll.includePendingChanges = true + + let context = modelContainer.mainContext + let storedMeasurements: [StoredMeasurement] + do { + storedMeasurements = try context.fetch(fetchAll) + } catch { + logger.error("Failed to retrieve stored measurements from disk \(error)") + return + } + + for storedMeasurement in storedMeasurements { + guard let id = loadMeasurement(storedMeasurement.healthMeasurement, form: storedMeasurement.device) else { + context.delete(storedMeasurement) + continue + } + + // Note, we associate `storedMeasurements` by the HealthKit sample UUID. + // However, when we redo the conversion, the identifier changes. + // Therefore, we need to make sure to update all associated ids after loading. + storedMeasurement.associatedMeasurement = id + } + + if context.hasChanges { + do { + try context.save() + } catch { + logger.error("Failed to save updated measurement id associations: \(error)") + } + } + } +} + + +extension HealthMeasurements { + /// Call in preview simulator wrappers. + /// + /// Loads a mock measurement to display in preview. + @_spi(TestingSupport) + @MainActor + public func loadMockWeightMeasurement() { + let device = MockDevice.createMockDevice() + + guard let measurement = device.weightScale.weightMeasurement else { + preconditionFailure("Mock Weight Measurement was never injected!") + } + + handleNewMeasurement(.weight(measurement, device.weightScale.features ?? []), from: device.hkDevice) + } + + /// Call in preview simulator wrappers. + /// + /// Loads a mock measurement to display in preview. + @_spi(TestingSupport) + @MainActor + public func loadMockBloodPressureMeasurement() { + let device = MockDevice.createMockDevice() + + guard let measurement = device.bloodPressure.bloodPressureMeasurement else { + preconditionFailure("Mock Blood Pressure Measurement was never injected!") + } + + handleNewMeasurement(.bloodPressure(measurement, device.bloodPressure.features ?? []), from: device.hkDevice) + } +} diff --git a/Sources/SpeziDevices/Measurements/BluetoothHealthMeasurement.swift b/Sources/SpeziDevices/Measurements/BluetoothHealthMeasurement.swift new file mode 100644 index 0000000..6739a5f --- /dev/null +++ b/Sources/SpeziDevices/Measurements/BluetoothHealthMeasurement.swift @@ -0,0 +1,68 @@ +// +// This source file is part of the Stanford SpeziDevices open source project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SpeziBluetoothServices + + +/// A measurement retrieved from a Bluetooth device. +/// +/// Bluetooth Measurements are represented using standardized measurement characteristics. +public enum BluetoothHealthMeasurement { + /// A weight measurement and its context. + case weight(WeightMeasurement, WeightScaleFeature) + /// A blood pressure measurement and its context. + case bloodPressure(BloodPressureMeasurement, BloodPressureFeature) +} + + +extension BluetoothHealthMeasurement: Hashable, Sendable {} + + +extension BluetoothHealthMeasurement: Codable { + enum MeasurementType: String, Codable { + case weight + case bloodPressure + } + + private enum CodingKeys: String, CodingKey { + case type + case measurement + case features + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let type = try container.decode(MeasurementType.self, forKey: .type) + switch type { + case .weight: + let measurement = try container.decode(WeightMeasurement.self, forKey: .measurement) + let features = try container.decode(WeightScaleFeature.self, forKey: .features) + self = .weight(measurement, features) + case .bloodPressure: + let measurement = try container.decode(BloodPressureMeasurement.self, forKey: .measurement) + let features = try container.decode(BloodPressureFeature.self, forKey: .features) + self = .bloodPressure(measurement, features) + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case let .weight(measurement, feature): + try container.encode(MeasurementType.weight, forKey: .type) + try container.encode(measurement, forKey: .measurement) + try container.encode(feature, forKey: .features) + case let .bloodPressure(measurement, feature): + try container.encode(MeasurementType.bloodPressure, forKey: .type) + try container.encode(measurement, forKey: .measurement) + try container.encode(feature, forKey: .features) + } + } +} diff --git a/Sources/SpeziDevices/Measurements/HealthKitMeasurement.swift b/Sources/SpeziDevices/Measurements/HealthKitMeasurement.swift new file mode 100644 index 0000000..57f0577 --- /dev/null +++ b/Sources/SpeziDevices/Measurements/HealthKitMeasurement.swift @@ -0,0 +1,58 @@ +// +// This source file is part of the Stanford SpeziDevices open source project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import HealthKit + + +/// A collection of HealthKit samples that form a measurement. +public enum HealthKitMeasurement { + /// A weight measurement with optional BMI and height samples. + case weight(HKQuantitySample, bmi: HKQuantitySample? = nil, height: HKQuantitySample? = nil) + /// A blood pressure correlation with an optional heart rate sample. + case bloodPressure(HKCorrelation, heartRate: HKQuantitySample? = nil) +} + + +extension HealthKitMeasurement: Hashable {} + + +extension HealthKitMeasurement { + /// The collection of HealthKit samples contained in the measurement. + public var samples: [HKSample] { + var samples: [HKSample] = [] + switch self { + case let .weight(sample, bmi, height): + samples.append(sample) + if let bmi { + samples.append(bmi) + } + if let height { + samples.append(height) + } + case let .bloodPressure(sample, heartRate): + samples.append(sample) + if let heartRate { + samples.append(heartRate) + } + } + + return samples + } +} + + +extension HealthKitMeasurement: Identifiable { + public var id: UUID { + switch self { + case let .weight(sample, _, _): + sample.uuid + case let .bloodPressure(sample, _): + sample.uuid + } + } +} diff --git a/Sources/SpeziDevices/Measurements/StoredMeasurement.swift b/Sources/SpeziDevices/Measurements/StoredMeasurement.swift new file mode 100644 index 0000000..d3d84b7 --- /dev/null +++ b/Sources/SpeziDevices/Measurements/StoredMeasurement.swift @@ -0,0 +1,314 @@ +// +// This source file is part of the Stanford SpeziDevices open source project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + + +import HealthKit +import SpeziBluetoothServices +import SpeziNumerics +import SwiftData + + +private struct CodableHKDevice { + let name: String? + let manufacturer: String? + let model: String? + let hardwareVersion: String? + let firmwareVersion: String? + let softwareVersion: String? + let localIdentifier: String? + let udiDeviceIdentifier: String? +} + + +/// Copy of the `BloodPressureMeasurement` type that just uses plain RawValue types to work around SwiftData coding issues and crashes. +private struct BloodPressureMeasurementSwiftDataWorkaroundContainer { // swiftlint:disable:this type_name + let systolicValue: UInt16 + let diastolicValue: UInt16 + let meanArterialPressure: UInt16 + let unit: String + + + let timeStamp: DateTime? + let pulseRate: UInt16? + + let userId: UInt8? + let measurementStatus: UInt16? + + + var measurement: BloodPressureMeasurement { + .init( + systolic: MedFloat16(bitPattern: systolicValue), + diastolic: MedFloat16(bitPattern: diastolicValue), + meanArterialPressure: MedFloat16(bitPattern: meanArterialPressure), + unit: .init(rawValue: unit) ?? .mmHg, + timeStamp: timeStamp, + pulseRate: pulseRate.map { MedFloat16(bitPattern: $0) }, + userId: userId, + measurementStatus: measurementStatus.map { .init(rawValue: $0) } + ) + } + + init(from measurement: BloodPressureMeasurement) { + self.systolicValue = measurement.systolicValue.rawValue + self.diastolicValue = measurement.diastolicValue.rawValue + self.meanArterialPressure = measurement.meanArterialPressure.rawValue + self.unit = measurement.unit.rawValue + self.timeStamp = measurement.timeStamp + self.pulseRate = measurement.pulseRate?.rawValue + self.userId = measurement.userId + self.measurementStatus = measurement.measurementStatus?.rawValue + } +} + + +private struct WeightMeasurementSwiftDataWorkaroundContainer { // swiftlint:disable:this type_name + let weight: UInt16 + let unit: String + + let timestamp: DateTime? + + let userId: UInt8? + let bmi: UInt16? + let height: UInt16? + + var measurement: WeightMeasurement { + var info: WeightMeasurement.AdditionalInfo? + if let bmi, let height { + info = .init(bmi: bmi, height: height) + } + + return WeightMeasurement( + weight: weight, + unit: .init(rawValue: unit) ?? .si, + timeStamp: timestamp, + userId: userId, + additionalInfo: info + ) + } + + init(from measurement: WeightMeasurement) { + self.weight = measurement.weight + self.unit = measurement.unit.rawValue + self.timestamp = measurement.timeStamp + self.userId = measurement.userId + self.bmi = measurement.additionalInfo?.bmi + self.height = measurement.additionalInfo?.height + } +} + + +// swiftlint:disable:next type_name +private struct SwiftDataBluetoothHealthMeasurementWorkaroundContainer { + enum MeasurementType: String, Codable { + case weight + case bloodPressure + } + + private let type: MeasurementType + + private var bloodPressureMeasurement: BloodPressureMeasurementSwiftDataWorkaroundContainer? + private var bloodPressureFeatures: BloodPressureFeature.RawValue? + + private var weightMeasurement: WeightMeasurementSwiftDataWorkaroundContainer? + private var weightScaleFeatures: WeightScaleFeature.RawValue? + + var measurement: BluetoothHealthMeasurement { + switch type { + case .bloodPressure: + guard let bloodPressureMeasurement, let bloodPressureFeatures else { + preconditionFailure("Inconsistent type") + } + return .bloodPressure(bloodPressureMeasurement.measurement, .init(rawValue: bloodPressureFeatures)) + case .weight: + guard let weightMeasurement, let weightScaleFeatures else { + preconditionFailure("Inconsistent type") + } + return .weight(weightMeasurement.measurement, .init(rawValue: weightScaleFeatures)) + } + } + + init(from measurement: BluetoothHealthMeasurement) { + switch measurement { + case let .bloodPressure(measurement, feature): + type = .bloodPressure + bloodPressureMeasurement = .init(from: measurement) + bloodPressureFeatures = feature.rawValue + weightMeasurement = nil + weightScaleFeatures = nil + case let .weight(measurement, features): + type = .weight + bloodPressureMeasurement = nil + bloodPressureFeatures = nil + weightMeasurement = .init(from: measurement) + weightScaleFeatures = features.rawValue + } + } +} + + +@Model +final class StoredMeasurement { + @Attribute(.unique) var associatedMeasurement: UUID + + private let measurement: SwiftDataBluetoothHealthMeasurementWorkaroundContainer + fileprivate let codableDevice: CodableHKDevice + + let storageDate: Date + + var device: HKDevice { + codableDevice.hkDevice + } + + var healthMeasurement: BluetoothHealthMeasurement { + measurement.measurement + } + + init(associatedMeasurement: UUID, measurement: BluetoothHealthMeasurement, device: HKDevice) { + self.associatedMeasurement = associatedMeasurement + self.measurement = .init(from: measurement) + self.codableDevice = CodableHKDevice(from: device) + self.storageDate = .now + } +} + + +extension CodableHKDevice: Codable {} + + +extension CodableHKDevice { + var hkDevice: HKDevice { + HKDevice( + name: name, + manufacturer: manufacturer, + model: model, + hardwareVersion: hardwareVersion, + firmwareVersion: firmwareVersion, + softwareVersion: softwareVersion, + localIdentifier: localIdentifier, + udiDeviceIdentifier: udiDeviceIdentifier + ) + } + + init(from hkDevice: HKDevice) { + self.name = hkDevice.name + self.manufacturer = hkDevice.manufacturer + self.model = hkDevice.model + self.hardwareVersion = hkDevice.hardwareVersion + self.firmwareVersion = hkDevice.firmwareVersion + self.softwareVersion = hkDevice.softwareVersion + self.localIdentifier = hkDevice.localIdentifier + self.udiDeviceIdentifier = hkDevice.udiDeviceIdentifier + } +} + + +extension BloodPressureMeasurementSwiftDataWorkaroundContainer: Codable { + enum CodingKeys: CodingKey { + case systolicValue + case diastolicValue + case meanArterialPressure + case unit + case timeStamp + case pulseRate + case userId + case measurementStatus + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.systolicValue = try container.decode(UInt16.self, forKey: .systolicValue) + self.diastolicValue = try container.decode(UInt16.self, forKey: .diastolicValue) + self.meanArterialPressure = try container.decode(UInt16.self, forKey: .meanArterialPressure) + self.unit = try container.decode(String.self, forKey: .unit) + self.timeStamp = try container.decodeIfPresent(DateTime.self, forKey: .timeStamp) + self.pulseRate = try container.decodeIfPresent(UInt16.self, forKey: .pulseRate) + self.userId = try container.decodeIfPresent(UInt8.self, forKey: .userId) + self.measurementStatus = try container.decodeIfPresent(UInt16.self, forKey: .measurementStatus) + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.systolicValue, forKey: .systolicValue) + try container.encode(self.diastolicValue, forKey: .diastolicValue) + try container.encode(self.meanArterialPressure, forKey: .meanArterialPressure) + try container.encode(self.unit, forKey: .unit) + try container.encodeIfPresent(self.timeStamp, forKey: .timeStamp) + try container.encodeIfPresent(self.pulseRate, forKey: .pulseRate) + try container.encodeIfPresent(self.userId, forKey: .userId) + try container.encodeIfPresent(self.measurementStatus, forKey: .measurementStatus) + } +} + + +extension WeightMeasurementSwiftDataWorkaroundContainer: Codable { + enum CodingKeys: CodingKey { + case weight + case unit + case timestamp + case userId + case bmi + case height + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.weight = try container.decode(UInt16.self, forKey: .weight) + self.unit = try container.decode(String.self, forKey: .unit) + self.timestamp = try container.decodeIfPresent(DateTime.self, forKey: .timestamp) + self.userId = try container.decodeIfPresent(UInt8.self, forKey: .userId) + self.bmi = try container.decodeIfPresent(UInt16.self, forKey: .bmi) + self.height = try container.decodeIfPresent(UInt16.self, forKey: .height) + } + + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.weight, forKey: .weight) + try container.encode(self.unit, forKey: .unit) + try container.encodeIfPresent(self.timestamp, forKey: .timestamp) + try container.encodeIfPresent(self.userId, forKey: .userId) + try container.encodeIfPresent(self.bmi, forKey: .bmi) + try container.encodeIfPresent(self.height, forKey: .height) + } +} + + +extension SwiftDataBluetoothHealthMeasurementWorkaroundContainer: Codable { + enum CodingKeys: CodingKey { + case type + case bloodPressureMeasurement + case bloodPressureFeatures + case weightMeasurement + case weightScaleFeatures + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.type = try container.decode(SwiftDataBluetoothHealthMeasurementWorkaroundContainer.MeasurementType.self, forKey: .type) + switch type { + case .bloodPressure: + self.bloodPressureMeasurement = try container.decodeIfPresent( + BloodPressureMeasurementSwiftDataWorkaroundContainer.self, + forKey: .bloodPressureMeasurement + ) + self.bloodPressureFeatures = try container.decodeIfPresent(BloodPressureFeature.RawValue.self, forKey: .bloodPressureFeatures) + case .weight: + self.weightMeasurement = try container.decodeIfPresent(WeightMeasurementSwiftDataWorkaroundContainer.self, forKey: .weightMeasurement) + self.weightScaleFeatures = try container.decodeIfPresent(WeightScaleFeature.RawValue.self, forKey: .weightScaleFeatures) + } + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.type, forKey: .type) + try container.encodeIfPresent(self.bloodPressureMeasurement, forKey: .bloodPressureMeasurement) + try container.encodeIfPresent(self.bloodPressureFeatures, forKey: .bloodPressureFeatures) + try container.encodeIfPresent(self.weightMeasurement, forKey: .weightMeasurement) + try container.encodeIfPresent(self.weightScaleFeatures, forKey: .weightScaleFeatures) + } +} diff --git a/Sources/SpeziDevices/Model/DevicePairingError.swift b/Sources/SpeziDevices/Model/DevicePairingError.swift new file mode 100644 index 0000000..da9da67 --- /dev/null +++ b/Sources/SpeziDevices/Model/DevicePairingError.swift @@ -0,0 +1,58 @@ +// +// This source file is part of the Stanford Spezi open-project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import SpeziFoundation + + +/// A device pairing error. +public enum DevicePairingError { + /// Device is currently in an invalid state. + /// + /// For example the device is not disconnected or the advertisement was not nearby. + case invalidState + /// The device is busy. + /// + /// For example the device is already within a pairing session + case busy + /// The device is not in pairing mode. + /// + /// The ``PairableDevice/isInPairingMode`` reports that the device is not pairable. + case notInPairingMode + /// The device disconnected while pairing. + /// + /// The device disconnecting indicated that the pairing failed. + case deviceDisconnected +} + + +extension DevicePairingError: LocalizedError { + public var errorDescription: String? { + switch self { + case .invalidState: + String(localized: "Invalid State") + case .busy: + String(localized: "Device Busy") + case .notInPairingMode: + String(localized: "Not Ready") + case .deviceDisconnected: + String(localized: "Pairing Failed") + } + } + + public var failureReason: String? { + switch self { + case .invalidState, .deviceDisconnected: + String(localized: "Failed to pair with device. Please try again.") + case .busy: + String(localized: "The device is busy and failed to complete pairing.") + case .notInPairingMode: + String(localized: "The device was not put into pairing mode.") + } + } +} diff --git a/Sources/SpeziDevices/Model/ImageReference.swift b/Sources/SpeziDevices/Model/ImageReference.swift new file mode 100644 index 0000000..d0514b8 --- /dev/null +++ b/Sources/SpeziDevices/Model/ImageReference.swift @@ -0,0 +1,45 @@ +// +// This source file is part of the Stanford Spezi open-project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +/// Reference an Image Resource. +public enum ImageReference { + /// Provides the system name for an image. + case system(String) + /// Reference an image from the asset catalog of a bundle. + case asset(String, bundle: Bundle? = nil) +} + + +extension ImageReference { + /// Retrieve Image. + /// + /// Returns nil if the image resource could not be located. + public var image: Image? { + switch self { + case let .system(name): + return Image(systemName: name) + case let .asset(name, bundle: bundle): + #if os(iOS) || os(visionOS) || os(tvOS) + guard UIImage(named: name, in: bundle, with: nil) != nil else { + return nil + } + #elseif os(macOS) + guard NSImage(named: name) != nil else { + return nil + } + #endif + return Image(name, bundle: bundle) + } + } +} + + +extension ImageReference: Hashable {} diff --git a/Sources/SpeziDevices/Model/PairedDeviceInfo.swift b/Sources/SpeziDevices/Model/PairedDeviceInfo.swift new file mode 100644 index 0000000..64ca7da --- /dev/null +++ b/Sources/SpeziDevices/Model/PairedDeviceInfo.swift @@ -0,0 +1,115 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import SwiftData + + +/// Persistent information stored of a paired device. +@Model +public final class PairedDeviceInfo { + /// The CoreBluetooth device identifier. + @Attribute(.unique) public let id: UUID + /// The device type. + /// + /// Stores the associated ``PairableDevice/deviceTypeIdentifier-9wsed`` device type used to locate the device implementation. + public let deviceType: String + /// A model string of the device. + public let model: String? + + /// The user edit-able name of the device. + public internal(set) var name: String + /// The date the device was last seen. + public internal(set) var lastSeen: Date + /// The last reported battery percentage of the device. + public internal(set) var lastBatteryPercentage: UInt8? + + /// The date at which the device was paired. + public let pairedAt: Date + + /// Could not retrieve the device from the Bluetooth central. + @Transient public internal(set) var notLocatable: Bool = false + /// Visual representation of the device. + @Transient public var icon: ImageReference? + + /// Create new paired device information. + /// - Parameters: + /// - id: The CoreBluetooth device identifier + /// - deviceType: The device type. + /// - name: The device name. + /// - model: A model string. + /// - icon: The device icon. + /// - lastSeen: The date the device was last seen. + /// - batteryPercentage: The last known battery percentage of the device. + public init( + id: UUID, + deviceType: String, + name: String, + model: String?, + icon: ImageReference? = nil, + lastSeen: Date = .now, + batteryPercentage: UInt8? = nil + ) { + self.id = id + self.deviceType = deviceType + self.name = name + self.model = model + self.icon = icon + self.lastSeen = lastSeen + self.lastBatteryPercentage = batteryPercentage + + self.pairedAt = .now + } +} + + +extension PairedDeviceInfo: Identifiable {} + + +extension PairedDeviceInfo: Hashable { + public static func == (lhs: PairedDeviceInfo, rhs: PairedDeviceInfo) -> Bool { + lhs.id == rhs.id + && lhs.deviceType == rhs.deviceType + && lhs.name == rhs.name + && lhs.model == rhs.model + && lhs.icon == rhs.icon + && lhs.lastSeen == rhs.lastSeen + && lhs.lastBatteryPercentage == rhs.lastBatteryPercentage + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + + +#if DEBUG +extension PairedDeviceInfo { + /// Mock Health Device 1 Data. + @_spi(TestingSupport) public static var mockHealthDevice1: PairedDeviceInfo { + PairedDeviceInfo( + id: UUID(), + deviceType: "HealthDevice1", + name: "Health Device 1", + model: "HD1", + icon: nil + ) + } + + /// Mock Health Device 2 Data. + @_spi(TestingSupport) public static var mockHealthDevice2: PairedDeviceInfo { + PairedDeviceInfo( + id: UUID(), + deviceType: "HealthDevice2", + name: "Health Device 2", + model: "HD2", + icon: nil + ) + } +} +#endif diff --git a/Sources/SpeziDevices/Model/PairingContinuation.swift b/Sources/SpeziDevices/Model/PairingContinuation.swift new file mode 100644 index 0000000..de1dddf --- /dev/null +++ b/Sources/SpeziDevices/Model/PairingContinuation.swift @@ -0,0 +1,46 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import SpeziFoundation + + +/// Stores pairing state information. +final class PairingContinuation { + private var pairingContinuation: CheckedContinuation + + /// Create a new pairing continuation management object. + init(_ continuation: CheckedContinuation) { + self.pairingContinuation = continuation + } + + func signalTimeout() { + pairingContinuation.resume(with: .failure(TimeoutError())) + } + + func signalCancellation() { + pairingContinuation.resume(with: .failure(CancellationError())) + } + + /// Signal that the device was successfully paired. + /// + /// This method should always be called if the condition for a successful pairing happened. It may be called even if there isn't currently a ongoing pairing. + func signalPaired() { + pairingContinuation.resume(with: .success(())) + } + + /// Signal that the device disconnected. + /// + /// This method should always be called if the condition for a successful pairing happened. It may be called even if there isn't currently a ongoing pairing. + func signalDisconnect() { + pairingContinuation.resume(with: .failure(DevicePairingError.deviceDisconnected)) + } +} + + +extension PairingContinuation {} diff --git a/Sources/SpeziDevices/PairedDevices.swift b/Sources/SpeziDevices/PairedDevices.swift new file mode 100644 index 0000000..0a1dd97 --- /dev/null +++ b/Sources/SpeziDevices/PairedDevices.swift @@ -0,0 +1,677 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import OrderedCollections +import Spezi +import SpeziBluetooth +import SpeziBluetoothServices +@_spi(TestingSupport) import SpeziFoundation +import SpeziViews +import SwiftData +import SwiftUI + + +/// Persistently pair with Bluetooth devices and automatically manage connections. +/// +/// Use the `PairedDevices` module to discover and pair ``PairableDevice``s and automatically manage connection establishment +/// of connected devices. +/// - Note: Implement your device as a [`BluetoothDevice`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetoothdevice) +/// using [SpeziBluetooth](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth). +/// +/// To support `PairedDevices`, you need to adopt the ``PairableDevice`` protocol for your device. +/// Optionally you can adopt ``BatteryPoweredDevice`` if your device supports the +/// [`BatteryService`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetoothservices/batteryservice). +/// Once your device is loaded, register it with the `PairedDevices` module by calling the ``configure(device:accessing:_:_:)`` method. +/// +/// - Important: Don't forget to configure the `PairedDevices` module in +/// your [`SpeziAppDelegate`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/speziappdelegate). +/// +/// ```swift +/// import SpeziDevices +/// +/// class MyDevice: PairableDevice { +/// @DeviceState(\.id) var id +/// @DeviceState(\.name) var name +/// @DeviceState(\.state) var state +/// @DeviceState(\.advertisementData) var advertisementData +/// @DeviceState(\.nearby) var nearby +/// +/// @Service var deviceInformation = DeviceInformationService() +/// +/// @DeviceAction(\.connect) var connect +/// @DeviceAction(\.disconnect) var disconnect +/// +/// var isInPairingMode: Bool { +/// // determine if a nearby device is in pairing mode +/// } +/// +/// @Dependency private var pairedDevices: PairedDevices? +/// +/// required init() {} +/// +/// func configure() { +/// pairedDevices?.configure(device: self, accessing: $state, $advertisementData, $nearby) +/// } +/// +/// func handleSuccessfulPairing() { // called on events where a device can be considered paired (e.g., incoming notifications) +/// pairedDevices?.signalDevicePaired(self) +/// } +/// } +/// ``` +/// +/// - Tip: To display and manage paired devices and support adding new paired devices, you can use the full-featured ``DevicesTab`` view. +/// +/// ## Topics +/// +/// ### Configuring Paired Devices +/// - ``init()`` +/// +/// ### Register Devices +/// - ``configure(device:accessing:_:_:)`` +/// +/// ### Pairing Nearby Devices +/// - ``shouldPresentDevicePairing`` +/// - ``discoveredDevices`` +/// - ``isScanningForNearbyDevices`` +/// - ``pair(with:timeout:)`` +/// - ``pairedDevices`` +/// +/// ### Forget Paired Device +/// - ``forgetDevice(id:)`` +/// +/// ### Manage Paired Devices +/// - ``isPaired(_:)`` +/// - ``isConnected(device:)`` +/// - ``updateName(for:name:)`` +@Observable +public final class PairedDevices: @unchecked Sendable { + /// 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 = [:] + /// The collection of paired devices that are persisted on disk. + @MainActor public var pairedDevices: [PairedDeviceInfo]? { // swiftlint:disable:this discouraged_optional_collection + didLoadDevices + ? Array(_pairedDevices.values) + : nil + } + + @MainActor private var _pairedDevices: OrderedDictionary = [:] + @MainActor private var didLoadDevices = false + + /// 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] = [:] + + @AppStorage("edu.stanford.spezi.SpeziDevices.ever-paired-once") @MainActor @ObservationIgnored private var everPairedDevice = false + + + @Application(\.logger) @ObservationIgnored private var logger + + @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. + @MainActor public var isScanningForNearbyDevices: Bool { + (pairedDevices?.isEmpty == true && !everPairedDevice) || shouldPresentDevicePairing + } + + private var stateSubscriptionTask: Task? { + willSet { + stateSubscriptionTask?.cancel() + } + } + + + /// Initialize the Paired Devices Module. + public required init() {} + + + /// Configures the Module. + @_documentation(visibility: internal) + public func configure() { + if bluetooth == nil { + self.logger.warning("PairedDevices Module initialized without Bluetooth dependency!") + } + + let configuration: ModelConfiguration +#if targetEnvironment(simulator) + configuration = ModelConfiguration(isStoredInMemoryOnly: true) +#else + let storageUrl = URL.documentsDirectory.appending(path: "edu.stanford.spezidevices.paired-devices.sqlite") + configuration = ModelConfiguration(url: storageUrl) +#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 { + return // no devices paired, no need to power up central + } + + await self.setupBluetoothStateSubscription() + } + } + + /// 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. + @MainActor + public func isConnected(device: UUID) -> Bool { + peripherals[device]?.state == .connected + } + + /// Determine if a device is paired. + /// - Parameter device: The device instance. + /// - Returns: Returns `true` if the given device is paired. + @MainActor + public func isPaired(_ device: Device) -> Bool { + _pairedDevices[device.id] != nil + } + + /// Update the user-chosen name of a paired device. + /// - Parameters: + /// - deviceInfo: The paired device information for which to update the name. + /// - 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 + } + + /// Configure a device to be managed by this PairedDevices instance. + /// - Parameters: + /// - device: The device instance to configure. + /// - state: The `@DeviceState` accessor for the `PeripheralState`. + /// - advertisements: The `@DeviceState` accessor for the current `AdvertisementData`. + /// - nearby: The `@DeviceState` accessor for the `nearby` flag. + public func configure( + device: Device, + accessing state: DeviceStateAccessor, + _ advertisements: DeviceStateAccessor, + _ nearby: DeviceStateAccessor + ) { + state.onChange { [weak self, weak device] oldValue, newValue in + if let device { + await self?.handleDeviceStateUpdated(device, old: oldValue, new: newValue) + } + } + advertisements.onChange(initial: true) { [weak self, weak device] _ in + guard let device else { + return + } + if device.isInPairingMode { + await self?.discoveredPairableDevice(device) + } + } + nearby.onChange { [weak self, weak device] nearby in + if let device, !nearby { + await self?.handleDiscardedDevice(device) + } + } + + if let batteryPowered = device as? any BatteryPoweredDevice { + batteryPowered.battery.$batteryLevel.onChange { [weak self, weak device] value in + guard let device, let self else { + return + } + await updateBattery(for: device, percentage: value) + } + } + + logger.debug("Registered device \(device.label), \(device.id) with PairedDevices") + } + + @MainActor + private func handleDeviceStateUpdated(_ device: Device, old oldState: PeripheralState, new newState: PeripheralState) { + switch newState { + case .connected: + cancelConnectionAttempt(for: device) // just clear the entry + updateLastSeen(for: device) + case .disconnecting: + if case .connected = oldState { + updateLastSeen(for: device) + } + case .disconnected: + ongoingPairings.removeValue(forKey: device.id)?.signalDisconnect() + + if case .connected = oldState { + updateLastSeen(for: device) + } + + // long-running reconnect (if applicable) + connectionAttempt(for: device) + default: + break + } + } + + @MainActor + private func discoveredPairableDevice(_ device: Device) { + guard discoveredDevices[device.id] == nil else { + return + } + + guard !isPaired(device) else { + return + } + + self.logger.info( + "Detected nearby \(Device.self) accessory\(device.advertisementData.manufacturerData.map { " with manufacturer data \($0)" } ?? "")" + ) + + discoveredDevices[device.id] = device + shouldPresentDevicePairing = true + } + + @MainActor + private func updateBattery(for device: Device, percentage: UInt8) { + guard let deviceInfo = _pairedDevices[device.id] else { + return + } + logger.debug("Updated battery level for \(device.label): \(percentage) %") + deviceInfo.lastBatteryPercentage = percentage + } + + @MainActor + private func updateLastSeen(for device: Device, lastSeen: Date = .now) { + guard let deviceInfo = _pairedDevices[device.id] else { + return + } + logger.debug("Updated lastSeen for \(device.label): \(lastSeen) %") + deviceInfo.lastSeen = lastSeen + } + + @MainActor + private func handleDiscardedDevice(_ device: Device) { + // device discovery was cleared by SpeziBluetooth + self.logger.debug("\(Device.self) \(device.label) was discarded from discovered devices.") + discoveredDevices[device.id] = nil + } + + @MainActor + private func connectionAttempt(for device: some PairableDevice) { + guard case .poweredOn = bluetooth?.state, isPaired(device) else { + return + } + + let previousTask = cancelConnectionAttempt(for: device) + + pendingConnectionAttempts[device.id] = Task { + await previousTask?.value // make sure its ordered + await device.connect() + } + } + + @MainActor + @discardableResult + private func cancelConnectionAttempt(for device: some PairableDevice) -> Task? { + let task = pendingConnectionAttempts.removeValue(forKey: device.id) + task?.cancel() + return task + } + + deinit { + _peripherals.removeAll() + stateSubscriptionTask = nil + } +} + + +extension PairedDevices: Module, EnvironmentAccessible, DefaultInitializable {} + +// MARK: - Device Pairing + +extension PairedDevices { + /// Pair with a recently discovered device. + /// + /// This method pairs with a currently advertising Bluetooth device. + /// - Note: The ``PairableDevice/isInPairingMode`` property determines if the device is currently pairable. + /// + /// The implementation verifies that the device is ``PairableDevice/isInPairingMode``, is currently disconnected and ``PairableDevice/nearby``. + /// It automatically connects to the device to start pairing. Pairing has a 15 second timeout by default. + /// Pairing is considered successful once ``signalDevicePaired(_:)`` is called by the device. It is considered unsuccessful if the device + /// disconnects prior to this call. + /// - Important: A successful pairing cannot be determined automatically and is specific to a device. You must manually call + /// ``signalDevicePaired(_:)`` to signal that a device is successfully paired (e.g., every time the device sends a notification for + /// a given characteristic). + /// - Parameters: + /// - device: The device to pair with this module. + /// - timeout: The duration after which the pairing attempt times out. + /// - Throws: Throws a ``DevicePairingError`` if not successful. + @MainActor + public func pair(with device: some PairableDevice, timeout: Duration = .seconds(15)) async throws { + guard ongoingPairings[device.id] == nil else { + throw DevicePairingError.busy + } + + guard device.isInPairingMode else { + throw DevicePairingError.notInPairingMode + } + + guard case .disconnected = device.state else { + throw DevicePairingError.invalidState + } + + guard device.nearby else { + throw DevicePairingError.invalidState + } + + await device.connect() + + let id = device.id + async let _ = withTimeout(of: timeout) { @MainActor in + ongoingPairings.removeValue(forKey: id)?.signalTimeout() + } + + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + ongoingPairings[id] = PairingContinuation(continuation) + } + } onCancel: { + Task { @MainActor [weak device] in + ongoingPairings.removeValue(forKey: id)?.signalCancellation() + await device?.disconnect() + } + } + + // if cancelled the continuation throws an CancellationError + await registerPairedDevice(device) + } + + /// Signal that a device is considered paired. + /// + /// You call this method from your device implementation on events that indicate that the device was successfully paired. + /// - Note: This method does nothing if there is currently no ongoing pairing session for a device. + /// - Parameter device: The device that can be considered paired and might have an ongoing pairing session. + /// - Returns: Returns `true` if there was an ongoing pairing session and the device is now paired. + @MainActor + @discardableResult + public func signalDevicePaired(_ device: some PairableDevice) -> Bool { + guard let continuation = ongoingPairings.removeValue(forKey: device.id) else { + return false + } + logger.debug("Device \(device.label), \(device.id) signaled it is fully paired.") + continuation.signalPaired() + return true + } + + @MainActor + private func registerPairedDevice(_ device: Device) async { + everPairedDevice = true + + var batteryLevel: UInt8? + if let batteryDevice = device as? any BatteryPoweredDevice { + batteryLevel = batteryDevice.battery.batteryLevel + } + + if device.deviceInformation.modelNumber == nil && device.deviceInformation.$modelNumber.isPresent { + // make sure it isn't just a race condition that we haven't received a value yet + do { + let readModel = try await device.deviceInformation.$modelNumber.read() + self.logger.info("ModelNumber was not present on device \(device.label), was read as \"\(readModel)\".") + } catch { + logger.debug("Failed to retrieve model number for device \(Device.self): \(error)") + } + } + + let deviceInfo = PairedDeviceInfo( + id: device.id, + deviceType: Device.deviceTypeIdentifier, + name: device.label, + model: device.deviceInformation.modelNumber, + icon: Device.icon, + batteryPercentage: batteryLevel + ) + + _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 + + + assert(peripherals[device.id] == nil, "Cannot overwrite peripheral. Device \(deviceInfo) was paired twice.") + peripherals[device.id] = device + + self.logger.debug("Device \(device.label) with id \(device.id) is now paired!") + + if stateSubscriptionTask == nil { + await setupBluetoothStateSubscription() + } + } + + /// Forget a paired device. + /// - Parameter id: The Bluetooth peripheral identifier of a paired device. + @MainActor + public func forgetDevice(id: UUID) { + 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() + } + } + + if _pairedDevices.isEmpty { + Task { + await cancelSubscription() + } + } + } +} + + +// MARK: - Paired Peripheral Management + +extension PairedDevices { + @MainActor + private func fetchAllPairedInfos() { + defer { + didLoadDevices = true + } + + guard let modelContainer else { + return + } + + let context = modelContainer.mainContext + var allPairedDevices = FetchDescriptor( + sortBy: [SortDescriptor(\.pairedAt)] + ) + allPairedDevices.includePendingChanges = true + + do { + let pairedDevices = try context.fetch(allPairedDevices) + self._pairedDevices = pairedDevices.reduce(into: [:]) { partialResult, deviceInfo in + partialResult[deviceInfo.id] = deviceInfo + } + logger.debug("Initialized PairedDevices with \(self._pairedDevices.count) paired devices!") + } catch { + logger.error("Failed to fetch paired device info from disk: \(error)") + } + } + + @MainActor + func refreshPairedDevices() throws { + _pairedDevices.removeAll() + didLoadDevices = false + + if let modelContainer, modelContainer.mainContext.hasChanges { + try modelContainer.mainContext.save() + } + + fetchAllPairedInfos() + } + + @MainActor + private func syncDeviceIcons() { + guard let bluetooth else { + return + } + + let configuredDevices = bluetooth.configuredPairableDevices + + for deviceInfo in _pairedDevices.values { + guard let deviceType = configuredDevices[deviceInfo.deviceType] else { + continue + } + + deviceInfo.icon = deviceType.icon + } + } + + @MainActor + private func setupBluetoothStateSubscription() async { + assert(!_pairedDevices.isEmpty, "Bluetooth State subscription doesn't need to be set up without any paired devices.") + + guard let bluetooth else { + return + } + + // If Bluetooth is currently turned off in control center or not authorized anymore, we would want to keep central allocated + // such that we are notified about the bluetooth state changing. + await bluetooth.powerOn() + + self.stateSubscriptionTask = Task.detached { [weak self] in + for await nextState in await bluetooth.stateSubscription { + guard let self else { + return + } + await self.handleBluetoothStateChanged(nextState) + } + } + + if case .poweredOn = bluetooth.state { + await self.handleCentralPoweredOn() + } + } + + @MainActor + private func cancelSubscription() async { + assert(_pairedDevices.isEmpty, "Bluetooth State subscription was tried to be cancelled even though devices were still paired.") + assert(peripherals.isEmpty, "Peripherals were unexpectedly not empty.") + + stateSubscriptionTask = nil + await bluetooth?.powerOff() + } + + @MainActor + private func handleBluetoothStateChanged(_ state: BluetoothState) async { + logger.debug("Bluetooth Module state is now \(state)") + + switch state { + case .poweredOn: + await handleCentralPoweredOn() + default: + for device in peripherals.values { + cancelConnectionAttempt(for: device) + } + peripherals.removeAll() + } + } + + @MainActor + private func handleCentralPoweredOn() async { + guard let bluetooth else { + return + } + + guard case .poweredOn = bluetooth.state else { + return + } + + // we just reuse the configured Bluetooth devices + let configuredDevices = bluetooth.configuredPairableDevices + + await withDiscardingTaskGroup { group in + for deviceInfo in self._pairedDevices.values { + group.addTask { @MainActor in + guard self.peripherals[deviceInfo.id] == nil else { + return + } + + guard let deviceType = configuredDevices[deviceInfo.deviceType] else { + self.logger.error("Unsupported device type \"\(deviceInfo.deviceType)\" for paired device \(deviceInfo.name).") + deviceInfo.notLocatable = true + return + } + await self.handleDeviceRetrieval(for: deviceInfo, deviceType: deviceType) + } + } + } + } + + @MainActor + private func handleDeviceRetrieval(for deviceInfo: PairedDeviceInfo, deviceType: any PairableDevice.Type) async { + guard let bluetooth else { + return + } + + let device = await deviceType.retrieveDevice(from: bluetooth, with: deviceInfo.id) + + guard let device else { + self.logger.warning("Device \(deviceInfo.id) \(deviceInfo.name) could not be retrieved!") + deviceInfo.notLocatable = true + return + } + + assert(self.peripherals[device.id] == nil, "Cannot overwrite peripheral. Device \(deviceInfo) was paired twice.") + self.peripherals[device.id] = device + + connectionAttempt(for: device) + } +} + + +extension Bluetooth { + fileprivate nonisolated var configuredPairableDevices: [String: any PairableDevice.Type] { + configuration.reduce(into: [:]) { partialResult, descriptor in + guard let pairableDevice = descriptor.deviceType as? any PairableDevice.Type else { + return + } + partialResult[pairableDevice.deviceTypeIdentifier] = pairableDevice + } + } +} + + +extension PairableDevice { + fileprivate static func retrieveDevice(from bluetooth: Bluetooth, with id: UUID) async -> Self? { + await bluetooth.retrieveDevice(for: id) + } +} + +// swiftlint:disable:this file_length diff --git a/Sources/SpeziDevices/SpeziDevices.docc/HealthKit.md b/Sources/SpeziDevices/SpeziDevices.docc/HealthKit.md new file mode 100644 index 0000000..4a736b4 --- /dev/null +++ b/Sources/SpeziDevices/SpeziDevices.docc/HealthKit.md @@ -0,0 +1,58 @@ +# HealthKit + +Convert Bluetooth measurement types to HealthKit samples. + + + +## Overview + +SpeziDevices helps developers converting measurements received from Bluetooth devices to HealthKit sample types. + +### Device Information + +As soon as you conform your [SpeziBluetooth `BluetoothDevice`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetoothdevice) +to the ``HealthDevice`` protocol and implement the [`DeviceInformationService`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetoothservices/deviceinformationservice), +you can access the [`HKDevice`](https://developer.apple.com/documentation/healthkit/hkdevice) +description using the ``HealthDevice/hkDevice-32s1d`` property + +### Converting Measurements + +SpeziDevices can convert your Bluetooth Health Measurement characteristics into HealthKit samples. +This is support for characteristics like [`BloodPressureMeasurement`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetoothservices/bloodpressuremeasurement) +or [`WeightMeasurement`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetoothservices/weightmeasurement). + +Use methods like ``SpeziBluetoothServices/BloodPressureMeasurement/bloodPressureSample(source:)`` or +``SpeziBluetoothServices/WeightMeasurement/weightSample(source:resolution:)`` to convert these measurements to their respective HealthKit Sample +representation. + +> Tip: By using the [`resource`](https://swiftpackageindex.com/stanfordbdhg/healthkitonfhir/documentation/healthkitonfhir/healthkit/hksample/resource) + provided through [`HealthKitOnFHIR`](https://swiftpackageindex.com/StanfordBDHG/HealthKitOnFHIR/documentation/healthkitonfhir) you can convert + your Bluetooth measurements to [HL7 FHIR Observation Resources](http://hl7.org/fhir/R4/observation.html). + +## Topics + +### Device + +- ``HealthDevice/hkDevice-32s1d`` + +### Blood Pressure Measurement + +- ``SpeziBluetoothServices/BloodPressureMeasurement/Unit/hkUnit`` +- ``SpeziBluetoothServices/BloodPressureMeasurement/bloodPressureSample(source:)`` +- ``SpeziBluetoothServices/BloodPressureMeasurement/heartRateSample(source:)`` + +### Weight Measurement + +- ``SpeziBluetoothServices/WeightMeasurement/Unit/massUnit`` +- ``SpeziBluetoothServices/WeightMeasurement/Unit/lengthUnit`` +- ``SpeziBluetoothServices/WeightMeasurement/weightSample(source:resolution:)`` +- ``SpeziBluetoothServices/WeightMeasurement/bmiSample(source:)`` +- ``SpeziBluetoothServices/WeightMeasurement/heightSample(source:resolution:)`` diff --git a/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md b/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md new file mode 100644 index 0000000..5d8fc92 --- /dev/null +++ b/Sources/SpeziDevices/SpeziDevices.docc/SpeziDevices.md @@ -0,0 +1,147 @@ +# ``SpeziDevices`` + +Support interactions with Bluetooth Devices. + + + +## Overview + +SpeziDevices abstracts common interactions with Bluetooth devices that are implemented using +[SpeziBluetooth](https://swiftpackageindex.com/StanfordSpezi/SpeziBluetooth/documentation/spezibluetooth). +It supports pairing with devices and process health measurements. + +### Pairing Devices + +Pairing devices is a good way of making sure that your application only connects to fixed set of devices and doesn't accept data from +non-authorized devices. +Further, it might be necessary to ensure certain operations stay secure. + +Use the ``PairedDevices`` module to discover and pair ``PairableDevice``s and automatically manage connection establishment +of connected devices. + +To support `PairedDevices`, you need to adopt the ``PairableDevice`` protocol for your device. +Optionally you can adopt the ``BatteryPoweredDevice`` protocol, if your device supports the +[`BatteryService`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetoothservices/batteryservice). +Once your device is loaded, register it with the `PairedDevices` module by calling the ``PairedDevices/configure(device:accessing:_:_:)`` method. + + +> Important: Don't forget to configure the `PairedDevices` module in + your [`SpeziAppDelegate`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/speziappdelegate). + +```swift +import SpeziDevices + +class MyDevice: PairableDevice { + @DeviceState(\.id) var id + @DeviceState(\.name) var name + @DeviceState(\.state) var state + @DeviceState(\.advertisementData) var advertisementData + @DeviceState(\.nearby) var nearby + + @Service var deviceInformation = DeviceInformationService() + + @DeviceAction(\.connect) var connect + @DeviceAction(\.disconnect) var disconnect + + var isInPairingMode: Bool { + // determine if a nearby device is in pairing mode + } + + @Dependency private var pairedDevices: PairedDevices? + + required init() {} + + func configure() { + pairedDevices?.configure(device: self, accessing: $state, $advertisementData, $nearby) + } + + func handleSuccessfulPairing() { // called on events where a device can be considered paired (e.g., incoming notifications) + pairedDevices?.signalDevicePaired(self) + } +} +``` + +> Tip: To display and manage paired devices and support adding new paired devices, you can use the full-featured ``DevicesView`` view. + +### Health Measurements + +Use the ``HealthMeasurements`` module to collect health measurements from nearby Bluetooth devices like connected weight scales or +blood pressure cuffs. + +To support `HealthMeasurements`, you need to adopt the ``HealthDevice`` protocol for your device. +One your device is loaded, register its measurement service with the `HealthMeasurements` module +by calling a suitable variant of `configureReceivingMeasurements(for:on:)`. + +```swift +import SpeziDevices + +class MyDevice: HealthDevice { + @Service var deviceInformation = DeviceInformationService() + @Service var weightScale = WeightScaleService() + + @Dependency private var measurements: HealthMeasurements? + + required init() {} + + func configure() { + measurements?.configureReceivingMeasurements(for: self, on: weightScale) + } +} +``` + +To display new measurements to the user and save them to your external data store, you can use ``MeasurementsRecordedSheet``. +Below is a short code example. + +```swift +import SpeziDevices +import SpeziDevicesUI + +struct MyHomeView: View { + @Environment(HealthMeasurements.self) private var measurements + + var body: some View { + @Bindable var measurements = measurements + ContentView() + .sheet(isPresented: $measurements.shouldPresentMeasurements) { + MeasurementsRecordedSheet { measurement in + // handle saving the measurement + } + } + } +} +``` + +> Important: Don't forget to configure the `HealthMeasurements` module in + your [`SpeziAppDelegate`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/speziappdelegate). + +## Topics + +### Device Pairing + +- ``PairedDevices`` +- ``PairedDeviceInfo`` +- ``DevicePairingError`` +- ``ImageReference`` + +### Devices + +- ``GenericBluetoothPeripheral`` +- ``GenericDevice`` +- ``BatteryPoweredDevice`` +- ``PairableDevice`` + +### Processing Measurements + +- ``HealthMeasurements`` +- ``HealthDevice`` +- ``BluetoothHealthMeasurement`` +- +- ``HealthKitMeasurement`` diff --git a/Sources/SpeziDevices/Testing/MockBluetoothPeripheral.swift b/Sources/SpeziDevices/Testing/MockBluetoothPeripheral.swift new file mode 100644 index 0000000..e6ff466 --- /dev/null +++ b/Sources/SpeziDevices/Testing/MockBluetoothPeripheral.swift @@ -0,0 +1,25 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziBluetooth + + +/// Mock peripheral used for internal previews. +@_spi(TestingSupport) +public struct MockBluetoothPeripheral: GenericBluetoothPeripheral { + public let label: String + public let state: PeripheralState + public let requiresUserAttention: Bool + + + public init(label: String, state: PeripheralState, requiresUserAttention: Bool = false) { + self.label = label + self.state = state + self.requiresUserAttention = requiresUserAttention + } +} diff --git a/Sources/SpeziDevices/Testing/MockDevice.swift b/Sources/SpeziDevices/Testing/MockDevice.swift new file mode 100644 index 0000000..bd2e405 --- /dev/null +++ b/Sources/SpeziDevices/Testing/MockDevice.swift @@ -0,0 +1,211 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation +@_spi(TestingSupport) import SpeziBluetooth +import SpeziBluetoothServices +import SpeziNumerics + + +@_spi(TestingSupport) +public final class MockDevice: PairableDevice, HealthDevice, BatteryPoweredDevice, @unchecked Sendable { + @DeviceState(\.id) public var id + @DeviceState(\.name) public var name + @DeviceState(\.state) public var state + @DeviceState(\.advertisementData) public var advertisementData + @DeviceState(\.nearby) public var nearby + + @DeviceAction(\.connect) public var connect + @DeviceAction(\.disconnect) public var disconnect + + + @Service public var deviceInformation = DeviceInformationService() + @Service public var battery = BatteryService() + + // Some mock health measurement services + @Service public var bloodPressure = BloodPressureService() + @Service public var weightScale = WeightScaleService() + + @Dependency private var pairedDevices: PairedDevices? + + public var isInPairingMode: Bool = false + + public init() {} + + + public func configure() { + $state.onChange { [weak self] state in + self?.handleStateChange(state) + } + } + + + fileprivate func handleStateChange(_ state: PeripheralState) { + if isInPairingMode { // automatically respond to pairing event + if case .connected = state { + Task { @MainActor in + try await Task.sleep(for: .seconds(2)) + + guard case .connected = self.state else { + return + } + pairedDevices?.signalDevicePaired(self) + } + } + } + } +} + + +extension MockDevice { + /// Create a new Mock Device instance. + /// + /// - Parameters: + /// - name: The name of the device. + /// - state: The initial peripheral state. + /// - nearby: The nearby state. + /// - bloodPressureMeasurement: The blood pressure measurement loaded into the device. + /// - weightMeasurement: The weight measurement loaded into the device. + /// - weightResolution: The weight resolution to use. + /// - heightResolution: The height resolution to use. + /// - Returns: Returns the initialized Mock Device. + @_spi(TestingSupport) + public static func createMockDevice( + name: String = "Mock Device", + state: PeripheralState = .disconnected, + nearby: Bool = true, + bloodPressureMeasurement: BloodPressureMeasurement = .mock(), + weightMeasurement: WeightMeasurement = .mock(), + weightResolution: WeightScaleFeature.WeightResolution = .resolution5g, + heightResolution: WeightScaleFeature.HeightResolution = .resolution1mm + ) -> MockDevice { + let device = MockDevice() + + device.deviceInformation.$manufacturerName.inject("Mock Company") + device.deviceInformation.$modelNumber.inject("MD1") + device.deviceInformation.$hardwareRevision.inject("2") + device.deviceInformation.$firmwareRevision.inject("1.0") + + device.battery.$batteryLevel.inject(85) + + device.$id.inject(UUID()) + device.$name.inject(name) + device.$state.inject(state) + device.$nearby.inject(nearby) + + device.bloodPressure.$features.inject([ + .bodyMovementDetectionSupported, + .irregularPulseDetectionSupported + ]) + device.bloodPressure.$bloodPressureMeasurement.inject(bloodPressureMeasurement) + + device.weightScale.$features.inject(WeightScaleFeature( + weightResolution: weightResolution, + heightResolution: heightResolution, + options: .timeStampSupported + )) + device.weightScale.$weightMeasurement.inject(weightMeasurement) + + device.$connect.inject { @MainActor [weak device] in + guard let device else { + return + } + device.$state.inject(.connecting) + + try? await Task.sleep(for: .seconds(1)) + + if case .connecting = device.state { + device.$state.inject(.connected) + } + } + + device.$disconnect.inject { @MainActor [weak device] in + device?.$state.inject(.disconnected) + } + + device.$state.enableSubscriptions() + device.$advertisementData.enableSubscriptions() + device.$nearby.enableSubscriptions() + + device.battery.$batteryLevel.enableSubscriptions() + device.battery.$batteryLevel.enablePeripheralSimulation() + + device.bloodPressure.$bloodPressureMeasurement.enableSubscriptions() + device.bloodPressure.$bloodPressureMeasurement.enablePeripheralSimulation() + + device.weightScale.$weightMeasurement.enableSubscriptions() + device.weightScale.$weightMeasurement.enablePeripheralSimulation() + + return device + } +} + + +extension BloodPressureMeasurement { + /// Create a mock blood pressure measurement. + /// - Parameters: + /// - systolic: The systolic value. + /// - diastolic: The diastolic value. + /// - meanArterialPressure: The mean arterial perssure. + /// - unit: The unit. + /// - timeStamp: The timestamp. + /// - pulseRate: The pulse rate. + /// - userId: The associated user id. + /// - status: The measurement status. + /// - Returns: + @_spi(TestingSupport) + public static func mock( + systolic: MedFloat16 = 103, + diastolic: MedFloat16 = 64, + meanArterialPressure: MedFloat16 = 77, + unit: BloodPressureMeasurement.Unit = .mmHg, + timeStamp: DateTime? = DateTime(year: 2024, month: .june, day: 5, hours: 12, minutes: 33, seconds: 11), + pulseRate: MedFloat16 = 62, + userId: UInt8 = 1, + status: BloodPressureMeasurement.Status = [] + ) -> BloodPressureMeasurement { + BloodPressureMeasurement( + systolic: systolic, + diastolic: diastolic, + meanArterialPressure: meanArterialPressure, + unit: unit, + timeStamp: timeStamp, + pulseRate: pulseRate, + userId: userId, + measurementStatus: status + ) + } +} + + +extension WeightMeasurement { + /// Create a mock weight measurement. + /// - Parameters: + /// - weight: The weight value. + /// - unit: The unit. + /// - timeStamp: The timestamp. + /// - userId: The associated user id. + /// - additionalInfo: Additional measurement information like BMI and height. + /// - Returns: + @_spi(TestingSupport) + public static func mock( + weight: UInt16 = 8400, + unit: WeightMeasurement.Unit = .si, + timeStamp: DateTime? = DateTime(year: 2024, month: .june, day: 5, hours: 12, minutes: 33, seconds: 11), + userId: UInt8? = 1, + additionalInfo: AdditionalInfo? = .init(bmi: 230, height: 1790) + ) -> WeightMeasurement { + WeightMeasurement( + weight: weight, + unit: unit, + timeStamp: timeStamp, + userId: userId, + additionalInfo: additionalInfo + ) + } +} diff --git a/Sources/SpeziDevicesUI/Devices/BatteryIcon.swift b/Sources/SpeziDevicesUI/Devices/BatteryIcon.swift new file mode 100644 index 0000000..66b1e3b --- /dev/null +++ b/Sources/SpeziDevicesUI/Devices/BatteryIcon.swift @@ -0,0 +1,102 @@ +// +// This source file is part of the Stanford Spezi open-project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +/// Battery Icon with optional label. +public struct BatteryIcon: View { + private let percentage: Int + private let isCharging: Bool + + + public var body: some View { + Label { + Text(verbatim: "\(percentage) %") + } icon: { + batteryIcon // hides accessibility, only text will be shown + .foregroundStyle(.primary) + } + .accessibilityRepresentation { + if !isCharging { + Text(verbatim: "\(percentage) %") + } else { + Text(verbatim: "\(percentage) %, is charging") + } + } + } + + + @ViewBuilder private var batteryIcon: some View { + Group { + if isCharging { + Image(systemName: "battery.100percent.bolt") + } else if percentage >= 90 { + Image(systemName: "battery.100") + } else if percentage >= 65 { + Image(systemName: "battery.75") + } else if percentage >= 40 { + Image(systemName: "battery.50") + } else if percentage >= 15 { + Image(systemName: "battery.25") + } else if percentage > 3 { + Image(systemName: "battery.25") + .symbolRenderingMode(.palette) + .foregroundStyle(.red, .primary) + } else { + Image(systemName: "battery.0") + .foregroundColor(.red) + } + } + .accessibilityHidden(true) + } + + + /// Create a new battery icon with charging indication. + /// - Parameters: + /// - percentage: The current battery percentage. + /// - isCharging: Indicate if the device is currently charging. + public init(percentage: Int, isCharging: Bool) { + self.percentage = percentage + self.isCharging = isCharging + } + + /// Create a new battery icon. + /// - Parameter percentage: The current battery percentage. + public init(percentage: Int) { + // isCharging=false is the same behavior as having no charging information + self.init(percentage: percentage, isCharging: false) + } +} + + +#if DEBUG +#Preview { + BatteryIcon(percentage: 100) +} + +#Preview { + BatteryIcon(percentage: 85, isCharging: true) +} + +#Preview { + BatteryIcon(percentage: 70) +} + +#Preview { + BatteryIcon(percentage: 50) +} + +#Preview { + BatteryIcon(percentage: 25) +} + +#Preview { + BatteryIcon(percentage: 10) +} +#endif diff --git a/Sources/SpeziDevicesUI/Devices/DeviceDetailsView.swift b/Sources/SpeziDevicesUI/Devices/DeviceDetailsView.swift new file mode 100644 index 0000000..f022e9e --- /dev/null +++ b/Sources/SpeziDevicesUI/Devices/DeviceDetailsView.swift @@ -0,0 +1,134 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +@_spi(TestingSupport) import SpeziDevices +import SpeziViews +import SwiftUI + + +/// Show the device details of a paired device. +public struct DeviceDetailsView: View { + private let deviceInfo: PairedDeviceInfo + + @Environment(\.dismiss) private var dismiss + @Environment(PairedDevices.self) private var pairedDevices + + @State private var presentForgetConfirmation = false + + private var image: Image { + deviceInfo.icon?.image ?? Image(systemName: "sensor") // swiftlint:disable:this accessibility_label_for_image + } + + private var lastSeenToday: Bool { + Calendar.current.isDateInToday(deviceInfo.lastSeen) + } + + public var body: some View { + List { + Section { + imageHeader + } + + DeviceInfoSection(deviceInfo: deviceInfo) + + if let percentage = deviceInfo.lastBatteryPercentage { + Section { + ListRow("Battery") { + BatteryIcon(percentage: Int(percentage)) + .labelStyle(.reverse) + } + } + } + + Section { + Button("Forget This Device") { + presentForgetConfirmation = true + } + } footer: { + if pairedDevices.isConnected(device: deviceInfo.id) { + Text("Synchronizing ...") + } else if lastSeenToday { + Text("This device was last seen at \(Text(deviceInfo.lastSeen, style: .time))") + } else { + Text("This device was last seen on \(Text(deviceInfo.lastSeen, style: .date)) at \(Text(deviceInfo.lastSeen, style: .time))") + } + } + } + .navigationTitle("Device Details") + .navigationBarTitleDisplayMode(.inline) + .confirmationDialog("Do you really want to forget this device?", isPresented: $presentForgetConfirmation, titleVisibility: .visible) { + Button("Forget Device", role: .destructive) { + ForgetDeviceTip.hasRemovedPairedDevice = true + pairedDevices.forgetDevice(id: deviceInfo.id) + dismiss() + } + Button("Cancel", role: .cancel) {} + } + .toolbar { + if pairedDevices.isConnected(device: deviceInfo.id) { + ToolbarItem(placement: .primaryAction) { + ProgressView() + } + } + } + } + + @ViewBuilder private var imageHeader: some View { + VStack(alignment: .center) { + image + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 180, maxHeight: 120) + .accessibilityHidden(true) + } + .frame(maxWidth: .infinity) + } + + + /// Create a new device details view. + /// - Parameter deviceInfo: The device info of the paired device. + public init(_ deviceInfo: PairedDeviceInfo) { + self.deviceInfo = deviceInfo + } +} + + +#if DEBUG +#Preview { + NavigationStack { + DeviceDetailsView(PairedDeviceInfo( + id: UUID(), + deviceType: MockDevice.deviceTypeIdentifier, + name: "Blood Pressure Monitor", + model: "BP5250", + icon: .asset("Omron-BP5250"), + batteryPercentage: 100 + )) + } + .previewWith { + PairedDevices() + } +} + +#Preview { + NavigationStack { + DeviceDetailsView(PairedDeviceInfo( + id: UUID(), + deviceType: MockDevice.deviceTypeIdentifier, + name: "Weight Scale", + model: "SC-150", + icon: .asset("Omron-SC-150"), + lastSeen: .now.addingTimeInterval(-60 * 60 * 24), + batteryPercentage: 85 + )) + } + .previewWith { + PairedDevices() + } +} +#endif diff --git a/Sources/SpeziDevicesUI/Devices/DeviceInfoSection.swift b/Sources/SpeziDevicesUI/Devices/DeviceInfoSection.swift new file mode 100644 index 0000000..5640956 --- /dev/null +++ b/Sources/SpeziDevicesUI/Devices/DeviceInfoSection.swift @@ -0,0 +1,59 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziDevices +import SpeziViews +import SwiftUI + + +struct DeviceInfoSection: View { + private let deviceInfo: PairedDeviceInfo + + @Environment(PairedDevices.self) private var pairedDevices + + var body: some View { + Section { + NavigationLink { + NameEditView(deviceInfo) { name in + pairedDevices.updateName(for: deviceInfo, name: name) + } + } label: { + ListRow("Name") { + Text(deviceInfo.name) + } + } + + if let model = deviceInfo.model, model != deviceInfo.name { + ListRow("Model") { + Text(model) + } + } + } + } + + + init(deviceInfo: PairedDeviceInfo) { + self.deviceInfo = deviceInfo + } +} + + +#if DEBUG +#Preview { + List { + DeviceInfoSection(deviceInfo: PairedDeviceInfo( + id: UUID(), + deviceType: "MockDevice", + name: "Blood Pressure Monitor", + model: "BP5250", + icon: .asset("Omron-BP5250"), + batteryPercentage: 100 + )) + } +} +#endif diff --git a/Sources/SpeziDevicesUI/Devices/DeviceTile.swift b/Sources/SpeziDevicesUI/Devices/DeviceTile.swift new file mode 100644 index 0000000..23e1ffb --- /dev/null +++ b/Sources/SpeziDevicesUI/Devices/DeviceTile.swift @@ -0,0 +1,100 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +@_spi(TestingSupport) import SpeziDevices +import SwiftUI + + +/// A tile showing a paired device. +public struct DeviceTile: View { + private let deviceInfo: PairedDeviceInfo + + @Environment(PairedDevices.self) private var pairedDevices + + private var image: Image { + deviceInfo.icon?.image ?? Image(systemName: "sensor") // swiftlint:disable:this accessibility_label_for_image + } + + public var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .top, spacing: 0) { + image + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundStyle(Color.accentColor) // set accent color if one uses sf symbols + .symbolRenderingMode(.hierarchical) // set symbol rendering mode if one uses sf symbols + .accessibilityHidden(true) + .frame(minWidth: 0, maxWidth: 100, minHeight: 0, maxHeight: 120, alignment: .topLeading) + Spacer() + + if pairedDevices.isConnected(device: deviceInfo.id) { + ProgressView() + } + } + .accessibilityHidden(true) + Spacer() + HStack { + Text(deviceInfo.name) + .foregroundStyle(.primary) + .font(.callout) + .fontWeight(.semibold) + .lineLimit(1) + .truncationMode(.tail) + Spacer() + if let percentage = deviceInfo.lastBatteryPercentage { + BatteryIcon(percentage: Int(percentage)) + .labelStyle(.iconOnly) + } + } + } + .padding(16) + .background { + RoundedRectangle(cornerSize: CGSize(width: 25, height: 25)) + .foregroundStyle(Color(uiColor: .secondarySystemGroupedBackground)) + } + .aspectRatio(1.0, contentMode: .fit) // explicit aspect ratio to ensure tile is always square + .accessibilityElement(children: .combine) + .accessibilityRemoveTraits(.isImage) // otherwise Voice Over will try to read text recognized in the image + } + + /// Create a new device tile view. + /// - Parameter deviceInfo: The paired device information. + public init(_ deviceInfo: PairedDeviceInfo) { + self.deviceInfo = deviceInfo + } +} + + +#if DEBUG +#Preview { + VStack(spacing: 0) { + HStack(spacing: 16) { + Group { + DeviceTile(.mockHealthDevice2) + DeviceTile(.mockHealthDevice1) + } + .background(Color(uiColor: .systemGroupedBackground)) + .frame(maxHeight: 190) + } + HStack(spacing: 16) { + Group { + DeviceTile(.mockHealthDevice2) + DeviceTile(.mockHealthDevice1) + } + .background(Color(uiColor: .systemGroupedBackground)) + .frame(maxHeight: 190) + } + } + .padding([.leading, .trailing], 12) + .frame(maxHeight: .infinity) + .background(Color(uiColor: .systemGroupedBackground)) + .previewWith { + PairedDevices() + } +} +#endif diff --git a/Sources/SpeziDevicesUI/Devices/DevicesGrid.swift b/Sources/SpeziDevicesUI/Devices/DevicesGrid.swift new file mode 100644 index 0000000..69895a1 --- /dev/null +++ b/Sources/SpeziDevicesUI/Devices/DevicesGrid.swift @@ -0,0 +1,107 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +@_spi(TestingSupport) import SpeziDevices +import SwiftUI +import TipKit + + +/// Grid view of paired devices. +public struct DevicesGrid: View { + private let devices: [PairedDeviceInfo]? // swiftlint:disable:this discouraged_optional_collection + @Binding private var presentingDevicePairing: Bool + + @State private var detailedDeviceInfo: PairedDeviceInfo? + + + private var gridItems = [ + GridItem(.adaptive(minimum: 120, maximum: 800), spacing: 12), + GridItem(.adaptive(minimum: 120, maximum: 800), spacing: 12) + ] + + + public var body: some View { + Group { + if let devices { + if devices.isEmpty { + ZStack { + VStack { + TipView(ForgetDeviceTip.instance) + .padding([.leading, .trailing], 20) + Spacer() + } + DevicesUnavailableView(presentingDevicePairing: $presentingDevicePairing) + } + } else { + ScrollView(.vertical) { + VStack(spacing: 16) { + TipView(ForgetDeviceTip.instance) + .tipBackground(Color(uiColor: .secondarySystemGroupedBackground)) + + LazyVGrid(columns: gridItems) { + ForEach(devices) { device in + Button { + detailedDeviceInfo = device + } label: { + DeviceTile(device) + } + .foregroundStyle(.primary) + } + } + } + .padding([.leading, .trailing], 20) + } + .background(Color(uiColor: .systemGroupedBackground)) + } + } else { + ProgressView() + } + } + .navigationDestination(item: $detailedDeviceInfo) { deviceInfo in + DeviceDetailsView(deviceInfo) + } + } + + + /// Create a new devices grid. + /// - Parameters: + /// - devices: The list of paired devices to display. + /// - presentingDevicePairing: Binding to indicate if the device discovery menu should be presented. + /// The view shows an `ContentUnavailableView` if no paired devices exists and uses the binding to provide an action that present device pairing. + public init(devices: [PairedDeviceInfo]?, presentingDevicePairing: Binding) { + // swiftlint:disable:previous discouraged_optional_collection + self.devices = devices + self._presentingDevicePairing = presentingDevicePairing + } +} + + +#if DEBUG +#Preview { + NavigationStack { + DevicesGrid(devices: [], presentingDevicePairing: .constant(false)) + } + .previewWith { + PairedDevices() + } +} + +#Preview { + let devices: [PairedDeviceInfo] = [ + .mockHealthDevice1, + .mockHealthDevice2 + ] + + return NavigationStack { + DevicesGrid(devices: devices, presentingDevicePairing: .constant(false)) + } + .previewWith { + PairedDevices() + } +} +#endif diff --git a/Sources/SpeziDevicesUI/Devices/DevicesUnavailableView.swift b/Sources/SpeziDevicesUI/Devices/DevicesUnavailableView.swift new file mode 100644 index 0000000..a8c726d --- /dev/null +++ b/Sources/SpeziDevicesUI/Devices/DevicesUnavailableView.swift @@ -0,0 +1,39 @@ +// +// This source file is part of the Stanford Spezi open-project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +struct DevicesUnavailableView: View { + @Binding private var presentingDevicePairing: Bool + + var body: some View { + ContentUnavailableView { + Text("No Devices") + .fontWeight(.semibold) + } description: { + Text("Paired devices will appear here once set up.") + } actions: { + Button("Pair New Device") { + presentingDevicePairing = true + } + } + } + + + init(presentingDevicePairing: Binding) { + self._presentingDevicePairing = presentingDevicePairing + } +} + + +#if DEBUG +#Preview { + DevicesUnavailableView(presentingDevicePairing: .constant(false)) +} +#endif diff --git a/Sources/SpeziDevicesUI/Devices/DevicesView.swift b/Sources/SpeziDevicesUI/Devices/DevicesView.swift new file mode 100644 index 0000000..776f53f --- /dev/null +++ b/Sources/SpeziDevicesUI/Devices/DevicesView.swift @@ -0,0 +1,90 @@ +// +// This source file is part of the Stanford Spezi open-project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SpeziBluetooth +import SpeziDevices +import SwiftUI + + +/// Devices view showing grid of paired devices and provides functionality to pair new devices. +/// +/// - Note: Make sure to place this view into an `NavigationStack`. +public struct DevicesView: View { + private let appName: String + private let pairingHint: PairingHint + + @Environment(Bluetooth.self) private var bluetooth + @Environment(PairedDevices.self) private var pairedDevices + + public var body: some View { + @Bindable var pairedDevices = pairedDevices + + DevicesGrid(devices: pairedDevices.pairedDevices, presentingDevicePairing: $pairedDevices.shouldPresentDevicePairing) + .navigationTitle("Devices") + // automatically search if no devices are paired + .scanNearbyDevices(enabled: pairedDevices.isScanningForNearbyDevices, with: bluetooth) + .sheet(isPresented: $pairedDevices.shouldPresentDevicePairing) { + AccessorySetupSheet(pairedDevices.discoveredDevices.values, appName: appName) { + pairingHint + } + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + // indicate that we are scanning in the background + if pairedDevices.isScanningForNearbyDevices && !pairedDevices.shouldPresentDevicePairing { + ProgressView() + } + } + ToolbarItem(placement: .primaryAction) { + Button("Add Device", systemImage: "plus") { + pairedDevices.shouldPresentDevicePairing = true + } + } + } + } + + /// Create a new devices tab + /// - Parameters: + /// - appName: The name of the application to show in the pairing UI. + /// - pairingHint: The pairing hint to display in the Discovery view. + public init(appName: String, @ViewBuilder pairingHint: () -> PairingHint = { EmptyView() }) { + self.appName = appName + self.pairingHint = pairingHint() + } + + /// Create a new devices tab + /// - Parameters: + /// - appName: The name of the application to show in the pairing UI. + /// - pairingHint: The pairing hint to display in the Discovery view. + public init(appName: String, pairingHint: Text) where PairingHint == Text { + self.init(appName: appName) { + pairingHint + } + } + + /// Create a new devices tab + /// - Parameters: + /// - appName: The name of the application to show in the pairing UI. + /// - pairingHint: The pairing hint to display in the Discovery view. + public init(appName: String, pairingHint: LocalizedStringResource) where PairingHint == Text { + self.init(appName: appName, pairingHint: Text(pairingHint)) + } +} + + +#if DEBUG +#Preview { + NavigationStack { + DevicesView(appName: "Example") + .previewWith { + Bluetooth {} + PairedDevices() + } + } +} +#endif diff --git a/Sources/SpeziDevicesUI/Devices/NameEditView.swift b/Sources/SpeziDevicesUI/Devices/NameEditView.swift new file mode 100644 index 0000000..f5e5ccd --- /dev/null +++ b/Sources/SpeziDevicesUI/Devices/NameEditView.swift @@ -0,0 +1,75 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +@_spi(TestingSupport) import SpeziDevices +import SpeziValidation +import SwiftUI + + +struct NameEditView: View { + private let deviceInfo: PairedDeviceInfo + private let save: (String) -> Void + + @Environment(\.dismiss) private var dismiss + + @State private var name: String + + @ValidationState private var validation + + var body: some View { + List { + VerifiableTextField("enter device name", text: $name) + .validate(input: name, rules: [.nonEmpty, .deviceNameMaxLength]) + .receiveValidation(in: $validation) + .autocapitalization(.words) + } + .navigationTitle("Name") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + Button("Done") { + save(name) + dismiss() + } + .disabled(deviceInfo.name == name || !validation.allInputValid) + } + } + + + init(_ deviceInfo: PairedDeviceInfo, save: @escaping (String) -> Void) { + self.deviceInfo = deviceInfo + self.save = save + self._name = State(wrappedValue: deviceInfo.name) + } +} + + +extension ValidationRule { + static var deviceNameMaxLength: ValidationRule { + ValidationRule(rule: { input in + input.count <= 50 + }, message: "The device name cannot be longer than 50 characters.") + } +} + + +#if DEBUG +#Preview { + NavigationStack { + NameEditView(PairedDeviceInfo( + id: UUID(), + deviceType: MockDevice.deviceTypeIdentifier, + name: "Blood Pressure Monitor", + model: "BP5250", + icon: .asset("Omron-BP5250"), + batteryPercentage: 100 + )) { name in + print("New Name is \(name)") + } + } +} +#endif diff --git a/Sources/SpeziDevicesUI/Measurements/BloodPressureMeasurementLabel.swift b/Sources/SpeziDevicesUI/Measurements/BloodPressureMeasurementLabel.swift new file mode 100644 index 0000000..9ab92c1 --- /dev/null +++ b/Sources/SpeziDevicesUI/Measurements/BloodPressureMeasurementLabel.swift @@ -0,0 +1,65 @@ +// +// This source file is part of the Stanford SpeziDevices open source project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import HealthKit +@_spi(TestingSupport) import SpeziDevices +import SwiftUI + + +struct BloodPressureMeasurementLabel: View { + private let bloodPressureSample: HKCorrelation + private let heartRateSample: HKQuantitySample? + + @ScaledMetric private var measurementTextSize: CGFloat = 50 + + private var systolic: HKQuantitySample? { + bloodPressureSample + .objects(for: HKQuantityType(.bloodPressureSystolic)) + .first as? HKQuantitySample + } + + private var diastolic: HKQuantitySample? { + bloodPressureSample + .objects(for: HKQuantityType(.bloodPressureDiastolic)) + .first as? HKQuantitySample + } + + var body: some View { + if let systolic, + let diastolic { + VStack(spacing: 5) { + Text("\(Int(systolic.quantity.doubleValue(for: .millimeterOfMercury())))/\(Int(diastolic.quantity.doubleValue(for: .millimeterOfMercury()))) mmHg") + .font(.system(size: measurementTextSize, weight: .bold, design: .rounded)) + .fixedSize(horizontal: false, vertical: true) + + if let heartRateSample { + Text("\(Int(heartRateSample.quantity.doubleValue(for: .count().unitDivided(by: .minute())))) BPM") + .font(.title) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + } + } + } else { + Text("Invalid Sample") + .italic() + } + } + + + init(_ bloodPressureSample: HKCorrelation, heartRate heartRateSample: HKQuantitySample? = nil) { + self.bloodPressureSample = bloodPressureSample + self.heartRateSample = heartRateSample + } +} + + +#if DEBUG +#Preview { + BloodPressureMeasurementLabel(.mockBloodPressureSample, heartRate: .mockHeartRateSample) +} +#endif diff --git a/Sources/SpeziDevicesUI/Measurements/ConfirmMeasurementButton.swift b/Sources/SpeziDevicesUI/Measurements/ConfirmMeasurementButton.swift new file mode 100644 index 0000000..4380ddf --- /dev/null +++ b/Sources/SpeziDevicesUI/Measurements/ConfirmMeasurementButton.swift @@ -0,0 +1,74 @@ +// +// This source file is part of the Stanford SpeziDevices open source project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SpeziDevices +import SpeziViews +import SwiftUI + + +struct DiscardButton: View { + private let discard: () -> Void + + @Binding var viewState: ViewState + + + var body: some View { + Button(action: discard) { + Text("Discard") + .foregroundStyle(viewState == .idle ? Color.red : Color.gray) + } + .disabled(viewState != .idle) + } + + init(viewState: Binding, discard: @escaping () -> Void) { + self._viewState = viewState + self.discard = discard + } +} + + +struct ConfirmMeasurementButton: View { + private let confirm: () async throws -> Void + private let discard: () -> Void + + @ScaledMetric private var buttonHeight: CGFloat = 38 + @Binding var viewState: ViewState + + var body: some View { + VStack { + AsyncButton(state: $viewState, action: confirm) { + Text("Save") + .frame(maxWidth: .infinity, maxHeight: 35) + .font(.title2) + .bold() + } + .buttonStyle(.borderedProminent) + .padding([.leading, .trailing], 36) + + DiscardButton(viewState: $viewState, discard: discard) + .padding(.top, 8) + } + } + + init(viewState: Binding, confirm: @escaping () async throws -> Void, discard: @escaping () -> Void) { + self._viewState = viewState + self.confirm = confirm + self.discard = discard + } +} + + +#if DEBUG +#Preview { + ConfirmMeasurementButton(viewState: .constant(.idle)) { + print("Save") + } discard: { + print("Discarded") + } +} +#endif diff --git a/Sources/SpeziDevicesUI/Measurements/MeasurementLayer.swift b/Sources/SpeziDevicesUI/Measurements/MeasurementLayer.swift new file mode 100644 index 0000000..3fa5161 --- /dev/null +++ b/Sources/SpeziDevicesUI/Measurements/MeasurementLayer.swift @@ -0,0 +1,49 @@ +// +// This source file is part of the Stanford SpeziDevices open source project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import HealthKit +@_spi(TestingSupport) import SpeziDevices +import SwiftUI + + +struct MeasurementLayer: View { + private let measurement: HealthKitMeasurement + + var body: some View { + VStack(spacing: 15) { + switch measurement { + case let .weight(sample, bmiSample, heightSample): + WeightMeasurementLabel(sample, bmi: bmiSample, height: heightSample) + case let .bloodPressure(bloodPressure, heartRate): + BloodPressureMeasurementLabel(bloodPressure, heartRate: heartRate) + } + } + .accessibilityElement(children: .combine) + .multilineTextAlignment(.center) + } + + + init(measurement: HealthKitMeasurement) { + self.measurement = measurement + } +} + + +#if DEBUG +#Preview { + MeasurementLayer(measurement: .weight(.mockWeighSample)) +} + +#Preview { + MeasurementLayer(measurement: .weight(.mockWeighSample, bmi: .mockBmiSample, height: .mockHeightSample)) +} + +#Preview { + MeasurementLayer(measurement: .bloodPressure(.mockBloodPressureSample, heartRate: .mockHeartRateSample)) +} +#endif diff --git a/Sources/SpeziDevicesUI/Measurements/MeasurementsRecordedSheet.swift b/Sources/SpeziDevicesUI/Measurements/MeasurementsRecordedSheet.swift new file mode 100644 index 0000000..e9ce9c6 --- /dev/null +++ b/Sources/SpeziDevicesUI/Measurements/MeasurementsRecordedSheet.swift @@ -0,0 +1,220 @@ +// +// This source file is part of the Stanford SpeziDevices open source project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +@preconcurrency import HealthKit +import OSLog +@_spi(TestingSupport) import SpeziDevices +import SpeziViews +import SwiftUI + + +/// A sheet view displaying one or many newly recorded measurements. +/// +/// This view retrieves the pending measurements from the ``HealthMeasurements`` Module that is present in the SwiftUI environment. +public struct MeasurementsRecordedSheet: View { + private let logger = Logger(subsystem: "edu.stanford.spezi.SpeziDevices", category: "MeasurementsRecordedSheet") + private let saveSamples: ([HKSample]) async throws -> Void + + @Environment(HealthMeasurements.self) private var measurements + @Environment(\.dismiss) private var dismiss + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + @State private var selectedMeasurement: HealthKitMeasurement? + + @State private var viewState = ViewState.idle + + @MainActor private var forcedUnwrappedMeasurement: Binding { + Binding { + guard let selectedMeasurement = selectedMeasurement ?? measurements.pendingMeasurements.first else { + preconditionFailure("Entered code path where selectedMeasurement was not set.") + } + return selectedMeasurement + } set: { newValue in + selectedMeasurement = newValue + } + } + + public var body: some View { + NavigationStack { + Group { + if measurements.pendingMeasurements.isEmpty { + ContentUnavailableView( + "No Pending Measurements", + systemImage: "heart.text.square", + description: Text("There are currently no pending measurements. Conduct a measurement with a paired device while nearby.") + ) + } else { + PaneContent { + Text("Measurement Recorded") + .font(.title) + .fixedSize(horizontal: false, vertical: true) + } subtitle: { + EmptyView() + } content: { + content + } action: { + action + } + .viewStateAlert(state: $viewState) + .interactiveDismissDisabled(viewState != .idle) + .dynamicTypeSize(.xSmall...DynamicTypeSize.accessibility3) + .onChange(of: selectedMeasurement, initial: true) { + if selectedMeasurement == nil { + selectedMeasurement = measurements.pendingMeasurements.first + } + } + } + } + .toolbar { + DismissButton() + } + } + .presentationDetents([.fraction(0.45), .fraction(0.6), .large]) + .presentationCornerRadius(25) + } + + + @ViewBuilder @MainActor private var content: some View { + if measurements.pendingMeasurements.count > 1 { + TabView(selection: forcedUnwrappedMeasurement) { + ForEach(measurements.pendingMeasurements) { measurement in + VStack { + MeasurementLayer(measurement: measurement) + Spacer() + .frame(minHeight: 30, idealHeight: 45, maxHeight: 60) + .fixedSize() + } + .tag(measurement) + } + } + .tabViewStyle(.page) + .indexViewStyle(.page(backgroundDisplayMode: .always)) + } else if let measurement = measurements.pendingMeasurements.first { + MeasurementLayer(measurement: measurement) + } + } + + @ViewBuilder @MainActor private var action: some View { + ConfirmMeasurementButton(viewState: $viewState) { + guard let selectedMeasurement else { + return + } + + do { + try await saveSamples(selectedMeasurement.samples) + } catch { + logger.error("Failed to save measurement samples: \(error)") + throw error + } + + + logger.info("Saved measurement: \(String(describing: selectedMeasurement))") + dismiss() + + discardSelectedMeasurement(selectedMeasurement) + } discard: { + guard let selectedMeasurement else { + return + } + + if measurements.pendingMeasurements.isEmpty { + dismiss() + } + + discardSelectedMeasurement(selectedMeasurement) + } + } + + + /// Create a new measurement sheet. + public init(save saveSamples: @escaping ([HKSample]) async throws -> Void) { + self.saveSamples = saveSamples + } + + + @MainActor + private func discardSelectedMeasurement(_ measurement: HealthKitMeasurement) { + guard let index = measurements.pendingMeasurements.firstIndex(of: measurement) else { + return + } + + measurements.discardMeasurement(measurement) + if index >= measurements.pendingMeasurements.count { + selectedMeasurement = measurements.pendingMeasurements.last + } else { + selectedMeasurement = measurements.pendingMeasurements[index] + } + } +} + + +#if DEBUG +#Preview { + Text(verbatim: "") + .sheet(isPresented: .constant(true)) { + MeasurementsRecordedSheet { samples in + print("Saving samples \(samples)") + } + } + .previewWith { + HealthMeasurements(mock: [.weight(.mockWeighSample)]) + } +} + +#Preview { + Text(verbatim: "") + .sheet(isPresented: .constant(true)) { + MeasurementsRecordedSheet { samples in + print("Saving samples \(samples)") + } + } + .previewWith { + HealthMeasurements(mock: [.weight(.mockWeighSample, bmi: .mockBmiSample, height: .mockHeightSample)]) + } +} + +#Preview { + Text(verbatim: "") + .sheet(isPresented: .constant(true)) { + MeasurementsRecordedSheet { samples in + print("Saving samples \(samples)") + } + } + .previewWith { + HealthMeasurements(mock: [.bloodPressure(.mockBloodPressureSample, heartRate: .mockHeartRateSample)]) + } +} + +#Preview { + Text(verbatim: "") + .sheet(isPresented: .constant(true)) { + MeasurementsRecordedSheet { samples in + print("Saving samples \(samples)") + } + } + .previewWith { + HealthMeasurements(mock: [ + .weight(.mockWeighSample, bmi: .mockBmiSample, height: .mockHeightSample), + .bloodPressure(.mockBloodPressureSample, heartRate: .mockHeartRateSample), + .weight(.mockWeighSample) + ]) + } +} + +#Preview { + Text(verbatim: "") + .sheet(isPresented: .constant(true)) { + MeasurementsRecordedSheet { samples in + print("Saving samples \(samples)") + } + } + .previewWith { + HealthMeasurements() + } +} +#endif diff --git a/Sources/SpeziDevicesUI/Measurements/WeightMeasurementLabel.swift b/Sources/SpeziDevicesUI/Measurements/WeightMeasurementLabel.swift new file mode 100644 index 0000000..5fa5bb9 --- /dev/null +++ b/Sources/SpeziDevicesUI/Measurements/WeightMeasurementLabel.swift @@ -0,0 +1,77 @@ +// +// This source file is part of the Stanford SpeziDevices open source project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import HealthKit +@_spi(TestingSupport) import SpeziDevices +import SwiftUI + + +struct WeightMeasurementLabel: View { + private let sample: HKQuantitySample + private let bmiSample: HKQuantitySample? + private let heightSample: HKQuantitySample? + + @ScaledMetric private var measurementTextSize: CGFloat = 60 + + private var additionalMeasurements: String? { + var string: String? + if let heightSample { + string = "\(Int(heightSample.quantity.doubleValue(for: .meterUnit(with: .centi)))) cm" + } + + if let bmiSample { + string = (string.map { $0 + ", " } ?? "") + + "\(Int(bmiSample.quantity.doubleValue(for: .count()))) BMI" + } + + return string + } + + var body: some View { + VStack(spacing: 5) { + Text(sample.quantity.description) + .font(.system(size: measurementTextSize, weight: .bold, design: .rounded)) + .fixedSize(horizontal: false, vertical: true) + + if let additionalMeasurements { + Text(additionalMeasurements) + .accessibilityElement(children: .combine) + .font(.title) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + + init(_ sample: HKQuantitySample, bmi bmiSample: HKQuantitySample? = nil, height heightSample: HKQuantitySample? = nil) { + self.sample = sample + self.bmiSample = bmiSample + self.heightSample = heightSample + } +} + + +#if DEBUG +#Preview { + WeightMeasurementLabel(.mockWeighSample) +} + +#Preview { + WeightMeasurementLabel(.mockWeighSample, bmi: .mockBmiSample, height: .mockHeightSample) +} + +#Preview { + WeightMeasurementLabel(.mockWeighSample, bmi: .mockBmiSample) +} + +#Preview { + WeightMeasurementLabel(.mockWeighSample, height: .mockHeightSample) +} +#endif diff --git a/Sources/SpeziDevicesUI/Pairing/AccessoryImageView.swift b/Sources/SpeziDevicesUI/Pairing/AccessoryImageView.swift new file mode 100644 index 0000000..0a67c68 --- /dev/null +++ b/Sources/SpeziDevicesUI/Pairing/AccessoryImageView.swift @@ -0,0 +1,49 @@ +// +// This source file is part of the Stanford Spezi open-project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +@_spi(TestingSupport) import SpeziDevices +import SwiftUI + + +struct AccessoryImageView: View { + private let device: any GenericDevice + + var body: some View { + let image = device.anyIcon?.image ?? Image(systemName: "sensor") // swiftlint:disable:this accessibility_label_for_image + HStack { + image + .resizable() + .aspectRatio(contentMode: .fit) + .accessibilityHidden(true) + .foregroundStyle(Color.accentColor) // set accent color if one uses sf symbols + .symbolRenderingMode(.hierarchical) // set symbol rendering mode if one uses sf symbols + .frame(maxWidth: 250, maxHeight: 120) + } + .frame(maxWidth: .infinity, maxHeight: 150) // make drag-able area a bit larger + .background(Color(uiColor: .systemBackground)) // we need to set a non-clear color for it to be drag-able + } + + + init(_ device: any GenericDevice) { + self.device = device + } +} + + +extension GenericDevice { + fileprivate var anyIcon: ImageReference? { + Self.icon + } +} + + +#if DEBUG +#Preview { + AccessoryImageView(MockDevice.createMockDevice()) +} +#endif diff --git a/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift b/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift new file mode 100644 index 0000000..cc1a25e --- /dev/null +++ b/Sources/SpeziDevicesUI/Pairing/AccessorySetupSheet.swift @@ -0,0 +1,141 @@ +// +// This source file is part of the Stanford Spezi open-project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import OSLog +import SpeziBluetooth +@_spi(TestingSupport) import SpeziDevices +import SpeziViews +import SwiftUI + + +/// Accessory Setup view displayed in a sheet. +public struct AccessorySetupSheet: View where Collection.Element == any PairableDevice { + private static var logger: Logger { + Logger(subsystem: "edu.stanford.sepzi.SpeziDevices", category: "AccessorySetupSheet") + } + + private let devices: Collection + private let appName: String + private let pairingHint: PairingHint + + @Environment(Bluetooth.self) private var bluetooth + @Environment(PairedDevices.self) private var pairedDevices + @Environment(\.dismiss) private var dismiss + + @State private var pairingState: PairingViewState = .discovery + + public var body: some View { + NavigationStack { + VStack { + if case let .error(error) = pairingState { + PairingFailureView(error) + } else if case let .paired(device) = pairingState { + PairedDeviceView(device, appName: appName) + } else if !devices.isEmpty { + PairDeviceView(devices: devices, appName: appName, state: $pairingState) { device in + do { + try await pairedDevices.pair(with: device) + } catch { + Self.logger.error("Failed to pair device \(device.id), \(device.name ?? "unnamed"): \(error)") + throw error + } + } + } else { + DiscoveryView { + pairingHint + } + } + } + .toolbar { + DismissButton() + } + } + .scanNearbyDevices(with: bluetooth) + .presentationDetents([.medium]) + .presentationCornerRadius(25) + .interactiveDismissDisabled() + } + + /// Create a new Accessory Setup sheet. + /// - Parameters: + /// - devices: The collection of nearby devices which are available for pairing. + /// - appName: The name of the application to show in the pairing UI. + /// - pairingHint: The pairing hint to display in the Discovery view. + public init(_ devices: Collection, appName: String, @ViewBuilder pairingHint: () -> PairingHint = { EmptyView() }) { + self.devices = devices + self.appName = appName + self.pairingHint = pairingHint() + } + + /// Create a new Accessory Setup sheet. + /// - Parameters: + /// - devices: The collection of nearby devices which are available for pairing. + /// - appName: The name of the application to show in the pairing UI. + /// - pairingHint: The pairing hint to display in the Discovery view. + public init(_ devices: Collection, appName: String, pairingHint: Text) where PairingHint == Text { + self.init(devices, appName: appName) { + pairingHint + } + } + + /// Create a new Accessory Setup sheet. + /// - Parameters: + /// - devices: The collection of nearby devices which are available for pairing. + /// - appName: The name of the application to show in the pairing UI. + /// - pairingHint: The pairing hint to display in the Discovery view. + public init(_ devices: Collection, appName: String, pairingHint: LocalizedStringResource) where PairingHint == Text { + self.init(devices, appName: appName, pairingHint: Text(pairingHint)) + } +} + + +#if DEBUG +#Preview { + Text(verbatim: "") + .sheet(isPresented: .constant(true)) { + AccessorySetupSheet([MockDevice.createMockDevice()], appName: "Example") { + Text(verbatim: "Make sure to enable pairing mode on the device.") + } + } + .previewWith { + Bluetooth {} + PairedDevices() + } +} + + +#Preview { + Text(verbatim: "") + .sheet(isPresented: .constant(true)) { + let devices: [any PairableDevice] = [ + MockDevice.createMockDevice(name: "Device 1"), + MockDevice.createMockDevice(name: "Device 2") + ] + AccessorySetupSheet(devices, appName: "Example") { + Text(verbatim: "Make sure to enable pairing mode on the device.") + } + } + .previewWith { + Bluetooth {} + PairedDevices() + } +} + +#Preview { + Text(verbatim: "") + .sheet(isPresented: .constant(true)) { + AccessorySetupSheet([], appName: "Example") { + Text(verbatim: "Make sure to enable pairing mode on the device.") + } + } + .previewWith { + Bluetooth {} + PairedDevices() + } +} +#endif diff --git a/Sources/SpeziDevicesUI/Pairing/DiscoveryView.swift b/Sources/SpeziDevicesUI/Pairing/DiscoveryView.swift new file mode 100644 index 0000000..c1b6b28 --- /dev/null +++ b/Sources/SpeziDevicesUI/Pairing/DiscoveryView.swift @@ -0,0 +1,39 @@ +// +// This source file is part of the Stanford Spezi open-project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +struct DiscoveryView: View { + private let pairingHint: Hint + + var body: some View { + PaneContent { + Text("Discovering") + } subtitle: { + pairingHint + } content: { + ProgressView() + .controlSize(.large) + .accessibilityHidden(true) + } + } + + init(@ViewBuilder pairingHint: () -> Hint = { EmptyView() }) { + self.pairingHint = pairingHint() + } +} + + +#if DEBUG +#Preview { + SheetPreview { + DiscoveryView() + } +} +#endif diff --git a/Sources/SpeziDevicesUI/Pairing/Model/PairingViewState.swift b/Sources/SpeziDevicesUI/Pairing/Model/PairingViewState.swift new file mode 100644 index 0000000..15b2da0 --- /dev/null +++ b/Sources/SpeziDevicesUI/Pairing/Model/PairingViewState.swift @@ -0,0 +1,23 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import SpeziDevices + + +/// Pairing view state. +enum PairingViewState { + /// View is currently in discovery. + case discovery + /// Pairing is currently in progress. + case pairing + /// Device is paired and shown to the user for acknowledgment. + case paired(any PairableDevice) + /// Pairing error occurred and is displayed to the user. + case error(LocalizedError) +} diff --git a/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift b/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift new file mode 100644 index 0000000..8567d7c --- /dev/null +++ b/Sources/SpeziDevicesUI/Pairing/PairDeviceView.swift @@ -0,0 +1,127 @@ +// +// This source file is part of the Stanford Spezi open-project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +@_spi(TestingSupport) import SpeziDevices +import SpeziViews +import SwiftUI + + +struct PairDeviceView: View where Collection.Element == any PairableDevice { + private let devices: Collection + private let appName: String + private let pairClosure: (any PairableDevice) async throws -> Void + + @Environment(\.dismiss) private var dismiss + + @Binding private var pairingState: PairingViewState + @AccessibilityFocusState private var isHeaderFocused: Bool + + @State private var selectedDeviceId: UUID? + @State private var selectedDevice: (any PairableDevice)? + + + private var forcedUnwrappedDeviceId: Binding { + Binding { + guard let selectedDeviceId = selectedDeviceId ?? devices.first?.id else { + preconditionFailure("Entered code path where selectedMeasurement was not set.") + } + return selectedDeviceId + } set: { newValue in + selectedDeviceId = newValue + } + } + + private var selectedDeviceName: String { + selectedDevice.map { "\"\($0.label)\"" } ?? "the accessory" + } + + var body: some View { + PaneContent(title: "Pair Accessory", subtitle: "Do you want to pair \(selectedDeviceName) with the \(appName) app?") { + if devices.count > 1 { + TabView(selection: forcedUnwrappedDeviceId) { + ForEach(devices, id: \.id) { device in + VStack { + AccessoryImageView(device) + Spacer() + .frame(minHeight: 30, idealHeight: 45, maxHeight: 60) + .fixedSize() + } + .tag(device.id) + } + } + .onChange(of: selectedDeviceId, initial: true) { + if selectedDeviceId == nil { + self.selectedDeviceId = devices.first?.id + } + selectedDevice = devices.first(where: { $0.id == selectedDeviceId }) + } + .tabViewStyle(.page) + .indexViewStyle(.page(backgroundDisplayMode: .always)) + } else if let device = devices.first { + AccessoryImageView(device) + .onAppear { + selectedDevice = device + } + } + } action: { + AsyncButton { + guard let selectedDevice else { + return + } + + guard case .discovery = pairingState else { + return + } + + pairingState = .pairing + + do { + try await pairClosure(selectedDevice) + pairingState = .paired(selectedDevice) + } catch { + pairingState = .error(AnyLocalizedError(error: error)) + } + } label: { + Text("Pair") + .frame(maxWidth: .infinity, maxHeight: 35) + } + .buttonStyle(.borderedProminent) + .padding([.leading, .trailing], 36) + } + } + + + init(devices: Collection, appName: String, state: Binding, pair: @escaping (any PairableDevice) async throws -> Void) { + self.devices = devices + self.appName = appName + self._pairingState = state + self.pairClosure = pair + } +} + + +#if DEBUG +#Preview { + SheetPreview { + PairDeviceView(devices: [MockDevice.createMockDevice()], appName: "Example", state: .constant(.discovery)) { _ in + } + } +} + +#Preview { + SheetPreview { + let device: [any PairableDevice] = [ + MockDevice.createMockDevice(name: "Device 1"), + MockDevice.createMockDevice(name: "Device 2") + ] + PairDeviceView(devices: device, appName: "Example", state: .constant(.discovery)) { device in + print("Pairing \(device.label)") + } + } +} +#endif diff --git a/Sources/SpeziDevicesUI/Pairing/PairedDeviceView.swift b/Sources/SpeziDevicesUI/Pairing/PairedDeviceView.swift new file mode 100644 index 0000000..3c1bf6d --- /dev/null +++ b/Sources/SpeziDevicesUI/Pairing/PairedDeviceView.swift @@ -0,0 +1,48 @@ +// +// This source file is part of the Stanford Spezi open-project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +@_spi(TestingSupport) import SpeziDevices +import SwiftUI + + +struct PairedDeviceView: View { + private let device: any PairableDevice + private let appName: String + + @Environment(\.dismiss) private var dismiss + + var body: some View { + PaneContent(title: "Accessory Paired", subtitle: "\"\(device.label)\" was successfully paired with the \(appName) app.") { + AccessoryImageView(device) + } action: { + Button { + dismiss() + } label: { + Text("Done") + .frame(maxWidth: .infinity, maxHeight: 35) + } + .buttonStyle(.borderedProminent) + .padding([.leading, .trailing], 36) + } + } + + + init(_ device: any PairableDevice, appName: String) { + self.device = device + self.appName = appName + } +} + + +#if DEBUG +#Preview { + SheetPreview { + PairedDeviceView(MockDevice.createMockDevice(), appName: "Example") + } +} +#endif diff --git a/Sources/SpeziDevicesUI/Pairing/PairingFailureView.swift b/Sources/SpeziDevicesUI/Pairing/PairingFailureView.swift new file mode 100644 index 0000000..e2070e1 --- /dev/null +++ b/Sources/SpeziDevicesUI/Pairing/PairingFailureView.swift @@ -0,0 +1,58 @@ +// +// This source file is part of the Stanford Spezi open-project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SpeziDevices +import SwiftUI + + +struct PairingFailureView: View { + private let error: any LocalizedError + + private var message: String { + error.failureReason ?? error.errorDescription + ?? String(localized: "Failed to pair accessory.") + } + + @Environment(\.dismiss) private var dismiss + + + var body: some View { + PaneContent(title: Text("Pairing Failed"), subtitle: Text(message)) { + Image(systemName: "exclamationmark.triangle.fill") + .symbolRenderingMode(.hierarchical) + .resizable() + .aspectRatio(contentMode: .fit) + .accessibilityHidden(true) + .frame(maxWidth: 250, maxHeight: 120) + .foregroundStyle(.red) + } action: { + Button { + dismiss() + } label: { + Text("OK") + .frame(maxWidth: .infinity, maxHeight: 35) + } + .buttonStyle(.borderedProminent) + .padding([.leading, .trailing], 36) + } + } + + + init(_ error: any LocalizedError) { + self.error = error + } +} + + +#if DEBUG +#Preview { + SheetPreview { + PairingFailureView(DevicePairingError.notInPairingMode) + } +} +#endif diff --git a/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings b/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings new file mode 100644 index 0000000..0acdebd --- /dev/null +++ b/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings @@ -0,0 +1,546 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "\"%@\" was successfully paired with the %@ app." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "\"%1$@\" was successfully paired with the %2$@ app." + } + } + } + }, + "%@, Searching" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@, Searching" + } + } + } + }, + "%lld BPM" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld BPM" + } + } + } + }, + "%lld cm" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld cm" + } + } + } + }, + "%lld/%lld mmHg" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld/%2$lld mmHg" + } + } + } + }, + "Accessory Paired" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Accessory Paired" + } + } + } + }, + "Add Device" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add Device" + } + } + } + }, + "Battery" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Battery" + } + } + } + }, + "Bluetooth Failure" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth Failure" + } + } + } + }, + "Bluetooth is required to make connections to nearby devices. ..." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth is required to make connections to a nearby device. Please allow Bluetooth connections in your Privacy settings." + } + } + } + }, + "Bluetooth is turned off. ..." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth is turned off. Please turn on Bluetooth in Control Center or Settings, in order to connect to a nearby device." + } + } + } + }, + "Bluetooth is unsupported on this device!" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth is unsupported on this device!" + } + } + } + }, + "Bluetooth Off" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth Off" + } + } + } + }, + "Bluetooth Prohibited" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth Prohibited" + } + } + } + }, + "Bluetooth Unsupported" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bluetooth Unsupported" + } + } + } + }, + "Cancel" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" + } + } + } + }, + "Connected" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connected" + } + } + } + }, + "Connecting" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connecting" + } + } + } + }, + "Device Details" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Device Details" + } + } + } + }, + "Devices" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Devices" + } + } + } + }, + "Discard" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Discard" + } + } + } + }, + "Disconnecting" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disconnecting" + } + } + } + }, + "Discovering" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Discovering" + } + } + } + }, + "Do you really want to forget this device?" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Do you really want to forget this device?" + } + } + } + }, + "Do you want to pair %@ with the %@ app?" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Do you want to pair %1$@ with the %2$@ app?" + } + } + } + }, + "Done" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Done" + } + } + } + }, + "enter device name" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "enter device name" + } + } + } + }, + "Failed to pair accessory." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed to pair accessory." + } + } + } + }, + "Forget Device" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forget Device" + } + } + } + }, + "Forget This Device" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forget This Device" + } + } + } + }, + "Fully Unpair Device" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fully Unpair Device" + } + } + } + }, + "Intervention Required" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intervention Required" + } + } + } + }, + "Invalid Sample" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invalid Sample" + } + } + } + }, + "Make sure to to remove the device from the Bluetooth settings to fully unpair the device." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Make sure to to remove the device from the Bluetooth settings to fully unpair the device." + } + } + } + }, + "Measurement Recorded" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Measurement Recorded" + } + } + } + }, + "Model" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Model" + } + } + } + }, + "Name" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Name" + } + } + } + }, + "No Devices" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No Devices" + } + } + } + }, + "No Pending Measurements" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No Pending Measurements" + } + } + } + }, + "OK" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + } + } + }, + "Open Settings" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Settings" + } + } + } + }, + "Pair" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pair" + } + } + } + }, + "Pair Accessory" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pair Accessory" + } + } + } + }, + "Pair New Device" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pair New Device" + } + } + } + }, + "Paired devices will appear here once set up." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paired devices will appear here once set up." + } + } + } + }, + "Pairing Failed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pairing Failed" + } + } + } + }, + "Requires Attention" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Requires Attention" + } + } + } + }, + "Save" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Save" + } + } + } + }, + "Synchronizing ..." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synchronizing ..." + } + } + } + }, + "The device name cannot be longer than 50 characters." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The device name cannot be longer then 50 characters." + } + } + } + }, + "There are currently no pending measurements. Conduct a measurement with a paired device while nearby." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "There are currently no pending measurements. Conduct a measurement with a paired device while nearby." + } + } + } + }, + "This device was last seen at %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This device was last seen at %@" + } + } + } + }, + "This device was last seen on %@ at %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "This device was last seen on %1$@ at %2$@" + } + } + } + }, + "We have trouble with the Bluetooth communication. Please try again." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "We have trouble with the Bluetooth communication. Please try again." + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings.license b/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings.license new file mode 100644 index 0000000..28f53d0 --- /dev/null +++ b/Sources/SpeziDevicesUI/Resources/Localizable.xcstrings.license @@ -0,0 +1,5 @@ +This source file is part of the Stanford Spezi open-source project + +SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) + +SPDX-License-Identifier: MIT \ No newline at end of file diff --git a/Sources/SpeziDevicesUI/Scanning/BluetoothUnavailableView.swift b/Sources/SpeziDevicesUI/Scanning/BluetoothUnavailableView.swift new file mode 100644 index 0000000..2a522ad --- /dev/null +++ b/Sources/SpeziDevicesUI/Scanning/BluetoothUnavailableView.swift @@ -0,0 +1,132 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziBluetooth +import SwiftUI + + +/// Informational view displaying the reason why Bluetooth is currently not available. +public struct BluetoothUnavailableView: View { + private let state: BluetoothState + + private var titleMessage: LocalizedStringResource? { + switch state { + case .poweredOn: + return nil + case .poweredOff: + return .init("Bluetooth Off", bundle: .atURL(from: .module)) + case .unauthorized: + return .init("Bluetooth Prohibited", bundle: .atURL(from: .module)) + case .unsupported: + return .init("Bluetooth Unsupported", bundle: .atURL(from: .module)) + case .unknown: + return .init("Bluetooth Failure", bundle: .atURL(from: .module)) + } + } + + private var subtitleMessage: LocalizedStringResource? { + switch state { + case .poweredOn: + return nil + case .poweredOff: + return .init("Bluetooth is turned off. ...", bundle: .atURL(from: .module)) + case .unauthorized: + return .init("Bluetooth is required to make connections to nearby devices. ...", bundle: .atURL(from: .module)) + case .unknown: + return .init("We have trouble with the Bluetooth communication. Please try again.", bundle: .atURL(from: .module)) + case .unsupported: + return .init("Bluetooth is unsupported on this device!", bundle: .atURL(from: .module)) + } + } + + + public var body: some View { + if titleMessage != nil || subtitleMessage != nil { + ContentUnavailableView { + if let titleMessage { + Label { + Text(titleMessage) + } icon: { + EmptyView() + } + } + } description: { + if let subtitleMessage { + Text(subtitleMessage) + } + } actions: { + switch state { + case .poweredOff, .unauthorized: + #if os(iOS) || os(visionOS) || os(tvOS) + Button(action: { + if let url = URL(string: "App-Prefs:root=General") { + UIApplication.shared.open(url) + } + }) { + Text("Open Settings", bundle: .module) + } + #else + EmptyView() + #endif + default: + EmptyView() + } + } + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets(top: -15, leading: 0, bottom: 0, trailing: 0)) + } else { + EmptyView() + } + } + + + /// Display Bluetooth Unavailable View based on the current Bluetooth State. + /// - Parameter state: The current Bluetooth state. + public init(_ state: BluetoothState) { + self.state = state + } +} + + +#if DEBUG +#Preview { + GeometryReader { proxy in + List { + BluetoothUnavailableView(.poweredOff) + .frame(height: proxy.size.height - 100) + } + } +} + +#Preview { + GeometryReader { proxy in + List { + BluetoothUnavailableView(.unauthorized) + .frame(height: proxy.size.height - 100) + } + } +} + +#Preview { + GeometryReader { proxy in + List { + BluetoothUnavailableView(.unsupported) + .frame(height: proxy.size.height - 100) + } + } +} + +#Preview { + GeometryReader { proxy in + List { + BluetoothUnavailableView(.unknown) + .frame(height: proxy.size.height - 100) + } + } +} +#endif diff --git a/Sources/SpeziDevicesUI/Scanning/LoadingSectionHeader.swift b/Sources/SpeziDevicesUI/Scanning/LoadingSectionHeader.swift new file mode 100644 index 0000000..edde206 --- /dev/null +++ b/Sources/SpeziDevicesUI/Scanning/LoadingSectionHeader.swift @@ -0,0 +1,64 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +/// A section header that displays a title and an optional loading indicator. +/// +/// This view is useful to, e.g., render the Section header of a list of nearby peripherals. The ProgressView can be used to +/// communicate that the application is currently scanning for nearby Bluetooth peripherals. +public struct LoadingSectionHeader: View { + private let text: Text + private let loading: Bool + + public var body: some View { + HStack { + text + if loading { + ProgressView() + .padding(.leading, 4) + .accessibilityRemoveTraits(.updatesFrequently) + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel(Text("\(text), Searching", bundle: .module)) + } + + @_disfavoredOverload + public init(verbatim: String, loading: Bool) { + self.init(Text(verbatim), loading: loading) + } + + public init(_ title: LocalizedStringResource, loading: Bool) { + self.init(Text(title), loading: loading) + } + + + public init(_ text: Text, loading: Bool) { + self.text = text + self.loading = loading + } +} + + +#if DEBUG +#Preview { + List { + Section { + Text(verbatim: "...") + } header: { + LoadingSectionHeader(verbatim: "Devices", loading: true) + } + } +} + +#Preview { + LoadingSectionHeader(verbatim: "Devices", loading: true) +} +#endif diff --git a/Sources/SpeziDevicesUI/Scanning/NearbyDeviceRow.swift b/Sources/SpeziDevicesUI/Scanning/NearbyDeviceRow.swift new file mode 100644 index 0000000..9f4973c --- /dev/null +++ b/Sources/SpeziDevicesUI/Scanning/NearbyDeviceRow.swift @@ -0,0 +1,169 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + + +import SpeziBluetooth +@_spi(TestingSupport) import SpeziDevices +import SpeziViews +import SwiftUI + + +/// A row that displays information of a nearby Bluetooth peripheral in a List view. +public struct NearbyDeviceRow: View { + private let peripheral: any GenericBluetoothPeripheral + private let devicePrimaryActionClosure: () -> Void + private let secondaryActionClosure: (() -> Void)? + + + var showDetailsButton: Bool { + secondaryActionClosure != nil && peripheral.state == .connected + } + + var localizationSecondaryLabel: LocalizedStringResource? { + if peripheral.requiresUserAttention { + return .init("Intervention Required", bundle: .atURL(from: .module)) + } + switch peripheral.state { + case .connecting: + return .init("Connecting", bundle: .atURL(from: .module)) + case .connected: + return .init("Connected", bundle: .atURL(from: .module)) + case .disconnecting: + return .init("Disconnecting", bundle: .atURL(from: .module)) + case .disconnected: + return nil + } + } + + public var body: some View { + let stack = HStack { + Button(action: devicePrimaryAction) { + HStack { + ListRow(verbatim: peripheral.label) { + deviceSecondaryLabel + } + if peripheral.state == .connecting || peripheral.state == .disconnecting { + ProgressView() + .accessibilityRemoveTraits(.updatesFrequently) + } + } + } + + if showDetailsButton { + Button(action: deviceDetailsAction) { + Label { + Text("Device Details", bundle: .module) + } icon: { + Image(systemName: "info.circle") // swiftlint:disable:this accessibility_label_for_image + } + } + .labelStyle(.iconOnly) + .font(.title3) + .buttonStyle(.plain) // ensure button is clickable next to the other button + .foregroundColor(.accentColor) + } + } + + #if TEST || targetEnvironment(simulator) + // accessibility actions cannot be unit tested + stack + #else + stack.accessibilityRepresentation { + accessibilityRepresentation + } + #endif + } + + @ViewBuilder var accessibilityRepresentation: some View { + let button = Button(action: devicePrimaryAction) { + Text(verbatim: peripheral.accessibilityLabel) + if let localizationSecondaryLabel { + Text(localizationSecondaryLabel) + } + } + + if showDetailsButton { + button + .accessibilityAction(named: Text("Device Details", bundle: .module), deviceDetailsAction) + } else { + button + } + } + + @ViewBuilder var deviceSecondaryLabel: some View { + if peripheral.requiresUserAttention { + Text("Requires Attention", bundle: .module) + } else { + switch peripheral.state { + case .connecting, .disconnecting: + EmptyView() + case .connected: + Text("Connected", bundle: .module) + case .disconnected: + EmptyView() + } + } + } + + + /// Create a new nearby device row. + /// - Parameters: + /// - peripheral: The nearby peripheral. + /// - primaryAction: The action that is executed when tapping the peripheral. + /// It is recommended to connect or disconnect devices when tapping on them. + /// - secondaryAction: The action that is executed when the device details button is pressed. + /// The device details button is displayed once the peripheral is connected. + public init( + peripheral: any GenericBluetoothPeripheral, + primaryAction: @escaping () -> Void, + secondaryAction: (() -> Void)? = nil + ) { + self.peripheral = peripheral + self.devicePrimaryActionClosure = primaryAction + self.secondaryActionClosure = secondaryAction + } + + + private func devicePrimaryAction() { + devicePrimaryActionClosure() + } + + private func deviceDetailsAction() { + if let secondaryActionClosure { + secondaryActionClosure() + } + } +} + + +#if DEBUG +#Preview { + List { + NearbyDeviceRow(peripheral: MockBluetoothPeripheral(label: "MyDevice 1", state: .connecting)) { + print("Clicked") + } secondaryAction: { + } + NearbyDeviceRow(peripheral: MockBluetoothPeripheral(label: "MyDevice 2", state: .connected)) { + print("Clicked") + } secondaryAction: { + } + NearbyDeviceRow(peripheral: MockBluetoothPeripheral(label: "Long MyDevice 3", state: .connected, requiresUserAttention: true)) { + print("Clicked") + } secondaryAction: { + } + NearbyDeviceRow(peripheral: MockBluetoothPeripheral(label: "MyDevice 4", state: .disconnecting)) { + print("Clicked") + } secondaryAction: { + } + NearbyDeviceRow(peripheral: MockBluetoothPeripheral(label: "MyDevice 5", state: .disconnected)) { + print("Clicked") + } secondaryAction: { + } + } +} +#endif diff --git a/Sources/SpeziDevicesUI/SpeziDevicesUI.docc/SpeziDevicesUI.md b/Sources/SpeziDevicesUI/SpeziDevicesUI.docc/SpeziDevicesUI.md new file mode 100644 index 0000000..773a6f4 --- /dev/null +++ b/Sources/SpeziDevicesUI/SpeziDevicesUI.docc/SpeziDevicesUI.md @@ -0,0 +1,92 @@ +# ``SpeziDevicesUI`` + +Visualize Bluetooth device interactions. + + + +## Overview + +SpeziDevicesUI helps you to visualize Bluetooth device state and communicate interactions to the user. + +### Displaying paired devices + +When managing paired devices using ``PairedDevices``, SpeziDevicesUI provides reusable View components to display paired devices. + +The ``DevicesView`` provides everything you need to pair and manage paired devices. +It shows already paired devices in a grid layout using the ``DevicesGrid``. Additionally, it places an add button in the toolbar +to discover new devices using the ``AccessorySetupSheet`` view. + +```swift +struct MyHomeView: View { + var body: some View { + TabView { + NavigationStack { + DevicesView(appName: "Example") { + Text("Provide helpful pairing instructions to the user.") + } + } + .tabItem { + Label("Devices", systemImage: "sensor.fill") + } + } + } +} +``` + +### Displaying Measurements + +When managing measurements using ``HealthMeasurements``, you can use the ``MeasurementsRecordedSheet`` to display pending measurements. +Below is a short code example on how you would configure this view. + +```swift +struct MyHomeView: View { + @Environment(HealthMeasurements.self) private var measurements + + var body: some View { + @Bindable var measurements = measurements + ContentView() + .sheet(isPresented: $measurements.shouldPresentMeasurements) { + MeasurementsRecordedSheet { samples in + // save the array of HKSamples + } + } + } +} +``` + +> Important: Don't forget to configure the `HealthMeasurements` module in + your [`SpeziAppDelegate`](https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/speziappdelegate). + +## Topics + +### Presenting nearby devices + +Views that are helpful when building a nearby devices view. + +- ``BluetoothUnavailableView`` +- ``NearbyDeviceRow`` +- ``LoadingSectionHeader`` + +### Pairing Devices + +- ``AccessorySetupSheet`` + +### Paired Devices + +- ``DevicesView`` +- ``DevicesGrid`` +- ``DeviceTile`` +- ``DeviceDetailsView`` +- ``BatteryIcon`` + +### Measurements + +- ``MeasurementsRecordedSheet`` diff --git a/Sources/SpeziDevicesUI/Tips/ForgetDeviceTip.swift b/Sources/SpeziDevicesUI/Tips/ForgetDeviceTip.swift new file mode 100644 index 0000000..13aead1 --- /dev/null +++ b/Sources/SpeziDevicesUI/Tips/ForgetDeviceTip.swift @@ -0,0 +1,49 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI +import TipKit + + +struct ForgetDeviceTip: Tip { + static let instance = ForgetDeviceTip() + + @Parameter static var hasRemovedPairedDevice: Bool = false + + var title: Text { + Text("Fully Unpair Device") + } + + var message: Text? { + Text("Make sure to to remove the device from the Bluetooth settings to fully unpair the device.") + } + + var actions: [Action] { + Action { + guard let url = URL(string: "App-Prefs:root=General") else { + return + } + Task { @MainActor in + UIApplication.shared.open(url) + } + } _: { + Text("Open Settings") + } + } + + var image: Image? { + Image(systemName: "exclamationmark.triangle.fill") + .symbolRenderingMode(.hierarchical) + } + + var rules: [Rule] { + #Rule(Self.$hasRemovedPairedDevice) { + $0 == true + } + } +} diff --git a/Sources/SpeziDevicesUI/Utils/PaneContent.swift b/Sources/SpeziDevicesUI/Utils/PaneContent.swift new file mode 100644 index 0000000..1bf06c6 --- /dev/null +++ b/Sources/SpeziDevicesUI/Utils/PaneContent.swift @@ -0,0 +1,130 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziViews +import SwiftUI + + +#if DEBUG +struct SheetPreview: View { + private let content: Content + + @State private var isPresented = true + + var body: some View { + Text(verbatim: "") + .sheet(isPresented: $isPresented) { + NavigationStack { + content + .toolbar { + DismissButton() + } + } + .presentationDetents([.medium]) + .presentationCornerRadius(25) + } + } + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } +} +#endif + + +struct PaneContent: View { + private let title: Title + private let subtitle: Subtitle? + private let content: Content + private let action: Action + + @AccessibilityFocusState private var isHeaderFocused: Bool + + var body: some View { + VStack { + VStack { + title + .bold() + .font(.largeTitle) + .accessibilityAddTraits(.isHeader) + .accessibilityFocused($isHeaderFocused) + if let subtitle { + subtitle + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .padding([.leading, .trailing], 20) + .multilineTextAlignment(.center) + + Spacer() + content + Spacer() + + action + } + .task { + try? await Task.sleep(for: .milliseconds(300)) + isHeaderFocused = true + } + } + + init( + @ViewBuilder title: () -> Title, + @ViewBuilder subtitle: () -> Subtitle = { EmptyView() }, + @ViewBuilder content: () -> Content, + @ViewBuilder action: () -> Action = { EmptyView() } + ) { + self.title = title() + self.subtitle = subtitle() + self.content = content() + self.action = action() + } + + init(title: Text, subtitle: Text? = nil, @ViewBuilder content: () -> Content, @ViewBuilder action: () -> Action = { EmptyView() }) + where Title == Text, Subtitle == Text { + self.title = title + self.subtitle = subtitle + self.content = content() + self.action = action() + } + + init( + title: LocalizedStringResource, + subtitle: LocalizedStringResource? = nil, + @ViewBuilder content: () -> Content, + @ViewBuilder action: () -> Action = { EmptyView() } + ) where Title == Text, Subtitle == Text { + self.init(title: Text(title), subtitle: subtitle.map { Text($0) }, content: content, action: action) + } +} + + +#if DEBUG +#Preview { + SheetPreview { + PaneContent(title: Text(verbatim: "The Title"), subtitle: Text(verbatim: "The Subtitle")) { + Image(systemName: "person.crop.square.badge.camera.fill") + .symbolRenderingMode(.hierarchical) + .resizable() + .aspectRatio(contentMode: .fit) + .accessibilityHidden(true) + .frame(maxWidth: 250, maxHeight: 120) + .foregroundStyle(.red) + } action: { + Button { + } label: { + Text(verbatim: "Button") + .frame(maxWidth: .infinity, maxHeight: 35) + } + .buttonStyle(.borderedProminent) + .padding([.leading, .trailing], 36) + } + } +} +#endif diff --git a/Sources/SpeziOmron/Characteristic/CharacteristicAccessor+OmronRecordAccess.swift b/Sources/SpeziOmron/Characteristic/CharacteristicAccessor+OmronRecordAccess.swift new file mode 100644 index 0000000..00f2619 --- /dev/null +++ b/Sources/SpeziOmron/Characteristic/CharacteristicAccessor+OmronRecordAccess.swift @@ -0,0 +1,63 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziBluetooth +@_spi(APISupport) import SpeziBluetoothServices + + +extension CharacteristicAccessor where Value == RecordAccessControlPoint { + /// Send report stored records request. + /// + /// Send a request to request to report the stored records via notify of the respective measurement characteristic. + /// Once all records were notified, the method returns. + /// + /// - Parameter content: Select the records the request applies to. + /// - Throws: Throws a [`RecordAccessResponseFormatError`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetoothservices/recordaccessresponseformaterror) + /// if there was an unexpected response or a [`RecordAccessResponseCode`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetoothservices/recordaccessresponsecode) + /// if the request failed. + public func reportStoredRecords(_ content: RecordAccessOperationContent) async throws { + try await sendRequestExpectingGeneralResponse(.reportStoredRecords(content)) + } + + /// Request the number of stored records. + /// + /// - Parameter content: Select the records the request applies to. + /// - Returns: The number of stored records. + /// - Throws: Throws a [`RecordAccessResponseFormatError`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetoothservices/recordaccessresponseformaterror) + /// if there was an unexpected response or a [`RecordAccessResponseCode`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetoothservices/recordaccessresponsecode) + /// if the request failed. + public func reportNumberOfStoredRecords(_ content: RecordAccessOperationContent) async throws -> UInt16 { + try await sendRequestExpectingValueResponse( + .reportNumberOfStoredRecords(content), + expectingResponse: .numberOfStoredRecordsResponse + ) { response in + guard case let .numberOfRecords(value) = response.operand else { + throw RecordAccessResponseFormatError(response: response, reason: .unexpectedOperand) + } + return value + } + } + + /// Request the sequence number of the latest records. + /// + /// - Returns: The sequence number of the latest record. + /// - Throws: Throws a [`RecordAccessResponseFormatError`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetoothservices/recordaccessresponseformaterror) + /// if there was an unexpected response or a [`RecordAccessResponseCode`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetoothservices/recordaccessresponsecode) + /// if the request failed. + public func reportSequenceNumberOfLatestRecords() async throws -> UInt16 { + try await sendRequestExpectingValueResponse( + .reportSequenceNumberOfLatestRecords(), + expectingResponse: .omronSequenceNumberOfLatestRecordsResponse + ) { response in + guard case let .sequenceNumber(value) = response.operand else { + throw RecordAccessResponseFormatError(response: response, reason: .unexpectedOperand) + } + return value + } + } +} diff --git a/Sources/SpeziOmron/Characteristic/OmronRecordAccessOperand.swift b/Sources/SpeziOmron/Characteristic/OmronRecordAccessOperand.swift new file mode 100644 index 0000000..d2c3e08 --- /dev/null +++ b/Sources/SpeziOmron/Characteristic/OmronRecordAccessOperand.swift @@ -0,0 +1,107 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import NIOCore +import SpeziBluetoothServices + + +/// The Record Access Operand format for the Omron Record Access Control Point characteristic. +public enum OmronRecordAccessOperand { + // REQUEST + + /// Specify filter criteria for supported requests. + case sequenceNumberFilter(UInt16) + + // RESPONSE + + /// The general response operand used with the ``RecordAccessOpCode/responseCode`` operation. + case generalResponse(RecordAccessGeneralResponse) + /// Reports the number of records in the ``RecordAccessOpCode/numberOfStoredRecordsResponse`` operation. + case numberOfRecords(UInt16) + /// Reports the sequence number of the latest records in the ``BluetoothServices/RecordAccessOpCode/omronSequenceNumberOfLatestRecordsResponse`` operation. + case sequenceNumber(UInt16) +} + + +extension OmronRecordAccessOperand: Hashable, Sendable {} + + +extension RecordAccessFilterType { + static let omronSequenceNumber = RecordAccessFilterType(rawValue: 0x04) +} + + +extension OmronRecordAccessOperand: RecordAccessOperand { + public var generalResponse: RecordAccessGeneralResponse? { + guard case let .generalResponse(response) = self else { + return nil + } + return response + } + + public init?( // swiftlint:disable:this cyclomatic_complexity + from byteBuffer: inout ByteBuffer, + opCode: RecordAccessOpCode, + operator: RecordAccessOperator + ) { + switch opCode { + case .responseCode: + guard let response = RecordAccessGeneralResponse(from: &byteBuffer) else { + return nil + } + self = .generalResponse(response) + case .reportStoredRecords, .deleteStoredRecords, .reportNumberOfStoredRecords: + switch `operator` { + case .lessThanOrEqualTo, .greaterThanOrEqual: + guard let filterType = RecordAccessFilterType(from: &byteBuffer), + case .omronSequenceNumber = filterType, + let sequenceNumber = UInt16(from: &byteBuffer) else { + return nil + } + self = .sequenceNumberFilter(sequenceNumber) + default: + return nil + } + case .numberOfStoredRecordsResponse: + guard let count = UInt16(from: &byteBuffer) else { + return nil + } + self = .numberOfRecords(count) + case .omronSequenceNumberOfLatestRecordsResponse: + guard let sequenceNumber = UInt16(from: &byteBuffer) else { + return nil + } + self = .sequenceNumber(sequenceNumber) + default: + return nil + } + } + + public func encode(to byteBuffer: inout ByteBuffer) { + switch self { + case let .generalResponse(response): + response.encode(to: &byteBuffer) + case let .sequenceNumberFilter(value): + RecordAccessFilterType.omronSequenceNumber.encode(to: &byteBuffer) + value.encode(to: &byteBuffer) + case let .numberOfRecords(value), let .sequenceNumber(value): + value.encode(to: &byteBuffer) + } + } +} + + +extension RecordAccessOperationContent where Operand == OmronRecordAccessOperand { + /// Records that are greater than or equal to the specified sequence number. + /// + /// - Parameter sequenceNumber: The sequence number to use as a filter criteria. + /// - Returns: The operation content. + public static func greaterThanOrEqualTo(sequenceNumber: UInt16) -> RecordAccessOperationContent { + RecordAccessOperationContent(operator: .greaterThanOrEqual, operand: .sequenceNumberFilter(sequenceNumber)) + } +} diff --git a/Sources/SpeziOmron/Characteristic/RecordAccessControlPoint+Omron.swift b/Sources/SpeziOmron/Characteristic/RecordAccessControlPoint+Omron.swift new file mode 100644 index 0000000..731d942 --- /dev/null +++ b/Sources/SpeziOmron/Characteristic/RecordAccessControlPoint+Omron.swift @@ -0,0 +1,25 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziBluetoothServices + + +extension RecordAccessControlPoint { + /// Report the sequence number of the latest records. + /// + /// Reports the the sequence number of the latest records on the peripheral. + /// The operator is ``RecordAccessOperator/null`` and no operand is used. + /// + /// The number of stored records is returned using ``BluetoothServices/RecordAccessOpCode/omronSequenceNumberOfLatestRecordsResponse``. + /// Erroneous conditions are returned using the ``responseCode`` code. + /// + /// - Returns: The Record Access Control Point value. + public static func reportSequenceNumberOfLatestRecords() -> RecordAccessControlPoint { + RecordAccessControlPoint(opCode: .omronReportSequenceNumberOfLatestRecords, operator: .null) + } +} diff --git a/Sources/SpeziOmron/Characteristic/RecordAccessOpCode+Omron.swift b/Sources/SpeziOmron/Characteristic/RecordAccessOpCode+Omron.swift new file mode 100644 index 0000000..830f4b1 --- /dev/null +++ b/Sources/SpeziOmron/Characteristic/RecordAccessOpCode+Omron.swift @@ -0,0 +1,27 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziBluetoothServices + + +extension RecordAccessOpCode { + /// Report the sequence number of the latest records. + /// + /// Reports the the sequence number of the latest records on the peripheral. + /// The operator is ``RecordAccessOperator/null`` and no operand is used. + /// + /// The number of stored records is returned using ``omronSequenceNumberOfLatestRecordsResponse``. + /// Erroneous conditions are returned using the ``responseCode`` code. + public static let omronReportSequenceNumberOfLatestRecords = RecordAccessOpCode(rawValue: 0x10) + /// Response returning the sequence number of the latest records. + /// + /// This is the response code to ``omronReportSequenceNumberOfLatestRecords``. + /// The operator is ``RecordAccessOperator/null``. + /// The operand contains the number of stored records as a `UInt16`. + public static let omronSequenceNumberOfLatestRecordsResponse = RecordAccessOpCode(rawValue: 0x11) // swiftlint:disable:this identifier_name +} diff --git a/Sources/SpeziOmron/Devices/OmronBloodPressureCuff.swift b/Sources/SpeziOmron/Devices/OmronBloodPressureCuff.swift new file mode 100644 index 0000000..f4240e7 --- /dev/null +++ b/Sources/SpeziOmron/Devices/OmronBloodPressureCuff.swift @@ -0,0 +1,180 @@ +// +// This source file is part of the Stanford SpeziDevices open source project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import CoreBluetooth +import Foundation +import OSLog +@_spi(TestingSupport) import SpeziBluetooth +import SpeziBluetoothServices +import SpeziDevices +import SpeziNumerics + + +/// Implementation of Omron BP5250 Blood Pressure Cuff. +public final class OmronBloodPressureCuff: BluetoothDevice, Identifiable, OmronHealthDevice, BatteryPoweredDevice, @unchecked Sendable { + public static var icon: ImageReference? { + .asset("Omron-BP5250", bundle: .module) + } + + private let logger = Logger(subsystem: "ENGAGEHF", category: "BloodPressureCuffDevice") + + @DeviceState(\.id) public var id: UUID + @DeviceState(\.name) public var name: String? + @DeviceState(\.state) public var state: PeripheralState + @DeviceState(\.advertisementData) public var advertisementData: AdvertisementData + @DeviceState(\.nearby) public var nearby + + @Service public var deviceInformation = DeviceInformationService() + + @Service public var time = CurrentTimeService() + @Service public var battery = BatteryService() + @Service public var bloodPressure = BloodPressureService() + + @DeviceAction(\.connect) public var connect + @DeviceAction(\.disconnect) public var disconnect + + @Dependency private var measurements: HealthMeasurements? + @Dependency private var pairedDevices: PairedDevices? + + /// Initialize the device. + public required init() {} + + public func configure() { + $state.onChange { [weak self] value in + await self?.handleStateChange(value) + } + + battery.$batteryLevel.onChange { [weak self] value in + await self?.handleBatteryChange(value) + } + time.$currentTime.onChange { [weak self] value in + await self?.handleCurrentTimeChange(value) + } + + if let pairedDevices { + pairedDevices.configure(device: self, accessing: $state, $advertisementData, $nearby) + } + if let measurements { + measurements.configureReceivingMeasurements(for: self, on: bloodPressure) + } + } + + private func handleStateChange(_ state: PeripheralState) async { + if case .connected = state, + case .transferMode = manufacturerData?.pairingMode { + time.synchronizeDeviceTime() + } + } + + @MainActor + private func handleBatteryChange(_ level: UInt8) { + pairedDevices?.signalDevicePaired(self) + } + + @MainActor + private func handleCurrentTimeChange(_ time: CurrentTime) { + logger.debug("Received updated device time for \(self.label) is \(String(describing: time))") + let paired = pairedDevices?.signalDevicePaired(self) + + if paired == true { + self.time.synchronizeDeviceTime() + } + } +} + + +@_spi(TestingSupport) +extension OmronBloodPressureCuff { + /// Create a mock instance. + /// - Parameters: + /// - systolic: The mock systolic value. + /// - diastolic: The mock diastolic value. + /// - pulseRate: The mock pulse rate value. + /// - state: The initial state. + /// - nearby: The nearby state. + /// - manufacturerData: The initial manufacturer data. + /// - Returns: Returns the mock device instance. + public static func createMockDevice( // swiftlint:disable:this function_body_length + systolic: MedFloat16 = 103, + diastolic: MedFloat16 = 64, + pulseRate: MedFloat16 = 62, + state: PeripheralState = .disconnected, + nearby: Bool = true, + manufacturerData: OmronManufacturerData = OmronManufacturerData(pairingMode: .pairingMode, users: [ + .init(id: 1, sequenceNumber: 2, recordsNumber: 1) + ]) + ) -> OmronBloodPressureCuff { + let device = OmronBloodPressureCuff() + + device.$id.inject(UUID()) + device.$name.inject("Mock Blood Pressure Cuff") + device.$state.inject(state) + device.$nearby.inject(nearby) + + device.deviceInformation.$manufacturerName.inject("Mock Blood Pressure Cuff") + device.deviceInformation.$modelNumber.inject(OmronModel.bp5250.rawValue) + device.deviceInformation.$hardwareRevision.inject("2") + device.deviceInformation.$firmwareRevision.inject("1.0") + + device.battery.$batteryLevel.inject(85) + + let features: BloodPressureFeature = [ + .bodyMovementDetectionSupported, + .irregularPulseDetectionSupported + ] + + let measurement = BloodPressureMeasurement( + systolic: systolic, + diastolic: diastolic, + meanArterialPressure: 77, + unit: .mmHg, + timeStamp: DateTime(year: 2024, month: .june, day: 5, hours: 12, minutes: 33, seconds: 11), + pulseRate: pulseRate, + userId: 1, + measurementStatus: [] + ) + + device.bloodPressure.$features.inject(features) + device.bloodPressure.$bloodPressureMeasurement.inject(measurement) + + let advertisementData = AdvertisementData([ + CBAdvertisementDataManufacturerDataKey: manufacturerData.encode() + ]) + device.$advertisementData.inject(advertisementData) + + device.$connect.inject { @MainActor [weak device] in + guard let device else { + return + } + + device.$state.inject(.connecting) + + try? await Task.sleep(for: .seconds(1)) + + if case .connecting = device.state { + device.$state.inject(.connected) + } + } + + device.$disconnect.inject { @MainActor [weak device] in + device?.$state.inject(.disconnected) + } + + device.$state.enableSubscriptions() + device.$advertisementData.enableSubscriptions() + device.$nearby.enableSubscriptions() + + device.battery.$batteryLevel.enableSubscriptions() + device.battery.$batteryLevel.enablePeripheralSimulation() + + device.bloodPressure.$bloodPressureMeasurement.enableSubscriptions() + device.bloodPressure.$bloodPressureMeasurement.enablePeripheralSimulation() + + return device + } +} diff --git a/Sources/SpeziOmron/Devices/OmronWeightScale.swift b/Sources/SpeziOmron/Devices/OmronWeightScale.swift new file mode 100644 index 0000000..3a35342 --- /dev/null +++ b/Sources/SpeziOmron/Devices/OmronWeightScale.swift @@ -0,0 +1,169 @@ +// +// This source file is part of the ENGAGE-HF project based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import CoreBluetooth +import Foundation +import OSLog +@_spi(TestingSupport) import SpeziBluetooth +import SpeziBluetoothServices +import SpeziDevices + + +/// Implementation of Omron SC150 Weight Scale. +public final class OmronWeightScale: BluetoothDevice, Identifiable, OmronHealthDevice, @unchecked Sendable { + public static var icon: ImageReference? { + .asset("Omron-SC-150", bundle: .module) + } + + private let logger = Logger(subsystem: "ENGAGEHF", category: "WeightScale") + + @DeviceState(\.id) public var id: UUID + @DeviceState(\.name) public var name: String? + @DeviceState(\.state) public var state: PeripheralState + @DeviceState(\.advertisementData) public var advertisementData: AdvertisementData + @DeviceState(\.nearby) public var nearby + + @Service public var deviceInformation = DeviceInformationService() + + @Service public var time = CurrentTimeService() + @Service public var weightScale = WeightScaleService() + + @DeviceAction(\.connect) public var connect + @DeviceAction(\.disconnect) public var disconnect + + @Dependency private var measurements: HealthMeasurements? + @Dependency private var pairedDevices: PairedDevices? + + private var dateOfConnection: Date? + + /// Initialize the device. + public required init() {} + + public func configure() { + $state.onChange { [weak self] value in + await self?.handleStateChange(value) + } + + time.$currentTime.onChange { [weak self] value in + await self?.handleCurrentTimeChange(value) + } + + if let pairedDevices { + pairedDevices.configure(device: self, accessing: $state, $advertisementData, $nearby) + } + if let measurements { + measurements.configureReceivingMeasurements(for: self, on: weightScale) + } + } + + private func handleStateChange(_ state: PeripheralState) async { + switch state { + case .connected: + switch manufacturerData?.pairingMode { + case .pairingMode: + dateOfConnection = .now + case .transferMode: + time.synchronizeDeviceTime() + case nil: + break + } + default: + break + } + } + + @MainActor + private func handleCurrentTimeChange(_ time: CurrentTime) { + logger.debug("Received updated device time for \(self.label): \(String(describing: time))") + let paired = pairedDevices?.signalDevicePaired(self) == true + if paired { + dateOfConnection = nil + self.time.synchronizeDeviceTime() + } + } +} + + +extension OmronWeightScale { + /// Create a mock instance. + /// - Parameters: + /// - weight: The weight value. + /// - resolution: The weight resolution. + /// - state: The initial state. + /// - nearby: The nearby state. + /// - manufacturerData: The initial manufacturer data. + /// - Returns: Returns the mock device instance. + public static func createMockDevice( + weight: UInt16 = 8400, + resolution: WeightScaleFeature.WeightResolution = .resolution5g, + state: PeripheralState = .disconnected, + nearby: Bool = true, + manufacturerData: OmronManufacturerData = OmronManufacturerData(pairingMode: .pairingMode, users: [ + .init(id: 1, sequenceNumber: 2, recordsNumber: 1) + ]) + ) -> OmronWeightScale { + let device = OmronWeightScale() + + device.$id.inject(UUID()) + device.$name.inject("Mock Health Scale") + device.$state.inject(state) + device.$nearby.inject(nearby) + + device.deviceInformation.$manufacturerName.inject("Mock Weight Scale") + device.deviceInformation.$modelNumber.inject(OmronModel.sc150.rawValue) + device.deviceInformation.$hardwareRevision.inject("2") + device.deviceInformation.$firmwareRevision.inject("1.0") + + // mocks the values as reported by the real device + let features = WeightScaleFeature( + weightResolution: resolution, + heightResolution: .unspecified, + options: .timeStampSupported + ) + + let measurement = WeightMeasurement( + weight: weight, + unit: .si + ) + + device.weightScale.$features.inject(features) + device.weightScale.$weightMeasurement.inject(measurement) + + let advertisementData = AdvertisementData([ + CBAdvertisementDataManufacturerDataKey: manufacturerData.encode() + ]) + device.$advertisementData.inject(advertisementData) + + device.$connect.inject { @MainActor [weak device] in + guard let device else { + return + } + + device.$state.inject(.connecting) + + try? await Task.sleep(for: .seconds(1)) + + if case .connecting = device.state { + device.$state.inject(.connected) + } + } + + device.$disconnect.inject { @MainActor [weak device] in + device?.$state.inject(.disconnected) + } + + device.$state.enableSubscriptions() + device.$advertisementData.enableSubscriptions() + device.$nearby.enableSubscriptions() + + device.weightScale.$weightMeasurement.enableSubscriptions() + device.weightScale.$weightMeasurement.enablePeripheralSimulation() + + return device + } +} diff --git a/Sources/SpeziOmron/ManufacturerIdentifier+Omron.swift b/Sources/SpeziOmron/ManufacturerIdentifier+Omron.swift new file mode 100644 index 0000000..be6f4fc --- /dev/null +++ b/Sources/SpeziOmron/ManufacturerIdentifier+Omron.swift @@ -0,0 +1,17 @@ +// +// This source file is part of the Stanford Spezi open-project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SpeziBluetooth + + +extension ManufacturerIdentifier { + /// Bluetooth manufacturer code for "Omron Healthcare Co., Ltd.". + public static var omronHealthcareCoLtd: ManufacturerIdentifier { + ManufacturerIdentifier(rawValue: 0x020E) + } +} diff --git a/Sources/SpeziOmron/OmronHealthDevice.swift b/Sources/SpeziOmron/OmronHealthDevice.swift new file mode 100644 index 0000000..7bd6e23 --- /dev/null +++ b/Sources/SpeziOmron/OmronHealthDevice.swift @@ -0,0 +1,46 @@ +// +// This source file is part of the Stanford Spezi open-project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SpeziBluetooth +import SpeziDevices + + +/// An Omron Health Device. +/// +/// An Omron Health Device is a `HealthDevice` that is pairable. +/// Further, it might adopt the `BatteryPoweredDevice` protocol if the Omron device supports the battery service. +public protocol OmronHealthDevice: HealthDevice, PairableDevice {} + + +extension OmronHealthDevice { + /// The Omron model string. + public var model: OmronModel { + OmronModel(rawValue: deviceInformation.modelNumber ?? "Generic Health Device") + } + + /// The Omron Manufacturer data observed in the Bluetooth advertisement. + public var manufacturerData: OmronManufacturerData? { + guard let manufacturerData = advertisementData.manufacturerData else { + return nil + } + return OmronManufacturerData(data: manufacturerData) + } +} + + +extension OmronHealthDevice { + /// Default implementation determining if device is in pairing mode. + /// + /// Pairing mode is advertised by the device through the ``manufacturerData`` in the Bluetooth advertisement. + public var isInPairingMode: Bool { + if case .pairingMode = manufacturerData?.pairingMode { + return true + } + return false + } +} diff --git a/Sources/SpeziOmron/OmronManufacturerData.swift b/Sources/SpeziOmron/OmronManufacturerData.swift new file mode 100644 index 0000000..502bc1c --- /dev/null +++ b/Sources/SpeziOmron/OmronManufacturerData.swift @@ -0,0 +1,222 @@ +// +// This source file is part of the Stanford Spezi open-project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import ByteCoding +import NIOCore +import SpeziBluetooth + + +/// Omron Manufacturer Data format. +public struct OmronManufacturerData { + /// The device's pairing mode. + public enum PairingMode { + /// The device is advertising to transfer data. + case transferMode + /// The device is advertising to get paired. + case pairingMode + } + + /// The streaming mode. + public enum StreamingMode { + /// Data Communication. + case dataCommunication + /// Streaming. + case streaming + } + + /// The services mode. + public enum ServicesMode { + /// Uses Bluetooth standard services and characteristics. + case bluetoothStandard + /// Uses services and characteristics of the Omron Extension. + case omronExtension + } + + /// Metadata of a user slot. + public struct UserSlot { + /// The user slot number. + public let id: UInt8 + /// The current record sequence number. + public let sequenceNumber: UInt16 + /// The amount of records currently stored on the device. + public let recordsNumber: UInt8 + + + /// Create a new user slot. + /// - Parameters: + /// - id: The user slot number. + /// - sequenceNumber: The current record sequence number. + /// - recordsNumber: The amount of records currently stored on the device. + public init(id: UInt8, sequenceNumber: UInt16, recordsNumber: UInt8) { + self.id = id + self.sequenceNumber = sequenceNumber + self.recordsNumber = recordsNumber + } + } + + fileprivate struct Flags: OptionSet { + static let timeNotSet = Flags(rawValue: 1 << 2) + static let pairingMode = Flags(rawValue: 1 << 3) + static let streamingMode = Flags(rawValue: 1 << 4) + static let wlpStp = Flags(rawValue: 1 << 5) + + let rawValue: UInt8 + + var numberOfUsers: UInt8 { + rawValue & 0x3 + 1 + } + + init(rawValue: UInt8) { + self.rawValue = rawValue + } + + init(numberOfUsers: UInt8) { + precondition(numberOfUsers > 0 && numberOfUsers <= 4, "Only 4 users are supported and at least one.") + self.rawValue = numberOfUsers - 1 + } + } + + /// Indicate if the time was set on the device. + public let timeSet: Bool + /// Determine the pairing mode the device is currently in. + public let pairingMode: PairingMode + /// The type of data transmission mode. + public let streamingMode: StreamingMode + /// The type of services the peripheral is exposing. + public let servicesMode: ServicesMode + + /// The advertised user slots. + /// + /// - Important: Exposes at least one, and a maximum of four slots. + public let users: [UserSlot] + + + /// Create new Omron Manufacture Data + /// - Parameters: + /// - timeSet: Indicate if the time was set. + /// - pairingMode: The pairing mode. + /// - streamingMode: The streaming mode. + /// - servicesMode: The services mode. + /// - users: The list of users. At least one, maximum four. + public init( // swiftlint:disable:this function_default_parameter_at_end + timeSet: Bool = true, + pairingMode: PairingMode, + streamingMode: StreamingMode = .dataCommunication, + servicesMode: ServicesMode = .bluetoothStandard, + users: [UserSlot] + ) { + // swiftlint:disable:next empty_count + precondition(users.count > 0 && users.count <= 4, "Only 4 users are supported and at least one.") + self.timeSet = timeSet + self.pairingMode = pairingMode + self.streamingMode = streamingMode + self.servicesMode = servicesMode + self.users = users + } +} + + +extension OmronManufacturerData.UserSlot: Identifiable {} + + +extension OmronManufacturerData.UserSlot: Hashable, Sendable {} + + +extension OmronManufacturerData: Hashable, Sendable {} + + +extension OmronManufacturerData.PairingMode: Hashable, Sendable {} + + +extension OmronManufacturerData.StreamingMode: Hashable, Sendable {} + + +extension OmronManufacturerData.ServicesMode: Hashable, Sendable {} + + +extension OmronManufacturerData.Flags: ByteCodable { + public init?(from byteBuffer: inout ByteBuffer) { + guard let rawValue = UInt8(from: &byteBuffer) else { + return nil + } + self.init(rawValue: rawValue) + } + + public func encode(to byteBuffer: inout ByteBuffer) { + rawValue.encode(to: &byteBuffer) + } +} + + +extension OmronManufacturerData: ByteCodable { + public init?(from byteBuffer: inout ByteBuffer) { + guard let companyIdentifier = ManufacturerIdentifier(from: &byteBuffer) else { + return nil + } + + guard companyIdentifier == .omronHealthcareCoLtd else { + return nil + } + + guard let dataType = UInt8(from: &byteBuffer), + dataType == 0x01 else { // 0x01 signifies start of "Each User Data" + return nil + } + + guard let flags = Flags(from: &byteBuffer) else { + return nil + } + + self.timeSet = !flags.contains(.timeNotSet) + self.pairingMode = flags.contains(.pairingMode) ? .pairingMode : .transferMode + self.streamingMode = flags.contains(.streamingMode) ? .streaming : .dataCommunication + self.servicesMode = flags.contains(.wlpStp) ? .bluetoothStandard : .omronExtension + + var userSlots: [UserSlot] = [] + for userNumber in 1...flags.numberOfUsers { + guard let sequenceNumber = UInt16(from: &byteBuffer), + let numberOfData = UInt8(from: &byteBuffer) else { + return nil + } + + let userData = UserSlot(id: userNumber, sequenceNumber: sequenceNumber, recordsNumber: numberOfData) + userSlots.append(userData) + } + self.users = userSlots + } + + public func encode(to byteBuffer: inout ByteBuffer) { + ManufacturerIdentifier.omronHealthcareCoLtd.encode(to: &byteBuffer) + UInt8(0x01).encode(to: &byteBuffer) + + var flags = Flags(numberOfUsers: UInt8(users.count)) + + if !timeSet { + flags.insert(.timeNotSet) + } + + if case .pairingMode = pairingMode { + flags.insert(.pairingMode) + } + + if case .streaming = streamingMode { + flags.insert(.streamingMode) + } + + if case .bluetoothStandard = servicesMode { + flags.insert(.wlpStp) + } + + flags.encode(to: &byteBuffer) + + for user in users { + user.sequenceNumber.encode(to: &byteBuffer) + user.recordsNumber.encode(to: &byteBuffer) + } + } +} diff --git a/Sources/SpeziOmron/OmronModel.swift b/Sources/SpeziOmron/OmronModel.swift new file mode 100644 index 0000000..27265da --- /dev/null +++ b/Sources/SpeziOmron/OmronModel.swift @@ -0,0 +1,37 @@ +// +// This source file is part of the Stanford Spezi open-project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + + +/// Omron Model. +public struct OmronModel { + /// The raw model number. + public let rawValue: String + + /// Initialize from raw value. + /// - Parameter rawValue: The raw model number string. + public init(rawValue: String) { + self.rawValue = rawValue + } +} + + +extension OmronModel { + /// The Omron SC150 weight scale. + public static let sc150 = OmronModel(rawValue: "SC-150") + /// The Omron BP5250 blood pressure monitor. + public static let bp5250 = OmronModel(rawValue: "BP5250") +} + + +extension OmronModel: RawRepresentable {} + + +extension OmronModel: Hashable, Sendable {} + + +extension OmronModel: Codable {} diff --git a/Sources/SpeziOmron/OmronOptionService.swift b/Sources/SpeziOmron/OmronOptionService.swift new file mode 100644 index 0000000..415a1db --- /dev/null +++ b/Sources/SpeziOmron/OmronOptionService.swift @@ -0,0 +1,61 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import CoreBluetooth.CBUUID +import SpeziBluetooth +import SpeziBluetoothServices + + +/// The Omron Option Service. +/// +/// Please refer to the respective Developer Guide for more information. +public final class OmronOptionService: BluetoothService, @unchecked Sendable { + public static var id: CBUUID { + CBUUID(string: "5DF5E817-A945-4F81-89C0-3D4E9759C07C") + } + + + @Characteristic(id: "2A52", notify: true) var recordAccessControlPoint: RecordAccessControlPoint? + + public init() {} + + + /// Send report stored records request. + /// + /// Send a request to request to report the stored records via notify of the respective measurement characteristic. + /// Once all records were notified, the method returns. + /// + /// - Parameter content: Select the records the request applies to. + /// - Throws: Throws a [`RecordAccessResponseFormatError`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetoothservices/recordaccessresponseformaterror) + /// if there was an unexpected response or a [`RecordAccessResponseCode`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetoothservices/recordaccessresponsecode) + /// if the request failed. + public func reportStoredRecords(_ content: RecordAccessOperationContent) async throws { + try await $recordAccessControlPoint.reportStoredRecords(content) + } + + /// Request the number of stored records. + /// + /// - Parameter content: Select the records the request applies to. + /// - Returns: The number of stored records. + /// - Throws: Throws a [`RecordAccessResponseFormatError`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetoothservices/recordaccessresponseformaterror) + /// if there was an unexpected response or a [`RecordAccessResponseCode`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetoothservices/recordaccessresponsecode) + /// if the request failed. + public func reportNumberOfStoredRecords(_ content: RecordAccessOperationContent) async throws -> UInt16 { + try await $recordAccessControlPoint.reportNumberOfStoredRecords(content) + } + + /// Request the sequence number of the latest records. + /// + /// - Returns: The sequence number of the latest record. + /// - Throws: Throws a [`RecordAccessResponseFormatError`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetoothservices/recordaccessresponseformaterror) + /// if there was an unexpected response or a [`RecordAccessResponseCode`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetoothservices/recordaccessresponsecode) + /// if the request failed. + public func reportSequenceNumberOfLatestRecords() async throws -> UInt16 { + try await $recordAccessControlPoint.reportSequenceNumberOfLatestRecords() + } +} diff --git a/Sources/SpeziOmron/Resources/Media.xcassets/Contents.json b/Sources/SpeziOmron/Resources/Media.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Sources/SpeziOmron/Resources/Media.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/SpeziOmron/Resources/Media.xcassets/Contents.json.license b/Sources/SpeziOmron/Resources/Media.xcassets/Contents.json.license new file mode 100644 index 0000000..6becaa6 --- /dev/null +++ b/Sources/SpeziOmron/Resources/Media.xcassets/Contents.json.license @@ -0,0 +1,6 @@ + +This source file is part of the Stanford SpeziDevices open source project + +SPDX-FileCopyrightText: 2024 Stanford University + +SPDX-License-Identifier: MIT diff --git a/Sources/SpeziOmron/Resources/Media.xcassets/Omron-BP5250.imageset/Contents.json b/Sources/SpeziOmron/Resources/Media.xcassets/Omron-BP5250.imageset/Contents.json new file mode 100644 index 0000000..61bac38 --- /dev/null +++ b/Sources/SpeziOmron/Resources/Media.xcassets/Omron-BP5250.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Omron-BP5250.jpg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/SpeziOmron/Resources/Media.xcassets/Omron-BP5250.imageset/Contents.json.license b/Sources/SpeziOmron/Resources/Media.xcassets/Omron-BP5250.imageset/Contents.json.license new file mode 100644 index 0000000..6becaa6 --- /dev/null +++ b/Sources/SpeziOmron/Resources/Media.xcassets/Omron-BP5250.imageset/Contents.json.license @@ -0,0 +1,6 @@ + +This source file is part of the Stanford SpeziDevices open source project + +SPDX-FileCopyrightText: 2024 Stanford University + +SPDX-License-Identifier: MIT diff --git a/Sources/SpeziOmron/Resources/Media.xcassets/Omron-BP5250.imageset/Omron-BP5250.jpg b/Sources/SpeziOmron/Resources/Media.xcassets/Omron-BP5250.imageset/Omron-BP5250.jpg new file mode 100644 index 0000000..253cad1 Binary files /dev/null and b/Sources/SpeziOmron/Resources/Media.xcassets/Omron-BP5250.imageset/Omron-BP5250.jpg differ diff --git a/Sources/SpeziOmron/Resources/Media.xcassets/Omron-BP5250.imageset/Omron-BP5250.jpg.license b/Sources/SpeziOmron/Resources/Media.xcassets/Omron-BP5250.imageset/Omron-BP5250.jpg.license new file mode 100644 index 0000000..6becaa6 --- /dev/null +++ b/Sources/SpeziOmron/Resources/Media.xcassets/Omron-BP5250.imageset/Omron-BP5250.jpg.license @@ -0,0 +1,6 @@ + +This source file is part of the Stanford SpeziDevices open source project + +SPDX-FileCopyrightText: 2024 Stanford University + +SPDX-License-Identifier: MIT diff --git a/Sources/SpeziOmron/Resources/Media.xcassets/Omron-SC-150.imageset/Contents.json b/Sources/SpeziOmron/Resources/Media.xcassets/Omron-SC-150.imageset/Contents.json new file mode 100644 index 0000000..552caff --- /dev/null +++ b/Sources/SpeziOmron/Resources/Media.xcassets/Omron-SC-150.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Omron-SC-150.jpg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/SpeziOmron/Resources/Media.xcassets/Omron-SC-150.imageset/Contents.json.license b/Sources/SpeziOmron/Resources/Media.xcassets/Omron-SC-150.imageset/Contents.json.license new file mode 100644 index 0000000..6becaa6 --- /dev/null +++ b/Sources/SpeziOmron/Resources/Media.xcassets/Omron-SC-150.imageset/Contents.json.license @@ -0,0 +1,6 @@ + +This source file is part of the Stanford SpeziDevices open source project + +SPDX-FileCopyrightText: 2024 Stanford University + +SPDX-License-Identifier: MIT diff --git a/Sources/SpeziOmron/Resources/Media.xcassets/Omron-SC-150.imageset/Omron-SC-150.jpg b/Sources/SpeziOmron/Resources/Media.xcassets/Omron-SC-150.imageset/Omron-SC-150.jpg new file mode 100644 index 0000000..1188aa2 Binary files /dev/null and b/Sources/SpeziOmron/Resources/Media.xcassets/Omron-SC-150.imageset/Omron-SC-150.jpg differ diff --git a/Sources/SpeziOmron/Resources/Media.xcassets/Omron-SC-150.imageset/Omron-SC-150.jpg.license b/Sources/SpeziOmron/Resources/Media.xcassets/Omron-SC-150.imageset/Omron-SC-150.jpg.license new file mode 100644 index 0000000..6becaa6 --- /dev/null +++ b/Sources/SpeziOmron/Resources/Media.xcassets/Omron-SC-150.imageset/Omron-SC-150.jpg.license @@ -0,0 +1,6 @@ + +This source file is part of the Stanford SpeziDevices open source project + +SPDX-FileCopyrightText: 2024 Stanford University + +SPDX-License-Identifier: MIT diff --git a/Sources/SpeziOmron/SpeziOmron.docc/SpeziOmron.md b/Sources/SpeziOmron/SpeziOmron.docc/SpeziOmron.md new file mode 100644 index 0000000..1f432ff --- /dev/null +++ b/Sources/SpeziOmron/SpeziOmron.docc/SpeziOmron.md @@ -0,0 +1,73 @@ +# ``SpeziOmron`` + +Support interactions with Omron Bluetooth Devices. + + + +## Overview + +SpeziOmron extends SpeziDevices with support for Omron devices. This includes Omron-specific models, characteristics, services and fully reusable +device support. + +### Omron Devices + +The ``OmronBloodPressureCuff`` and ``OmronWeightScale`` devices provide reusable device implementations for the Omron `BP5250` blood pressure cuff +and the Omron `SC-150` weight scale. +Both devices automatically integrate with the ``HealthMeasurements`` and ``PairedDevices`` modules of SpeziDevices. +You just need to configure them for use with the [`Bluetooth`](https://swiftpackageindex.com/stanfordspezi/spezibluetooth/documentation/spezibluetooth/bluetooth#Configure-the-Bluetooth-Module) +module. + +```swift +import SpeziBluetooth +import SpeziBluetoothServices +import SpeziDevices +import SpeziOmron + +class ExampleAppDelegate: SpeziAppDelegate { + override var configuration: Configuration { + Configuration { + Bluetooth { + Discover(OmronBloodPressureCuff.self, by: .accessory(manufacturer: .omronHealthcareCoLtd, advertising: BloodPressureService.self)) + Discover(OmronWeightScale.self, by: .accessory(manufacturer: .omronHealthcareCoLtd, advertising: WeightScaleService.self)) + } + + // If required, configure the PairedDevices and HealthMeasurements modules + PairedDevices() + HealthMeasurements() + } + } +} +``` + +## Topics + +### Omron Devices + +- ``OmronBloodPressureCuff`` +- ``OmronWeightScale`` + +### Omron Device + +- ``OmronHealthDevice`` +- ``OmronModel`` +- ``OmronManufacturerData`` +- ``SpeziBluetooth/ManufacturerIdentifier/omronHealthcareCoLtd`` + +### Omron Services + +- ``OmronOptionService`` + +### Omron Record Access + +- ``SpeziBluetooth/CharacteristicAccessor/reportStoredRecords(_:)`` +- ``SpeziBluetooth/CharacteristicAccessor/reportNumberOfStoredRecords(_:)`` +- ``SpeziBluetooth/CharacteristicAccessor/reportSequenceNumberOfLatestRecords()`` +- ``OmronRecordAccessOperand`` diff --git a/Sources/TemplatePackage/TemplatePackage.docc/TemplatePackage.md b/Sources/TemplatePackage/TemplatePackage.docc/TemplatePackage.md deleted file mode 100644 index 2417f3d..0000000 --- a/Sources/TemplatePackage/TemplatePackage.docc/TemplatePackage.md +++ /dev/null @@ -1,23 +0,0 @@ -# ``TemplatePackage`` - - - -The template repository contains a template Swift Package, including a continuous integration setup. - -## Overview - -Please follow the steps in the README.md file to customize the code to your needs. - -## Types - -### Template Package - -- ``TemplatePackage`` diff --git a/Sources/TemplatePackage/TemplatePackage.swift b/Sources/TemplatePackage/TemplatePackage.swift deleted file mode 100644 index fb06b53..0000000 --- a/Sources/TemplatePackage/TemplatePackage.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// This source file is part of the TemplatePackage open source project -// -// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -/// The main type of the Swift Package template. -public struct TemplatePackage { - /// The Swift Package template package is provided by Stanford University. - public var stanford: String { - "Stanford University" - } - - - /// The main type of the Swift Package template. - public init() {} -} diff --git a/Tests/SpeziDevicesTests/HealthMeasurementsTests.swift b/Tests/SpeziDevicesTests/HealthMeasurementsTests.swift new file mode 100644 index 0000000..5a445e1 --- /dev/null +++ b/Tests/SpeziDevicesTests/HealthMeasurementsTests.swift @@ -0,0 +1,242 @@ +// +// This source file is part of the Stanford SpeziDevices open source project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import HealthKit +@_spi(TestingSupport) import SpeziBluetooth +import SpeziBluetoothServices +@_spi(TestingSupport) @testable import SpeziDevices +import XCTest + + +final class HealthMeasurementsTests: XCTestCase { + @MainActor + func testReceivingWeightMeasurements() async throws { + let device = MockDevice.createMockDevice(weightMeasurement: .mock(additionalInfo: .init(bmi: 230, height: 1790))) + let measurements = HealthMeasurements() + + measurements.configureReceivingMeasurements(for: device, on: device.weightScale) + + // just inject the same value again to trigger on change! + let measurement = try XCTUnwrap(device.weightScale.weightMeasurement) + device.weightScale.$weightMeasurement.inject(measurement) + + try await Task.sleep(for: .milliseconds(50)) + + XCTAssertTrue(measurements.shouldPresentMeasurements) + XCTAssertEqual(measurements.pendingMeasurements.count, 1) + + let weightMeasurement = try XCTUnwrap(measurements.pendingMeasurements.first) + guard case let .weight(sample, bmi0, height0) = weightMeasurement else { + XCTFail("Unexpected type of measurement: \(weightMeasurement)") + return + } + + let bmi = try XCTUnwrap(bmi0) + let height = try XCTUnwrap(height0) + let expectedDate = try XCTUnwrap(device.weightScale.weightMeasurement?.timeStamp?.date) + + XCTAssertEqual(weightMeasurement.samples, [sample, bmi, height]) + + XCTAssertEqual(sample.quantityType, HKQuantityType(.bodyMass)) + XCTAssertEqual(sample.startDate, expectedDate) + XCTAssertEqual(sample.endDate, sample.startDate) + XCTAssertEqual(sample.quantity.doubleValue(for: .gramUnit(with: .kilo)), 42.0) + XCTAssertEqual(sample.device?.name, "Mock Device") + + + XCTAssertEqual(bmi.quantityType, HKQuantityType(.bodyMassIndex)) + XCTAssertEqual(bmi.startDate, expectedDate) + XCTAssertEqual(bmi.endDate, sample.startDate) + XCTAssertEqual(bmi.quantity.doubleValue(for: .count()), 23) + XCTAssertEqual(bmi.device?.name, "Mock Device") + + XCTAssertEqual(height.quantityType, HKQuantityType(.height)) + XCTAssertEqual(height.startDate, expectedDate) + XCTAssertEqual(height.endDate, sample.startDate) + XCTAssertEqual(height.quantity.doubleValue(for: .meterUnit(with: .centi)), 179.0) + XCTAssertEqual(height.device?.name, "Mock Device") + } + + @MainActor + func testReceivingBloodPressureMeasurements() async throws { + let device = MockDevice.createMockDevice() + let measurements = HealthMeasurements() + + measurements.configureReceivingMeasurements(for: device, on: device.bloodPressure) + + // just inject the same value again to trigger on change! + let measurement = try XCTUnwrap(device.bloodPressure.bloodPressureMeasurement) + device.bloodPressure.$bloodPressureMeasurement.inject(measurement) + + try await Task.sleep(for: .milliseconds(50)) + + XCTAssertTrue(measurements.shouldPresentMeasurements) + XCTAssertEqual(measurements.pendingMeasurements.count, 1) + + let bloodPressureMeasurement = try XCTUnwrap(measurements.pendingMeasurements.first) + guard case let .bloodPressure(sample, heartRate0) = bloodPressureMeasurement else { + XCTFail("Unexpected type of measurement: \(bloodPressureMeasurement)") + return + } + + let heartRate = try XCTUnwrap(heartRate0) + let expectedDate = try XCTUnwrap(device.weightScale.weightMeasurement?.timeStamp?.date) + + XCTAssertEqual(bloodPressureMeasurement.samples, [sample, heartRate]) + + + XCTAssertEqual(heartRate.quantityType, HKQuantityType(.heartRate)) + XCTAssertEqual(heartRate.startDate, expectedDate) + XCTAssertEqual(heartRate.endDate, sample.startDate) + XCTAssertEqual(heartRate.quantity.doubleValue(for: .count().unitDivided(by: .minute())), 62) + XCTAssertEqual(heartRate.device?.name, "Mock Device") + + XCTAssertEqual(sample.objects.count, 2) + let systolic = try XCTUnwrap(sample.objects(for: HKQuantityType(.bloodPressureSystolic)).first as? HKQuantitySample) + let diastolic = try XCTUnwrap(sample.objects(for: HKQuantityType(.bloodPressureDiastolic)).first as? HKQuantitySample) + + XCTAssertEqual(systolic.quantityType, HKQuantityType(.bloodPressureSystolic)) + XCTAssertEqual(systolic.startDate, expectedDate) + XCTAssertEqual(systolic.endDate, sample.startDate) + XCTAssertEqual(systolic.quantity.doubleValue(for: .millimeterOfMercury()), 103.0) + XCTAssertEqual(systolic.device?.name, "Mock Device") + + XCTAssertEqual(diastolic.quantityType, HKQuantityType(.bloodPressureDiastolic)) + XCTAssertEqual(diastolic.startDate, expectedDate) + XCTAssertEqual(diastolic.endDate, sample.startDate) + XCTAssertEqual(diastolic.quantity.doubleValue(for: .millimeterOfMercury()), 64.0) + XCTAssertEqual(diastolic.device?.name, "Mock Device") + } + + @MainActor + func testMeasurementStorage() async throws { + let measurements = HealthMeasurements() + + measurements.configure() // init model container + try await Task.sleep(for: .milliseconds(50)) + + measurements.loadMockWeightMeasurement() + measurements.loadMockBloodPressureMeasurement() + + XCTAssertEqual(measurements.pendingMeasurements.count, 2) + + try measurements.refreshFetchingMeasurements() // clear pending measurements and fetch again from storage + try await Task.sleep(for: .milliseconds(50)) + + XCTAssertEqual(measurements.pendingMeasurements.count, 2) + // tests that order stays same over storage retrieval + + // Restoring from disk doesn't preserve HealthKit UUIDs + guard case .bloodPressure = measurements.pendingMeasurements.first, + case .weight = measurements.pendingMeasurements.last else { + XCTFail("Order of measurements doesn't match: \(measurements.pendingMeasurements)") + return + } + } + + @MainActor + func testDiscardingMeasurements() async throws { + let device = MockDevice.createMockDevice() + let measurements = HealthMeasurements() + + measurements.configureReceivingMeasurements(for: device, on: device.bloodPressure) + measurements.configureReceivingMeasurements(for: device, on: device.weightScale) + + let measurement1 = try XCTUnwrap(device.weightScale.weightMeasurement) + device.weightScale.$weightMeasurement.inject(measurement1) + + try await Task.sleep(for: .milliseconds(50)) + + let measurement0 = try XCTUnwrap(device.bloodPressure.bloodPressureMeasurement) + device.bloodPressure.$bloodPressureMeasurement.inject(measurement0) + + try await Task.sleep(for: .milliseconds(50)) + + XCTAssertTrue(measurements.shouldPresentMeasurements) + XCTAssertEqual(measurements.pendingMeasurements.count, 2) + + let bloodPressureMeasurement = try XCTUnwrap(measurements.pendingMeasurements.first) + + // measurements are prepended + guard case .bloodPressure = bloodPressureMeasurement else { + XCTFail("Unexpected type of measurement: \(bloodPressureMeasurement)") + return + } + + measurements.discardMeasurement(bloodPressureMeasurement) + XCTAssertTrue(measurements.shouldPresentMeasurements) + XCTAssertEqual(measurements.pendingMeasurements.count, 1) + + let weightMeasurement = try XCTUnwrap(measurements.pendingMeasurements.first) + guard case .weight = weightMeasurement else { + XCTFail("Unexpected type of measurement: \(weightMeasurement)") + return + } + } + + func testBluetoothMeasurementCodable() throws { // swiftlint:disable:this function_body_length + let weightMeasurement = + """ + { + "type": "weight", + "measurement": {"weight":8400, "unit":"si", "timeStamp":{"minutes":33,"day":5,"year":2024,"hours":12,"seconds":11,"month":6}}, + "features": 6 + } + + """ + let bloodPressureMeasurement = + """ + { + "type":"bloodPressure", + "measurement":{ + "unit":"mmHg", + "systolicValue":62470, + "diastolicValue":62080, + "timeStamp":{"seconds":11,"day":5,"hours":12,"year":2024,"month":6,"minutes":33}, + "meanArterialPressure":62210, + "pulseRate":62060, + "measurementStatus":0, + "userId":1 + }, + "features":257 + } + + """ + + let decoder = JSONDecoder() + + let weightData = try XCTUnwrap(weightMeasurement.data(using: .utf8)) + let pressureData = try XCTUnwrap(bloodPressureMeasurement.data(using: .utf8)) + + let decodedWeight = try decoder.decode(BluetoothHealthMeasurement.self, from: weightData) + let decodedPressure = try decoder.decode(BluetoothHealthMeasurement.self, from: pressureData) + + let dateTime = DateTime(year: 2024, month: .june, day: 5, hours: 12, minutes: 33, seconds: 11) + XCTAssertEqual( + decodedWeight, + .weight(.init(weight: 8400, unit: .si, timeStamp: dateTime), [.bmiSupported, .multipleUsersSupported]) + ) + + XCTAssertEqual( + decodedPressure, + .bloodPressure( + .init( + systolic: 103, + diastolic: 64, + meanArterialPressure: 77, + unit: .mmHg, + timeStamp: dateTime, + pulseRate: 62, + userId: 1, + measurementStatus: [] + ), + [.bodyMovementDetectionSupported, .userFacingTimeSupported] + ) + ) + } +} diff --git a/Tests/SpeziDevicesTests/PairedDevicesTests.swift b/Tests/SpeziDevicesTests/PairedDevicesTests.swift new file mode 100644 index 0000000..57cc2e5 --- /dev/null +++ b/Tests/SpeziDevicesTests/PairedDevicesTests.swift @@ -0,0 +1,185 @@ +// +// This source file is part of the Stanford SpeziDevices open source project +// +// SPDX-FileCopyrightText: 2024 Stanford University +// +// SPDX-License-Identifier: MIT +// + +@_spi(TestingSupport) import SpeziBluetooth +import SpeziBluetoothServices +@_spi(TestingSupport) @testable import SpeziDevices +import SpeziFoundation +import XCTest +import XCTestExtensions +import XCTSpezi + + +final class PairedDevicesTests: XCTestCase { + @MainActor + func testPairDevice() async throws { + let device = MockDevice.createMockDevice() + let devices = PairedDevices() + + + // ensure PairedDevices gets injected into the MockDevice + withDependencyResolution { + device + devices + } + + device.isInPairingMode = true + + + XCTAssertFalse(devices.isConnected(device: device.id)) + XCTAssertFalse(devices.isPaired(device)) + + devices.configure(device: device, accessing: device.$state, device.$advertisementData, device.$nearby) + + try await devices.pair(with: device) + + + XCTAssertTrue(devices.isPaired(device)) + XCTAssertTrue(devices.isConnected(device: device.id)) + + XCTAssertEqual(devices.pairedDevices?.count, 1) + let deviceInfo = try XCTUnwrap(devices.pairedDevices?.first) + + XCTAssertEqual(deviceInfo.id, device.id) + XCTAssertEqual(deviceInfo.deviceType, MockDevice.deviceTypeIdentifier) + XCTAssertNil(deviceInfo.icon) + XCTAssertEqual(deviceInfo.model, device.deviceInformation.modelNumber) + XCTAssertEqual(deviceInfo.name, device.name) + XCTAssertEqual(deviceInfo.lastBatteryPercentage, 85) + + let initialLastSeen = deviceInfo.lastSeen + + device.battery.$batteryLevel.inject(71) + await device.disconnect() + device.$nearby.inject(false) + + XCTAssertEqual(device.state, .disconnected) + + try await Task.sleep(for: .milliseconds(50)) + + + XCTAssertTrue(deviceInfo.lastSeen > initialLastSeen) // should be later and updated on disconnect + XCTAssertEqual(deviceInfo.lastBatteryPercentage, 71) // should have captured the updated battery + + + devices.updateName(for: deviceInfo, name: "Custom Name") + XCTAssertEqual(deviceInfo.name, "Custom Name") + + let recentLastSeen = deviceInfo.lastSeen + + // test storage persistence! + try devices.refreshPairedDevices() + try { + XCTAssertEqual(devices.pairedDevices?.count, 1) + let info0 = try XCTUnwrap(devices.pairedDevices?.first) + XCTAssertEqual(info0.name, "Custom Name") + XCTAssertEqual(info0.lastBatteryPercentage, 71) + XCTAssertEqual(info0.lastSeen, recentLastSeen) + }() + + + await device.connect() + try await Task.sleep(for: .seconds(1.1)) + XCTAssertEqual(device.state, .connected) + + + devices.forgetDevice(id: device.id) + try await Task.sleep(for: .milliseconds(50)) + + XCTAssertEqual(device.state, .disconnected) + XCTAssertEqual(devices.pairedDevices?.isEmpty, true) + XCTAssertTrue(devices.discoveredDevices.isEmpty) + } + + @MainActor + func testPairingErrors() async throws { + let device = MockDevice.createMockDevice() + let devices = PairedDevices() + + withDependencyResolution { + devices + } + + device.isInPairingMode = true + + device.$nearby.inject(false) + try await XCTAssertThrowsErrorAsync(await devices.pair(with: device)) { error in + XCTAssertEqual(try XCTUnwrap(error as? DevicePairingError), .invalidState) + } + device.$nearby.inject(true) + + await device.connect() + try await XCTAssertThrowsErrorAsync(await devices.pair(with: device)) { error in + XCTAssertEqual(try XCTUnwrap(error as? DevicePairingError), .invalidState) + } + await device.disconnect() + + device.isInPairingMode = false + try await XCTAssertThrowsErrorAsync(await devices.pair(with: device)) { error in + XCTAssertEqual(try XCTUnwrap(error as? DevicePairingError), .notInPairingMode) + } + device.isInPairingMode = true + + try await XCTAssertThrowsErrorAsync(await devices.pair(with: device, timeout: .milliseconds(200))) { error in + XCTAssertTrue(error is TimeoutError) + } + } + + @MainActor + func testPairingCancellation() async throws { + let device = MockDevice.createMockDevice() + let devices = PairedDevices() + + withDependencyResolution { + devices + } + + device.isInPairingMode = true + + let task = Task { + try await devices.pair(with: device) + } + + try await Task.sleep(for: .milliseconds(50)) + task.cancel() + + try await XCTAssertThrowsErrorAsync(await task.value) { error in + XCTAssertTrue(error is CancellationError) + } + + XCTAssertEqual(device.state, .disconnected) + } + + @MainActor + func testFailedPairing() async throws { + let device = MockDevice.createMockDevice() + let devices = PairedDevices() + + withDependencyResolution { + device + devices + } + + device.isInPairingMode = true + + devices.configure(device: device, accessing: device.$state, device.$advertisementData, device.$nearby) + + let task = Task { + try await devices.pair(with: device) + } + + try await Task.sleep(for: .milliseconds(1150)) + await device.disconnect() + + try await XCTAssertThrowsErrorAsync(await task.value) { error in + XCTAssertEqual(try XCTUnwrap(error as? DevicePairingError), .deviceDisconnected) + } + + XCTAssertEqual(device.state, .disconnected) + } +} diff --git a/Tests/SpeziOmronTests/SpeziOmronTests.swift b/Tests/SpeziOmronTests/SpeziOmronTests.swift new file mode 100644 index 0000000..f002656 --- /dev/null +++ b/Tests/SpeziOmronTests/SpeziOmronTests.swift @@ -0,0 +1,186 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +@_spi(TestingSupport) import ByteCoding +import CoreBluetooth +@_spi(TestingSupport) import SpeziBluetooth +import SpeziBluetoothServices +@_spi(TestingSupport) import SpeziDevices +@testable import SpeziOmron +import XCTByteCoding +import XCTest +import XCTestExtensions + + +typealias RACP = RecordAccessControlPoint + +final class SpeziOmronTests: XCTestCase { + func testModelCodable() throws { + let string = "\"SC-150\"" + let data = try XCTUnwrap(string.data(using: .utf8)) + let decoded = try JSONDecoder().decode(OmronModel.self, from: data) + XCTAssertEqual(decoded, .sc150) + } + + func testOmronManufacturerData() throws { + try testIdentity(from: OmronManufacturerData( + timeSet: true, + pairingMode: .pairingMode, + streamingMode: .dataCommunication, + servicesMode: .bluetoothStandard, + users: [.init(id: 1, sequenceNumber: 3, recordsNumber: 8)] + )) + + try testIdentity(from: OmronManufacturerData( + timeSet: false, + pairingMode: .transferMode, + streamingMode: .streaming, + servicesMode: .omronExtension, + users: [ + .init(id: 1, sequenceNumber: 3, recordsNumber: 8), + .init(id: 2, sequenceNumber: 0, recordsNumber: 0) + ] + )) + + try testIdentity(from: OmronManufacturerData( + timeSet: false, + pairingMode: .transferMode, + streamingMode: .streaming, + servicesMode: .omronExtension, + users: [ + .init(id: 1, sequenceNumber: 3, recordsNumber: 8), + .init(id: 2, sequenceNumber: 0, recordsNumber: 0), + .init(id: 3, sequenceNumber: 5, recordsNumber: 0) + ] + )) + + try testIdentity(from: OmronManufacturerData( + timeSet: false, + pairingMode: .transferMode, + streamingMode: .streaming, + servicesMode: .omronExtension, + users: [ + .init(id: 1, sequenceNumber: 3, recordsNumber: 8), + .init(id: 2, sequenceNumber: 0, recordsNumber: 0), + .init(id: 3, sequenceNumber: 5, recordsNumber: 0), + .init(id: 4, sequenceNumber: 9, recordsNumber: 0) + ] + )) + } + + func testOmronHealthDevice() throws { + let manufacturerData = OmronManufacturerData(pairingMode: .pairingMode, users: [.init(id: 1, sequenceNumber: 3, recordsNumber: 8)]) + + let device = MockDevice.createMockDevice() + device.$advertisementData.inject(AdvertisementData([ + CBAdvertisementDataManufacturerDataKey: manufacturerData.encode() + ])) + + XCTAssertEqual(device.manufacturerData?.pairingMode, .pairingMode) + + let manufacturerData0 = OmronManufacturerData(pairingMode: .transferMode, users: [.init(id: 1, sequenceNumber: 3, recordsNumber: 8)]) + device.$advertisementData.inject(AdvertisementData([ + CBAdvertisementDataManufacturerDataKey: manufacturerData0.encode() + ])) + + XCTAssertEqual(device.manufacturerData?.pairingMode, .transferMode) + + + device.deviceInformation.$modelNumber.inject(OmronModel.bp5250.rawValue) + + XCTAssertEqual(device.model, .bp5250) + } + + func testRACPReportStoredRecords() throws { + try testIdentity(from: RACP.reportStoredRecords(.allRecords)) + try testIdentity(from: RACP.reportStoredRecords(.lastRecord)) + try testIdentity(from: RACP.reportStoredRecords(.firstRecord)) + try testIdentity(from: RACP.reportStoredRecords(.greaterThanOrEqualTo(sequenceNumber: 12))) + + try testIdentity(of: RACP.self, from: XCTUnwrap(Data(hex: "0x0101"))) // Report All Records + try testIdentity(of: RACP.self, from: XCTUnwrap(Data(hex: "0x010304FFFF"))) // Report greater than or equal to + try testIdentity(of: RACP.self, from: XCTUnwrap(Data(hex: "0x06000101"))) // SUCCESS + try testIdentity(of: RACP.self, from: XCTUnwrap(Data(hex: "0x06000106"))) // no records found + } + + func testRACPReportNumberOfStoredRecords() throws { + try testIdentity(from: RACP.reportNumberOfStoredRecords(.allRecords)) + try testIdentity(from: RACP.reportNumberOfStoredRecords(.lastRecord)) + try testIdentity(from: RACP.reportNumberOfStoredRecords(.firstRecord)) + try testIdentity(from: RACP.reportNumberOfStoredRecords(.greaterThanOrEqualTo(sequenceNumber: 12))) + + try testIdentity(of: RACP.self, from: XCTUnwrap(Data(hex: "0x0401"))) // Report All Records + try testIdentity(of: RACP.self, from: XCTUnwrap(Data(hex: "0x040304FFFF"))) // Report greater than or equal to + try testIdentity(of: RACP.self, from: XCTUnwrap(Data(hex: "0x0500FFFF"))) // Response + } + + func testRACPNumberOfLatestRecords() throws { + try testIdentity(from: RACP.reportSequenceNumberOfLatestRecords()) + + try testIdentity(of: RACP.self, from: XCTUnwrap(Data(hex: "0x1100FFFF"))) // Response + } + + func testRACPReportRecordsRequest() async throws { + let service = OmronOptionService() + + service.$recordAccessControlPoint.onRequest { _ in + RACP(opCode: .responseCode, operator: .null, operand: .generalResponse(.init(requestOpCode: .reportStoredRecords, response: .success))) + } + try await service.reportStoredRecords(.allRecords) + + service.$recordAccessControlPoint.onRequest { _ in + RACP( + opCode: .responseCode, + operator: .null, + operand: .generalResponse(.init(requestOpCode: .reportStoredRecords, response: .noRecordsFound)) + ) + } + try await XCTAssertThrowsErrorAsync(await service.reportStoredRecords(.allRecords)) { error in + try XCTAssertEqual(XCTUnwrap(error as? RecordAccessResponseCode), .noRecordsFound) + } + } + + func testRACPReportNumberOfStoredRecordsRequest() async throws { + let service = OmronOptionService() + + service.$recordAccessControlPoint.onRequest { _ in + RACP(opCode: .numberOfStoredRecordsResponse, operator: .null, operand: .numberOfRecords(1234)) + } + + let count = try await service.reportNumberOfStoredRecords(.allRecords) + XCTAssertEqual(count, 1234) + + service.$recordAccessControlPoint.onRequest { _ in + RACP(opCode: .numberOfStoredRecordsResponse, operator: .null, operand: .sequenceNumber(1234)) + } + try await XCTAssertThrowsErrorAsync(await service.reportNumberOfStoredRecords(.allRecords)) { error in + try XCTAssertEqual(XCTUnwrap(error as? RecordAccessResponseFormatError).reason, .unexpectedOperand) + } + } + + func testRACPReportSequenceNumberOfLatestRecords() async throws { + let service = OmronOptionService() + + service.$recordAccessControlPoint.onRequest { _ in + RACP(opCode: .omronSequenceNumberOfLatestRecordsResponse, operator: .null, operand: .sequenceNumber(1234)) + } + + let count = try await service.reportSequenceNumberOfLatestRecords() + XCTAssertEqual(count, 1234) + + service.$recordAccessControlPoint.onRequest { _ in + RACP(opCode: .omronSequenceNumberOfLatestRecordsResponse, operator: .null, operand: .numberOfRecords(1234)) + } + try await XCTAssertThrowsErrorAsync(await service.reportSequenceNumberOfLatestRecords()) { error in + try XCTAssertEqual(XCTUnwrap(error as? RecordAccessResponseFormatError).reason, .unexpectedOperand) + } + } +} + + +extension MockDevice: OmronHealthDevice {} diff --git a/Tests/TemplatePackageTests/TemplatePackageTests.swift b/Tests/TemplatePackageTests/TemplatePackageTests.swift deleted file mode 100644 index bc52b5f..0000000 --- a/Tests/TemplatePackageTests/TemplatePackageTests.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// This source file is part of the TemplatePackage open source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -@testable import TemplatePackage -import XCTest - - -final class TemplatePackageTests: XCTestCase { - func testTemplatePackage() throws { - let templatePackage = TemplatePackage() - XCTAssertEqual(templatePackage.stanford, "Stanford University") - } -} diff --git a/Tests/UITests/TestApp.xctestplan b/Tests/UITests/TestApp.xctestplan index 54e441a..1bf139f 100644 --- a/Tests/UITests/TestApp.xctestplan +++ b/Tests/UITests/TestApp.xctestplan @@ -13,8 +13,18 @@ "targets" : [ { "containerPath" : "container:..\/..", - "identifier" : "TemplatePackage", - "name" : "TemplatePackage" + "identifier" : "SpeziDevices", + "name" : "SpeziDevices" + }, + { + "containerPath" : "container:..\/..", + "identifier" : "SpeziDevicesUI", + "name" : "SpeziDevicesUI" + }, + { + "containerPath" : "container:..\/..", + "identifier" : "SpeziOmron", + "name" : "SpeziOmron" } ] }, diff --git a/Tests/UITests/TestApp.xctestplan.license b/Tests/UITests/TestApp.xctestplan.license index d77e33d..7f16969 100644 --- a/Tests/UITests/TestApp.xctestplan.license +++ b/Tests/UITests/TestApp.xctestplan.license @@ -1,4 +1,4 @@ -This source file is part of the TemplatePackage open-source project +This source file is part of the Stanford Spezi open-source project SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) diff --git a/Tests/UITests/TestApp/Assets.xcassets/AccentColor.colorset/Contents.json.license b/Tests/UITests/TestApp/Assets.xcassets/AccentColor.colorset/Contents.json.license index d77e33d..7f16969 100644 --- a/Tests/UITests/TestApp/Assets.xcassets/AccentColor.colorset/Contents.json.license +++ b/Tests/UITests/TestApp/Assets.xcassets/AccentColor.colorset/Contents.json.license @@ -1,4 +1,4 @@ -This source file is part of the TemplatePackage open-source project +This source file is part of the Stanford Spezi open-source project SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) diff --git a/Tests/UITests/TestApp/Assets.xcassets/AppIcon.appiconset/Contents.json.license b/Tests/UITests/TestApp/Assets.xcassets/AppIcon.appiconset/Contents.json.license index d77e33d..7f16969 100644 --- a/Tests/UITests/TestApp/Assets.xcassets/AppIcon.appiconset/Contents.json.license +++ b/Tests/UITests/TestApp/Assets.xcassets/AppIcon.appiconset/Contents.json.license @@ -1,4 +1,4 @@ -This source file is part of the TemplatePackage open-source project +This source file is part of the Stanford Spezi open-source project SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) diff --git a/Tests/UITests/TestApp/Assets.xcassets/Contents.json.license b/Tests/UITests/TestApp/Assets.xcassets/Contents.json.license index d77e33d..7f16969 100644 --- a/Tests/UITests/TestApp/Assets.xcassets/Contents.json.license +++ b/Tests/UITests/TestApp/Assets.xcassets/Contents.json.license @@ -1,4 +1,4 @@ -This source file is part of the TemplatePackage open-source project +This source file is part of the Stanford Spezi open-source project SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) diff --git a/Tests/UITests/TestApp/BluetoothViewsTest.swift b/Tests/UITests/TestApp/BluetoothViewsTest.swift new file mode 100644 index 0000000..284b26f --- /dev/null +++ b/Tests/UITests/TestApp/BluetoothViewsTest.swift @@ -0,0 +1,55 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +@_spi(TestingSupport) import SpeziDevices +import SpeziDevicesUI +import SwiftUI + + +struct BluetoothViewsTest: View { + @State private var device = MockDevice.createMockDevice() + @State private var presentDeviceDetails = false + + var body: some View { + NavigationStack { + List { + BluetoothUnavailableSection() + + Section { + NearbyDeviceRow(peripheral: device, primaryAction: tapAction) { + presentDeviceDetails = true + } + } header: { + LoadingSectionHeader("Devices", loading: true) + } + } + .navigationTitle("Views") + .navigationDestination(isPresented: $presentDeviceDetails) { + MockDeviceDetailsView(device) + } + } + } + + + @MainActor + private func tapAction() { + Task { + switch device.state { + case .disconnected, .disconnecting: + await device.connect() + case .connecting, .connected: + await device.disconnect() + } + } + } +} + + +#Preview { + BluetoothViewsTest() +} diff --git a/Tests/UITests/TestApp/ContentView.swift b/Tests/UITests/TestApp/ContentView.swift new file mode 100644 index 0000000..226611f --- /dev/null +++ b/Tests/UITests/TestApp/ContentView.swift @@ -0,0 +1,43 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziBluetooth +import SpeziDevices +import SpeziDevicesUI +import SwiftUI + + +struct ContentView: View { + var body: some View { + TabView { + DevicesTestView() + .tabItem { + Label("Devices", systemImage: "sensor.fill") + } + MeasurementsTestView() + .tabItem { + Label("Measurements", systemImage: "list.bullet.clipboard.fill") + } + BluetoothViewsTest() + .tabItem { + Label("Views", systemImage: "macwindow") + } + } + } +} + + +#Preview { + ContentView() + .previewWith { + MockDeviceLoading() + PairedDevices() + HealthMeasurements() + Bluetooth {} + } +} diff --git a/Tests/UITests/TestApp/DevicesTestView.swift b/Tests/UITests/TestApp/DevicesTestView.swift new file mode 100644 index 0000000..6172694 --- /dev/null +++ b/Tests/UITests/TestApp/DevicesTestView.swift @@ -0,0 +1,78 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +@_spi(APISupport) import Spezi +@_spi(TestingSupport) import SpeziBluetooth +@_spi(TestingSupport) import SpeziDevices +import SpeziDevicesUI +import SpeziViews +import SwiftUI + + +class MockDeviceLoading: Module, EnvironmentAccessible { + @Application(\.spezi) private var spezi + + init() {} + + func loadMockDevice(_ device: some PairableDevice) { + spezi.loadModule(device, ownership: .external) + } +} + + +struct DevicesTestView: View { + @Environment(PairedDevices.self) private var pairedDevices + @Environment(MockDeviceLoading.self) private var moduleLoading + + @State private var didRegister = false + @State private var device = MockDevice.createMockDevice() + + var body: some View { + NavigationStack { + DevicesView(appName: "TestApp", pairingHint: "Enable pairing mode on the device.") + .toolbar { + ToolbarItemGroup(placement: .secondaryAction) { + Button("Discover Device", systemImage: "plus.rectangle.fill.on.rectangle.fill") { + device.isInPairingMode = true + device.$advertisementData.inject(AdvertisementData([:])) // trigger onChange advertisement + } + AsyncButton { + await device.connect() + } label: { + Label("Connect", systemImage: "cable.connector") + } + AsyncButton { + await device.disconnect() + } label: { + Label("Disconnect", systemImage: "cable.connector.slash") + } + } + } + } + .onAppear { + guard !didRegister else { + return + } + + moduleLoading.loadMockDevice(device) + // simulator this being called in the configure method of the device + pairedDevices.configure(device: device, accessing: device.$state, device.$advertisementData, device.$nearby) + didRegister = true + } + } +} + + +#Preview { + DevicesTestView() + .previewWith { + PairedDevices() + MockDeviceLoading() + Bluetooth {} + } +} diff --git a/Tests/UITests/TestApp/Health/HKCorrelationView.swift b/Tests/UITests/TestApp/Health/HKCorrelationView.swift new file mode 100644 index 0000000..683a161 --- /dev/null +++ b/Tests/UITests/TestApp/Health/HKCorrelationView.swift @@ -0,0 +1,39 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import HealthKit +import Spezi +@_spi(TestingSupport) import SpeziDevices +import SpeziDevicesUI +import SpeziViews +import SwiftUI + + +struct HKCorrelationView: View { + private let correlation: HKCorrelation + + var body: some View { + if let systolic = correlation.objects(for: HKQuantityType(.bloodPressureSystolic)).first as? HKQuantitySample { + HKQuantitySampleView(systolic) + } + if let diastolic = correlation.objects(for: HKQuantityType(.bloodPressureDiastolic)).first as? HKQuantitySample { + HKQuantitySampleView(diastolic) + } + } + + init(_ correlation: HKCorrelation) { + self.correlation = correlation + } +} + + +#Preview { + List { + HKCorrelationView(.mockBloodPressureSample) + } +} diff --git a/Tests/UITests/TestApp/Health/HKQuantitySampleView.swift b/Tests/UITests/TestApp/Health/HKQuantitySampleView.swift new file mode 100644 index 0000000..5644510 --- /dev/null +++ b/Tests/UITests/TestApp/Health/HKQuantitySampleView.swift @@ -0,0 +1,61 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import HealthKit +import Spezi +@_spi(TestingSupport) import SpeziDevices +import SpeziDevicesUI +import SpeziViews +import SwiftUI + + +struct HKQuantitySampleView: View { + private let sample: HKQuantitySample + + var body: some View { + VStack(alignment: .leading) { + ListRow(sample.quantity.description) { + Text(sample.startDate, style: .time) + } + if let device = sample.device, let name = device.name { + Text(name) + .foregroundStyle(.secondary) + .font(.caption2) + } + } + } + + init(_ sample: HKQuantitySample) { + self.sample = sample + } +} + + +#Preview { + List { + HKQuantitySampleView(HKQuantitySample.mockWeighSample) + } +} + +#Preview { + List { + HKQuantitySampleView(HKQuantitySample.mockBmiSample) + } +} + +#Preview { + List { + HKQuantitySampleView(HKQuantitySample.mockHeightSample) + } +} + +#Preview { + List { + HKQuantitySampleView(HKQuantitySample.mockHeartRateSample) + } +} diff --git a/Tests/UITests/TestApp/Health/HKSampleView.swift b/Tests/UITests/TestApp/Health/HKSampleView.swift new file mode 100644 index 0000000..7817c1a --- /dev/null +++ b/Tests/UITests/TestApp/Health/HKSampleView.swift @@ -0,0 +1,63 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import HealthKit +@_spi(TestingSupport) import SpeziDevices +import SwiftUI + + +struct HKSampleView: View { + private let sample: HKSample + + var body: some View { + switch sample.sampleType { + case HKQuantityType(.heartRate), HKQuantityType(.bodyMass), HKQuantityType(.bodyMassIndex), HKQuantityType(.height): + HKQuantitySampleView(sample as! HKQuantitySample) // swiftlint:disable:this force_cast + case HKCorrelationType(.bloodPressure): + HKCorrelationView(sample as! HKCorrelation) // swiftlint:disable:this force_cast + default: + Text("Unknown sample type: \(sample.sampleType)") + } + } + + + init(_ sample: HKSample) { + self.sample = sample + } +} + + +#Preview { + List { + HKSampleView(HKQuantitySample.mockWeighSample) + } +} + +#Preview { + List { + HKSampleView(HKQuantitySample.mockBmiSample) + } +} + +#Preview { + List { + HKSampleView(HKQuantitySample.mockHeightSample) + } +} + +#Preview { + List { + HKSampleView(HKQuantitySample.mockHeartRateSample) + } +} + +#Preview { + List { + HKSampleView(HKCorrelation.mockBloodPressureSample) + } +} diff --git a/Tests/UITests/TestApp/MeasurementsTestView.swift b/Tests/UITests/TestApp/MeasurementsTestView.swift new file mode 100644 index 0000000..1eec233 --- /dev/null +++ b/Tests/UITests/TestApp/MeasurementsTestView.swift @@ -0,0 +1,72 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import HealthKit +import Spezi +@_spi(TestingSupport) import SpeziDevices +import SpeziDevicesUI +import SwiftUI + + +struct MeasurementsTestView: View { + @Environment(HealthMeasurements.self) private var healthMeasurements + + @State private var samples: [HKSample] = [] + + var body: some View { + @Bindable var healthMeasurements = healthMeasurements + NavigationStack { // swiftlint:disable:this closure_body_length + Group { + if samples.isEmpty { + ContentUnavailableView( + "No Samples", + systemImage: "heart.text.square", + description: Text("Please add new measurements.") + ) + } else { + List { + ForEach(samples, id: \.uuid) { sample in + HKSampleView(sample) + } + } + } + } + .navigationTitle("Measurements") + .sheet(isPresented: $healthMeasurements.shouldPresentMeasurements) { + MeasurementsRecordedSheet { samples in + self.samples.append(contentsOf: samples) + } + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button("Add Measurement", systemImage: "plus") { + healthMeasurements.shouldPresentMeasurements = true + } + } + ToolbarItemGroup(placement: .secondaryAction) { + Button("Simulate Weight", systemImage: "scalemass.fill") { + healthMeasurements.loadMockWeightMeasurement() + } + Button("Simulate Blood Pressure", systemImage: "heart.fill") { + healthMeasurements.loadMockBloodPressureMeasurement() + } + } + } + } + } + + init() {} +} + + +#Preview { + MeasurementsTestView() + .previewWith { + HealthMeasurements() + } +} diff --git a/Tests/UITests/TestApp/TestApp.swift b/Tests/UITests/TestApp/TestApp.swift index 90fdb20..049549f 100644 --- a/Tests/UITests/TestApp/TestApp.swift +++ b/Tests/UITests/TestApp/TestApp.swift @@ -1,20 +1,41 @@ // -// This source file is part of the TemplatePackage open-source project +// This source file is part of the Stanford Spezi open-source project // -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) // // SPDX-License-Identifier: MIT // +import Spezi +import SpeziBluetooth +import SpeziBluetoothServices +@_spi(TestingSupport) import SpeziDevices +import SpeziDevicesUI import SwiftUI -import TemplatePackage + + +class TestAppDelegate: SpeziAppDelegate { + override var configuration: Configuration { + Configuration { + Bluetooth { + Discover(MockDevice.self, by: .accessory(manufacturer: .init(rawValue: 0x01), advertising: BloodPressureService.self)) + } + PairedDevices() + HealthMeasurements() + MockDeviceLoading() + } + } +} @main -struct UITestsApp: App { +struct TestApp: App { + @ApplicationDelegateAdaptor(TestAppDelegate.self) private var delegate + var body: some Scene { WindowGroup { - Text(TemplatePackage().stanford) + ContentView() + .spezi(delegate) } } } diff --git a/Tests/UITests/TestApp/Views/BluetoothUnavailableSection.swift b/Tests/UITests/TestApp/Views/BluetoothUnavailableSection.swift new file mode 100644 index 0000000..0654a78 --- /dev/null +++ b/Tests/UITests/TestApp/Views/BluetoothUnavailableSection.swift @@ -0,0 +1,42 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziDevicesUI +import SwiftUI + + +struct BluetoothUnavailableSection: View { + var body: some View { + Section("Bluetooth Unavailable") { + NavigationLink("Bluetooth Powered Off") { + BluetoothUnavailableView(.poweredOff) + } + NavigationLink("Bluetooth Powered On") { + BluetoothUnavailableView(.poweredOn) + } + NavigationLink("Bluetooth Unauthorized") { + BluetoothUnavailableView(.unauthorized) + } + NavigationLink("Bluetooth Unsupported") { + BluetoothUnavailableView(.unsupported) + } + NavigationLink("Bluetooth Unknown") { + BluetoothUnavailableView(.unknown) + } + } + } +} + + +#Preview { + NavigationStack { + List { + BluetoothUnavailableSection() + } + } +} diff --git a/Tests/UITests/TestApp/Views/MockDeviceDetailsView.swift b/Tests/UITests/TestApp/Views/MockDeviceDetailsView.swift new file mode 100644 index 0000000..aed67a3 --- /dev/null +++ b/Tests/UITests/TestApp/Views/MockDeviceDetailsView.swift @@ -0,0 +1,54 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +@_spi(TestingSupport) import SpeziDevices +import SpeziDevicesUI +import SpeziViews +import SwiftUI + + +struct MockDeviceDetailsView: View { + private let device: MockDevice + + var body: some View { + List { + ListRow("Name") { + Text(device.label) + } + if let model = device.deviceInformation.modelNumber { + ListRow("Model") { + Text(model) + } + } + if let firmwareVersion = device.deviceInformation.firmwareRevision { + ListRow("Firmware Version") { + Text(firmwareVersion) + } + } + if let battery = device.battery.batteryLevel { + ListRow("Battery") { + BatteryIcon(percentage: Int(battery)) + .labelStyle(.reverse) + } + } + } + .navigationTitle(device.label) + .navigationBarTitleDisplayMode(.inline) + } + + init(_ device: MockDevice) { + self.device = device + } +} + + +#Preview { + NavigationStack { + MockDeviceDetailsView(MockDevice.createMockDevice()) + } +} diff --git a/Tests/UITests/TestAppUITests/BluetoothViewsTests.swift b/Tests/UITests/TestAppUITests/BluetoothViewsTests.swift new file mode 100644 index 0000000..d67d3a4 --- /dev/null +++ b/Tests/UITests/TestAppUITests/BluetoothViewsTests.swift @@ -0,0 +1,75 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import XCTest + + +class BluetoothViewsTests: XCTestCase { + override func setUpWithError() throws { + try super.setUpWithError() + + continueAfterFailure = false + } + + @MainActor + func testBluetoothUnavailableViews() { + let app = XCUIApplication() + app.launch() + + XCTAssert(app.buttons["Views"].waitForExistence(timeout: 2.0)) + app.buttons["Views"].tap() + + func navigateUnavailableView(name: String, expected: String?, back: Bool = true) { + XCTAssert(app.buttons[name].waitForExistence(timeout: 2.0)) + app.buttons[name].tap() + if let expected { + XCTAssert(app.staticTexts[expected].waitForExistence(timeout: 2.0)) + } + if back { + XCTAssert(app.navigationBars.buttons["Views"].exists) + app.navigationBars.buttons["Views"].tap() + } + } + + navigateUnavailableView(name: "Bluetooth Powered On", expected: nil) + navigateUnavailableView(name: "Bluetooth Unauthorized", expected: "Bluetooth Prohibited") + navigateUnavailableView(name: "Bluetooth Unsupported", expected: "Bluetooth Unsupported") + navigateUnavailableView(name: "Bluetooth Unknown", expected: "Bluetooth Failure") + navigateUnavailableView(name: "Bluetooth Powered Off", expected: "Bluetooth Off", back: false) + + XCTAssert(app.buttons["Open Settings"].exists) + app.buttons["Open Settings"].tap() + + let settingsApp = XCUIApplication(bundleIdentifier: "com.apple.Preferences") + XCTAssertEqual(settingsApp.state, .runningForeground) + } + + @MainActor + func testNearbyDeviceRow() { + let app = XCUIApplication() + app.launch() + + XCTAssert(app.buttons["Views"].waitForExistence(timeout: 2.0)) + app.buttons["Views"].tap() + + XCTAssert(app.staticTexts["DEVICES"].exists) + + XCTAssert(app.buttons["Mock Device"].exists) + app.buttons["Mock Device"].tap() + + XCTAssert(app.buttons["Mock Device, Connected"].waitForExistence(timeout: 5.0)) + XCTAssert(app.buttons["Device Details"].exists) + app.buttons["Device Details"].tap() + + XCTAssert(app.navigationBars.staticTexts["Mock Device"].waitForExistence(timeout: 2.0)) + XCTAssert(app.staticTexts["Name, Mock Device"].exists) + XCTAssert(app.staticTexts["Model, MD1"].exists) + XCTAssert(app.staticTexts["Firmware Version, 1.0"].exists) + XCTAssert(app.staticTexts["Battery, 85 %"].exists) + } +} diff --git a/Tests/UITests/TestAppUITests/HealthMeasurementsTests.swift b/Tests/UITests/TestAppUITests/HealthMeasurementsTests.swift new file mode 100644 index 0000000..94442e2 --- /dev/null +++ b/Tests/UITests/TestAppUITests/HealthMeasurementsTests.swift @@ -0,0 +1,152 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import XCTest + + +class HealthMeasurementsTests: XCTestCase { + override func setUpWithError() throws { + try super.setUpWithError() + + continueAfterFailure = false + } + + + @MainActor + func testNoMeasurements() { + let app = XCUIApplication() + app.launch() + + XCTAssert(app.buttons["Measurements"].waitForExistence(timeout: 2.0)) + app.buttons["Measurements"].tap() + + XCTAssert(app.staticTexts["No Samples"].waitForExistence(timeout: 0.5)) + app.staticTexts["No Samples"].tap() + + XCTAssert(app.navigationBars.buttons["Add Measurement"].exists) + app.navigationBars.buttons["Add Measurement"].tap() + + XCTAssert(app.staticTexts["No Pending Measurements"].waitForExistence(timeout: 2.0)) + XCTAssert(app.navigationBars.buttons["Dismiss"].exists) + app.navigationBars.buttons["Dismiss"].tap() + } + + @MainActor + func testWeightMeasurement() { + let app = XCUIApplication() + app.launch() + + XCTAssert(app.buttons["Measurements"].waitForExistence(timeout: 2.0)) + app.buttons["Measurements"].tap() + + XCTAssert(app.navigationBars.buttons["More"].exists) + app.navigationBars.buttons["More"].tap() + XCTAssert(app.buttons["Simulate Weight"].waitForExistence(timeout: 0.5)) + app.buttons["Simulate Weight"].tap() + + XCTAssert(app.staticTexts["Measurement Recorded"].waitForExistence(timeout: 2.0)) + XCTAssert(app.staticTexts["42 kg"].exists) + XCTAssert(app.staticTexts["179 cm, 23 BMI"].exists) + XCTAssert(app.buttons["Save"].exists) + XCTAssert(app.buttons["Discard"].exists) + + app.buttons["Save"].tap() + + XCTAssert(app.staticTexts["42 kg"].waitForExistence(timeout: 0.5)) + XCTAssert(app.staticTexts["23 count"].exists) + XCTAssert(app.staticTexts["1.79 m"].exists) + XCTAssert(app.staticTexts["Mock Device"].exists) + } + + @MainActor + func testBloodPressureMeasurement() { + let app = XCUIApplication() + app.launch() + + XCTAssert(app.buttons["Measurements"].waitForExistence(timeout: 2.0)) + app.buttons["Measurements"].tap() + + XCTAssert(app.navigationBars.buttons["More"].exists) + app.navigationBars.buttons["More"].tap() + XCTAssert(app.buttons["Simulate Blood Pressure"].waitForExistence(timeout: 0.5)) + app.buttons["Simulate Blood Pressure"].tap() + + XCTAssert(app.staticTexts["Measurement Recorded"].waitForExistence(timeout: 2.0)) + XCTAssert(app.staticTexts["103/64 mmHg"].exists) + XCTAssert(app.staticTexts["62 BPM"].exists) + XCTAssert(app.buttons["Save"].exists) + XCTAssert(app.buttons["Discard"].exists) + + app.buttons["Save"].tap() + + XCTAssert(app.staticTexts["103 mmHg"].waitForExistence(timeout: 0.5)) + XCTAssert(app.staticTexts["64 mmHg"].exists) + XCTAssert(app.staticTexts["62 count/min"].exists) + XCTAssert(app.staticTexts["Mock Device"].exists) + } + + @MainActor + func testMultiMeasurementsAndDiscarding() throws { + let app = XCUIApplication() + app.launch() + + XCTAssert(app.buttons["Measurements"].waitForExistence(timeout: 2.0)) + app.buttons["Measurements"].tap() + + XCTAssert(app.navigationBars.buttons["More"].exists) + app.navigationBars.buttons["More"].tap() + XCTAssert(app.buttons["Simulate Weight"].waitForExistence(timeout: 0.5)) + app.buttons["Simulate Weight"].tap() + + XCTAssert(app.navigationBars.buttons["Dismiss"].waitForExistence(timeout: 0.5)) + app.navigationBars.buttons["Dismiss"].tap() + + XCTAssert(app.navigationBars.buttons["More"].exists) + app.navigationBars.buttons["More"].tap() + XCTAssert(app.buttons["Simulate Blood Pressure"].waitForExistence(timeout: 0.5)) + app.buttons["Simulate Blood Pressure"].tap() + + XCTAssert(app.staticTexts["Measurement Recorded"].waitForExistence(timeout: 2.0)) + XCTAssert(app.staticTexts["103/64 mmHg"].exists) + XCTAssert(app.staticTexts["62 BPM"].exists) + XCTAssert(app.buttons["Save"].exists) + XCTAssert(app.buttons["Discard"].exists) + + XCTAssert(app.pageIndicators.firstMatch.exists) + let page1Value = try XCTUnwrap(app.pageIndicators.firstMatch.value as? String, "Unexpected value \(String(describing: app.steppers["Page"].value))") + XCTAssertEqual(page1Value, "page 1 of 2") + app.pageIndicators.firstMatch.coordinate(withNormalizedOffset: .init(dx: 0.8, dy: 0.5)).tap() + let page2Value = try XCTUnwrap(app.pageIndicators.firstMatch.value as? String, "Unexpected value \(String(describing: app.steppers["Page"].value))") + XCTAssertEqual(page2Value, "page 2 of 2") + + XCTAssert(app.staticTexts["Measurement Recorded"].waitForExistence(timeout: 2.0)) + XCTAssert(app.staticTexts["42 kg"].exists) + XCTAssert(app.staticTexts["179 cm, 23 BMI"].exists) + XCTAssert(app.buttons["Save"].exists) + XCTAssert(app.buttons["Discard"].exists) + + XCTAssert(app.navigationBars.buttons["Dismiss"].exists) + app.navigationBars.buttons["Dismiss"].tap() + + XCTAssert(app.navigationBars.buttons["Add Measurement"].waitForExistence(timeout: 0.5)) + app.navigationBars.buttons["Add Measurement"].tap() + + XCTAssert(app.buttons["Discard"].waitForExistence(timeout: 0.5)) + app.buttons["Discard"].tap() + + XCTAssert(app.staticTexts["42 kg"].waitForExistence(timeout: 2.0)) + XCTAssert(app.staticTexts["179 cm, 23 BMI"].exists) + + XCTAssert(app.buttons["Discard"].waitForExistence(timeout: 0.5)) + app.buttons["Discard"].tap() + + XCTAssert(app.staticTexts["No Pending Measurements"].waitForExistence(timeout: 2.0)) + XCTAssert(app.navigationBars.buttons["Dismiss"].exists) + app.navigationBars.buttons["Dismiss"].tap() + } +} diff --git a/Tests/UITests/TestAppUITests/PairedDevicesTests.swift b/Tests/UITests/TestAppUITests/PairedDevicesTests.swift new file mode 100644 index 0000000..7eef0b6 --- /dev/null +++ b/Tests/UITests/TestAppUITests/PairedDevicesTests.swift @@ -0,0 +1,197 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import XCTest +import XCTestExtensions + + +class PairedDevicesTests: XCTestCase { + override func setUpWithError() throws { + try super.setUpWithError() + + continueAfterFailure = false + } + + + @MainActor + func testTipsView() throws { + let app = XCUIApplication() + app.launchArguments = ["--testTips"] + app.launch() + + XCTAssert(app.buttons["Devices"].waitForExistence(timeout: 2.0)) + app.buttons["Devices"].tap() + + + XCTAssert(app.staticTexts["Fully Unpair Device"].waitForExistence(timeout: 0.5)) + XCTAssert(app.buttons["Open Settings"].exists) + app.buttons["Open Settings"].tap() + + let settingsApp = XCUIApplication(bundleIdentifier: "com.apple.Preferences") + XCTAssertEqual(settingsApp.state, .runningForeground) + } + + @MainActor + func testDiscoveringView() throws { + let app = XCUIApplication() + app.launch() + + XCTAssert(app.buttons["Devices"].waitForExistence(timeout: 2.0)) + app.buttons["Devices"].tap() + + + XCTAssert(app.staticTexts["No Devices"].exists) + XCTAssert(app.buttons["Pair New Device"].exists) + app.buttons["Pair New Device"].tap() + + XCTAssert(app.staticTexts["Discovering"].waitForExistence(timeout: 0.5)) + XCTAssert(app.staticTexts["Enable pairing mode on the device."].exists) + XCTAssert(app.navigationBars.buttons["Dismiss"].exists) + app.navigationBars.buttons["Dismiss"].tap() + + XCTAssert(app.staticTexts["No Devices"].waitForExistence(timeout: 0.5)) + } + + @MainActor + func testPairDevice() throws { // swiftlint:disable:this function_body_length + let app = XCUIApplication() + app.launch() + + XCTAssert(app.buttons["Devices"].waitForExistence(timeout: 2.0)) + app.buttons["Devices"].tap() + + XCTAssert(app.navigationBars.buttons["More"].exists) + app.navigationBars.buttons["More"].tap() + + XCTAssert(app.buttons["Discover Device"].waitForExistence(timeout: 0.5)) + app.buttons["Discover Device"].tap() + + XCTAssert(app.staticTexts["Pair Accessory"].waitForExistence(timeout: 2.0)) + XCTAssert(app.staticTexts["Do you want to pair \"Mock Device\" with the TestApp app?"].exists) + XCTAssert(app.buttons["Pair"].exists) + app.buttons["Pair"].tap() + + XCTAssert(app.staticTexts["Accessory Paired"].waitForExistence(timeout: 5.0)) + XCTAssert(app.staticTexts["\"Mock Device\" was successfully paired with the TestApp app."].exists) + XCTAssert(app.buttons["Done"].exists) + app.buttons["Done"].tap() + + XCTAssert(app.buttons["Mock Device, 85 %"].waitForExistence(timeout: 0.5)) + app.buttons["Mock Device, 85 %"].tap() + + XCTAssert(app.navigationBars.staticTexts["Device Details"].waitForExistence(timeout: 2.0)) + XCTAssert(app.buttons["Name, Mock Device"].exists) + XCTAssert(app.staticTexts["Model, MD1"].exists) + XCTAssert(app.staticTexts["Battery, 85 %"].exists) + XCTAssert(app.buttons["Forget This Device"].exists) + XCTAssert(app.staticTexts["Synchronizing ..."].exists) // assert device currently connected + + app.buttons["Name, Mock Device"].tap() + + XCTAssert(app.textFields["enter device name"].exists) + app.textFields["enter device name"].tap() + app.typeText("2") + + app.dismissKeyboard() + + XCTAssert(app.navigationBars.buttons["Done"].waitForExistence(timeout: 0.5)) + app.navigationBars.buttons["Done"].tap() + + XCTAssert(app.staticTexts["Name, Mock Device2"].waitForExistence(timeout: 0.5)) + XCTAssert(app.navigationBars.buttons["Devices"].exists) + app.navigationBars.buttons["Devices"].tap() + + XCTAssert(app.buttons["Mock Device2, 85 %"].waitForExistence(timeout: 2.0)) + + XCTAssert(app.navigationBars.buttons["More"].exists) + app.navigationBars.buttons["More"].tap() + XCTAssert(app.buttons["Disconnect"].waitForExistence(timeout: 0.5)) + app.buttons["Disconnect"].tap() + sleep(1) + + app.buttons["Mock Device2, 85 %"].tap() + XCTAssert(app.navigationBars.buttons["Devices"].waitForExistence(timeout: 2.0)) + app.navigationBars.buttons["Devices"].tap() + + XCTAssert(app.navigationBars.buttons["More"].exists) + app.navigationBars.buttons["More"].tap() + XCTAssert(app.buttons["Connect"].waitForExistence(timeout: 0.5)) + app.buttons["Connect"].tap() + sleep(3) + + + XCTAssert(app.buttons["Mock Device2, 85 %"].waitForExistence(timeout: 0.5)) + app.buttons["Mock Device2, 85 %"].tap() + + XCTAssert(app.buttons["Forget This Device"].waitForExistence(timeout: 2.0)) + app.buttons["Forget This Device"].tap() + + XCTAssert(app.buttons["Forget Device"].waitForExistence(timeout: 2.0)) + app.buttons["Forget Device"].tap() + + + XCTAssert(app.staticTexts["Fully Unpair Device"].waitForExistence(timeout: 2.0)) + } + + @MainActor + func testPlusButton() { + let app = XCUIApplication() + app.launch() + + XCTAssert(app.buttons["Devices"].waitForExistence(timeout: 2.0)) + app.buttons["Devices"].tap() + + XCTAssert(app.navigationBars.buttons["More"].exists) + app.navigationBars.buttons["More"].tap() + + XCTAssert(app.buttons["Discover Device"].waitForExistence(timeout: 0.5)) + app.buttons["Discover Device"].tap() + + XCTAssert(app.staticTexts["Pair Accessory"].waitForExistence(timeout: 2.0)) + XCTAssert(app.buttons["Dismiss"].exists) + app.buttons["Dismiss"].tap() + + XCTAssert(app.navigationBars.buttons["Add Device"].exists) + app.navigationBars.buttons["Add Device"].tap() + + XCTAssert(app.staticTexts["Pair Accessory"].waitForExistence(timeout: 2.0)) + } + + @MainActor + func testPairingFailed() { + let app = XCUIApplication() + app.launch() + + XCTAssert(app.buttons["Devices"].waitForExistence(timeout: 2.0)) + app.buttons["Devices"].tap() + + XCTAssert(app.navigationBars.buttons["More"].exists) + app.navigationBars.buttons["More"].tap() + XCTAssert(app.buttons["Connect"].exists) + app.buttons["Connect"].tap() + + XCTAssert(app.navigationBars.buttons["More"].exists) + app.navigationBars.buttons["More"].tap() + XCTAssert(app.buttons["Discover Device"].exists) + app.buttons["Discover Device"].tap() + + XCTAssert(app.staticTexts["Pair Accessory"].waitForExistence(timeout: 2.0)) + XCTAssert(app.buttons["Pair"].exists) + app.buttons["Pair"].tap() + + XCTAssert(app.staticTexts["Pairing Failed"].waitForExistence(timeout: 2.0)) + XCTAssert(app.staticTexts["Failed to pair with device. Please try again."].exists) + XCTAssert(app.buttons["OK"].exists) + app.buttons["OK"].tap() + + XCTAssert(app.navigationBars.buttons["Add Device"].waitForExistence(timeout: 0.5)) + app.navigationBars.buttons["Add Device"].tap() + + XCTAssert(app.staticTexts["Pair Accessory"].waitForExistence(timeout: 2.0)) + } +} diff --git a/Tests/UITests/TestAppUITests/TestAppUITests.swift b/Tests/UITests/TestAppUITests/TestAppUITests.swift deleted file mode 100644 index d422843..0000000 --- a/Tests/UITests/TestAppUITests/TestAppUITests.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// This source file is part of the TemplatePackage open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import XCTest - - -class TestAppUITests: XCTestCase { - override func setUpWithError() throws { - try super.setUpWithError() - - continueAfterFailure = false - } - - - func testTemplatePackage() throws { - let app = XCUIApplication() - app.launch() - XCTAssert(app.staticTexts["Stanford University"].waitForExistence(timeout: 0.1)) - } -} diff --git a/Tests/UITests/TestAppWatchApp.xctestplan b/Tests/UITests/TestAppWatchApp.xctestplan deleted file mode 100644 index 6f6f2dd..0000000 --- a/Tests/UITests/TestAppWatchApp.xctestplan +++ /dev/null @@ -1,37 +0,0 @@ -{ - "configurations" : [ - { - "id" : "B8537494-39D3-45EC-98D4-B3C417844ADD", - "name" : "Default", - "options" : { - - } - } - ], - "defaultOptions" : { - "codeCoverage" : { - "targets" : [ - { - "containerPath" : "container:..\/..", - "identifier" : "TemplatePackage", - "name" : "TemplatePackage" - } - ] - }, - "targetForVariableExpansion" : { - "containerPath" : "container:UITests.xcodeproj", - "identifier" : "2F9CBEA52A76C40E009818FF", - "name" : "TestAppWatchApp" - } - }, - "testTargets" : [ - { - "target" : { - "containerPath" : "container:UITests.xcodeproj", - "identifier" : "2F9CBEBE2A76C412009818FF", - "name" : "TestAppWatchAppUITests" - } - } - ], - "version" : 1 -} diff --git a/Tests/UITests/TestAppWatchApp.xctestplan.license b/Tests/UITests/TestAppWatchApp.xctestplan.license index d77e33d..7f16969 100644 --- a/Tests/UITests/TestAppWatchApp.xctestplan.license +++ b/Tests/UITests/TestAppWatchApp.xctestplan.license @@ -1,4 +1,4 @@ -This source file is part of the TemplatePackage open-source project +This source file is part of the Stanford Spezi open-source project SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index 25c3d4a..f69e7cb 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -7,15 +7,23 @@ objects = { /* Begin PBXBuildFile section */ - 2F68C3C8292EA52000B3E12C /* TemplatePackage in Frameworks */ = {isa = PBXBuildFile; productRef = 2F68C3C7292EA52000B3E12C /* TemplatePackage */; }; + 2F68C3C8292EA52000B3E12C /* SpeziDevices in Frameworks */ = {isa = PBXBuildFile; productRef = 2F68C3C7292EA52000B3E12C /* SpeziDevices */; }; 2F6D139A28F5F386007C25D6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2F6D139928F5F386007C25D6 /* Assets.xcassets */; }; - 2F8A431329130A8C005D2B8F /* TestAppUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F8A431229130A8C005D2B8F /* TestAppUITests.swift */; }; - 2F9CBEC92A76C412009818FF /* TestApp Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 2F9CBEA62A76C40E009818FF /* TestApp Watch App.app */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - 2F9CBED72A76C752009818FF /* TestApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA7382B290ADFAA007ACEB9 /* TestApp.swift */; }; - 2F9CBED82A76C75E009818FF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2F6D139928F5F386007C25D6 /* Assets.xcassets */; }; - 2F9CBEDA2A76C795009818FF /* TemplatePackage in Frameworks */ = {isa = PBXBuildFile; productRef = 2F9CBED92A76C795009818FF /* TemplatePackage */; }; - 2F9CBEDB2A76C7EC009818FF /* TestAppUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F8A431229130A8C005D2B8F /* TestAppUITests.swift */; }; + 2F8A431329130A8C005D2B8F /* HealthMeasurementsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F8A431229130A8C005D2B8F /* HealthMeasurementsTests.swift */; }; 2FA7382C290ADFAA007ACEB9 /* TestApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA7382B290ADFAA007ACEB9 /* TestApp.swift */; }; + A922BB1A2C2CB072009DD0E1 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A922BB192C2CB072009DD0E1 /* ContentView.swift */; }; + A922BB1C2C2CB0AB009DD0E1 /* SpeziDevicesUI in Frameworks */ = {isa = PBXBuildFile; productRef = A922BB1B2C2CB0AB009DD0E1 /* SpeziDevicesUI */; }; + A922BB1E2C2CB276009DD0E1 /* DevicesTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A922BB1D2C2CB276009DD0E1 /* DevicesTestView.swift */; }; + A922BB202C2CB280009DD0E1 /* MeasurementsTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A922BB1F2C2CB280009DD0E1 /* MeasurementsTestView.swift */; }; + A959B7E62C2CBF0900ACA775 /* HKSampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A959B7E52C2CBF0900ACA775 /* HKSampleView.swift */; }; + A959B7E82C2CBF1400ACA775 /* HKQuantitySampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A959B7E72C2CBF1400ACA775 /* HKQuantitySampleView.swift */; }; + A959B7EB2C2CC05A00ACA775 /* HKCorrelationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A959B7EA2C2CC05A00ACA775 /* HKCorrelationView.swift */; }; + A959B7F02C2D602C00ACA775 /* PairedDevicesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A959B7EF2C2D602C00ACA775 /* PairedDevicesTests.swift */; }; + A959B7F32C2D646500ACA775 /* XCTestExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = A959B7F22C2D646500ACA775 /* XCTestExtensions */; }; + A959B7F52C2D72A500ACA775 /* BluetoothViewsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A959B7F42C2D72A500ACA775 /* BluetoothViewsTest.swift */; }; + A959B7F82C2D75B400ACA775 /* BluetoothUnavailableSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A959B7F72C2D75B400ACA775 /* BluetoothUnavailableSection.swift */; }; + A959B7FA2C2D75F300ACA775 /* MockDeviceDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A959B7F92C2D75F300ACA775 /* MockDeviceDetailsView.swift */; }; + A959B7FC2C2D769A00ACA775 /* BluetoothViewsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A959B7FB2C2D769A00ACA775 /* BluetoothViewsTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -26,27 +34,6 @@ remoteGlobalIDString = 2F6D139128F5F384007C25D6; remoteInfo = Example; }; - 2F9CBEC02A76C412009818FF /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 2F6D138A28F5F384007C25D6 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 2F9CBEA52A76C40E009818FF; - remoteInfo = "TestAppWatchOS Watch App"; - }; - 2F9CBEC72A76C412009818FF /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 2F6D138A28F5F384007C25D6 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 2F9CBEA52A76C40E009818FF; - remoteInfo = "TestAppWatchOS Watch App"; - }; - 2FF8922F2A770DE200903A5A /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 2F6D138A28F5F384007C25D6 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 2F6D139128F5F384007C25D6; - remoteInfo = TestApp; - }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -56,7 +43,6 @@ dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; dstSubfolderSpec = 16; files = ( - 2F9CBEC92A76C412009818FF /* TestApp Watch App.app in Embed Watch Content */, ); name = "Embed Watch Content"; runOnlyForDeploymentPostprocessing = 0; @@ -64,16 +50,24 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 2F68C3C6292E9F8F00B3E12C /* TemplatePackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = TemplatePackage; path = ../..; sourceTree = ""; }; + 2F68C3C6292E9F8F00B3E12C /* SpeziDevices */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = SpeziDevices; path = ../..; sourceTree = ""; }; 2F6D139228F5F384007C25D6 /* TestApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TestApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 2F6D139928F5F386007C25D6 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 2F6D13AC28F5F386007C25D6 /* TestAppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TestAppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 2F8A431229130A8C005D2B8F /* TestAppUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAppUITests.swift; sourceTree = ""; }; - 2F9CBEA62A76C40E009818FF /* TestApp Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "TestApp Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; }; - 2F9CBEBF2A76C412009818FF /* TestAppWatchAppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TestAppWatchAppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 2F8A431229130A8C005D2B8F /* HealthMeasurementsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthMeasurementsTests.swift; sourceTree = ""; }; 2FA7382B290ADFAA007ACEB9 /* TestApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestApp.swift; sourceTree = ""; }; 2FB0758A299DDB9000C0B37F /* TestApp.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestApp.xctestplan; sourceTree = ""; }; - 2FF8922E2A770D4200903A5A /* TestAppWatchApp.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestAppWatchApp.xctestplan; sourceTree = ""; }; + A922BB192C2CB072009DD0E1 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + A922BB1D2C2CB276009DD0E1 /* DevicesTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevicesTestView.swift; sourceTree = ""; }; + A922BB1F2C2CB280009DD0E1 /* MeasurementsTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeasurementsTestView.swift; sourceTree = ""; }; + A959B7E52C2CBF0900ACA775 /* HKSampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKSampleView.swift; sourceTree = ""; }; + A959B7E72C2CBF1400ACA775 /* HKQuantitySampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKQuantitySampleView.swift; sourceTree = ""; }; + A959B7EA2C2CC05A00ACA775 /* HKCorrelationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKCorrelationView.swift; sourceTree = ""; }; + A959B7EF2C2D602C00ACA775 /* PairedDevicesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PairedDevicesTests.swift; sourceTree = ""; }; + A959B7F42C2D72A500ACA775 /* BluetoothViewsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothViewsTest.swift; sourceTree = ""; }; + A959B7F72C2D75B400ACA775 /* BluetoothUnavailableSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothUnavailableSection.swift; sourceTree = ""; }; + A959B7F92C2D75F300ACA775 /* MockDeviceDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDeviceDetailsView.swift; sourceTree = ""; }; + A959B7FB2C2D769A00ACA775 /* BluetoothViewsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothViewsTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -81,7 +75,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 2F68C3C8292EA52000B3E12C /* TemplatePackage in Frameworks */, + A922BB1C2C2CB0AB009DD0E1 /* SpeziDevicesUI in Frameworks */, + 2F68C3C8292EA52000B3E12C /* SpeziDevices in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -89,21 +84,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 2F9CBEA32A76C40E009818FF /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 2F9CBEDA2A76C795009818FF /* TemplatePackage in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 2F9CBEBC2A76C412009818FF /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( + A959B7F32C2D646500ACA775 /* XCTestExtensions in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -114,8 +95,7 @@ isa = PBXGroup; children = ( 2FB0758A299DDB9000C0B37F /* TestApp.xctestplan */, - 2FF8922E2A770D4200903A5A /* TestAppWatchApp.xctestplan */, - 2F68C3C6292E9F8F00B3E12C /* TemplatePackage */, + 2F68C3C6292E9F8F00B3E12C /* SpeziDevices */, 2F6D139428F5F384007C25D6 /* TestApp */, 2F6D13AF28F5F386007C25D6 /* TestAppUITests */, 2F6D139328F5F384007C25D6 /* Products */, @@ -128,8 +108,6 @@ children = ( 2F6D139228F5F384007C25D6 /* TestApp.app */, 2F6D13AC28F5F386007C25D6 /* TestAppUITests.xctest */, - 2F9CBEA62A76C40E009818FF /* TestApp Watch App.app */, - 2F9CBEBF2A76C412009818FF /* TestAppWatchAppUITests.xctest */, ); name = Products; sourceTree = ""; @@ -137,8 +115,14 @@ 2F6D139428F5F384007C25D6 /* TestApp */ = { isa = PBXGroup; children = ( + A959B7F42C2D72A500ACA775 /* BluetoothViewsTest.swift */, + A922BB192C2CB072009DD0E1 /* ContentView.swift */, + A922BB1D2C2CB276009DD0E1 /* DevicesTestView.swift */, + A922BB1F2C2CB280009DD0E1 /* MeasurementsTestView.swift */, 2FA7382B290ADFAA007ACEB9 /* TestApp.swift */, 2F6D139928F5F386007C25D6 /* Assets.xcassets */, + A959B7E92C2CC04400ACA775 /* Health */, + A959B7F62C2D759700ACA775 /* Views */, ); path = TestApp; sourceTree = ""; @@ -146,7 +130,9 @@ 2F6D13AF28F5F386007C25D6 /* TestAppUITests */ = { isa = PBXGroup; children = ( - 2F8A431229130A8C005D2B8F /* TestAppUITests.swift */, + 2F8A431229130A8C005D2B8F /* HealthMeasurementsTests.swift */, + A959B7EF2C2D602C00ACA775 /* PairedDevicesTests.swift */, + A959B7FB2C2D769A00ACA775 /* BluetoothViewsTests.swift */, ); path = TestAppUITests; sourceTree = ""; @@ -158,6 +144,25 @@ name = Frameworks; sourceTree = ""; }; + A959B7E92C2CC04400ACA775 /* Health */ = { + isa = PBXGroup; + children = ( + A959B7EA2C2CC05A00ACA775 /* HKCorrelationView.swift */, + A959B7E72C2CBF1400ACA775 /* HKQuantitySampleView.swift */, + A959B7E52C2CBF0900ACA775 /* HKSampleView.swift */, + ); + path = Health; + sourceTree = ""; + }; + A959B7F62C2D759700ACA775 /* Views */ = { + isa = PBXGroup; + children = ( + A959B7F72C2D75B400ACA775 /* BluetoothUnavailableSection.swift */, + A959B7F92C2D75F300ACA775 /* MockDeviceDetailsView.swift */, + ); + path = Views; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -173,11 +178,11 @@ buildRules = ( ); dependencies = ( - 2F9CBEC82A76C412009818FF /* PBXTargetDependency */, ); name = TestApp; packageProductDependencies = ( - 2F68C3C7292EA52000B3E12C /* TemplatePackage */, + 2F68C3C7292EA52000B3E12C /* SpeziDevices */, + A922BB1B2C2CB0AB009DD0E1 /* SpeziDevicesUI */, ); productName = Example; productReference = 2F6D139228F5F384007C25D6 /* TestApp.app */; @@ -197,47 +202,11 @@ 2F6D13AE28F5F386007C25D6 /* PBXTargetDependency */, ); name = TestAppUITests; - productName = ExampleUITests; - productReference = 2F6D13AC28F5F386007C25D6 /* TestAppUITests.xctest */; - productType = "com.apple.product-type.bundle.ui-testing"; - }; - 2F9CBEA52A76C40E009818FF /* TestAppWatchApp */ = { - isa = PBXNativeTarget; - buildConfigurationList = 2F9CBECA2A76C412009818FF /* Build configuration list for PBXNativeTarget "TestAppWatchApp" */; - buildPhases = ( - 2F9CBEA22A76C40E009818FF /* Sources */, - 2F9CBEA32A76C40E009818FF /* Frameworks */, - 2F9CBEA42A76C40E009818FF /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = TestAppWatchApp; packageProductDependencies = ( - 2F9CBED92A76C795009818FF /* TemplatePackage */, - ); - productName = "TestAppWatchOS Watch App"; - productReference = 2F9CBEA62A76C40E009818FF /* TestApp Watch App.app */; - productType = "com.apple.product-type.application"; - }; - 2F9CBEBE2A76C412009818FF /* TestAppWatchAppUITests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 2F9CBED32A76C412009818FF /* Build configuration list for PBXNativeTarget "TestAppWatchAppUITests" */; - buildPhases = ( - 2F9CBEBB2A76C412009818FF /* Sources */, - 2F9CBEBC2A76C412009818FF /* Frameworks */, - 2F9CBEBD2A76C412009818FF /* Resources */, + A959B7F22C2D646500ACA775 /* XCTestExtensions */, ); - buildRules = ( - ); - dependencies = ( - 2F9CBEC12A76C412009818FF /* PBXTargetDependency */, - 2FF892302A770DE200903A5A /* PBXTargetDependency */, - ); - name = TestAppWatchAppUITests; - productName = "TestAppWatchOS Watch AppUITests"; - productReference = 2F9CBEBF2A76C412009818FF /* TestAppWatchAppUITests.xctest */; + productName = ExampleUITests; + productReference = 2F6D13AC28F5F386007C25D6 /* TestAppUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; /* End PBXNativeTarget section */ @@ -248,7 +217,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1500; - LastUpgradeCheck = 1500; + LastUpgradeCheck = 1540; TargetAttributes = { 2F6D139128F5F384007C25D6 = { CreatedOnToolsVersion = 14.1; @@ -257,13 +226,6 @@ CreatedOnToolsVersion = 14.1; TestTargetID = 2F6D139128F5F384007C25D6; }; - 2F9CBEA52A76C40E009818FF = { - CreatedOnToolsVersion = 15.0; - }; - 2F9CBEBE2A76C412009818FF = { - CreatedOnToolsVersion = 15.0; - TestTargetID = 2F9CBEA52A76C40E009818FF; - }; }; }; buildConfigurationList = 2F6D138D28F5F384007C25D6 /* Build configuration list for PBXProject "UITests" */; @@ -275,14 +237,15 @@ Base, ); mainGroup = 2F6D138928F5F384007C25D6; + packageReferences = ( + A959B7F12C2D646500ACA775 /* XCRemoteSwiftPackageReference "XCTestExtensions" */, + ); productRefGroup = 2F6D139328F5F384007C25D6 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 2F6D139128F5F384007C25D6 /* TestApp */, 2F6D13AB28F5F386007C25D6 /* TestAppUITests */, - 2F9CBEA52A76C40E009818FF /* TestAppWatchApp */, - 2F9CBEBE2A76C412009818FF /* TestAppWatchAppUITests */, ); }; /* End PBXProject section */ @@ -303,21 +266,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 2F9CBEA42A76C40E009818FF /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 2F9CBED82A76C75E009818FF /* Assets.xcassets in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 2F9CBEBD2A76C412009818FF /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -325,7 +273,16 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A959B7E82C2CBF1400ACA775 /* HKQuantitySampleView.swift in Sources */, + A922BB1A2C2CB072009DD0E1 /* ContentView.swift in Sources */, + A959B7EB2C2CC05A00ACA775 /* HKCorrelationView.swift in Sources */, + A959B7FA2C2D75F300ACA775 /* MockDeviceDetailsView.swift in Sources */, + A959B7F82C2D75B400ACA775 /* BluetoothUnavailableSection.swift in Sources */, + A922BB202C2CB280009DD0E1 /* MeasurementsTestView.swift in Sources */, + A922BB1E2C2CB276009DD0E1 /* DevicesTestView.swift in Sources */, + A959B7E62C2CBF0900ACA775 /* HKSampleView.swift in Sources */, 2FA7382C290ADFAA007ACEB9 /* TestApp.swift in Sources */, + A959B7F52C2D72A500ACA775 /* BluetoothViewsTest.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -333,23 +290,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 2F8A431329130A8C005D2B8F /* TestAppUITests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 2F9CBEA22A76C40E009818FF /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 2F9CBED72A76C752009818FF /* TestApp.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 2F9CBEBB2A76C412009818FF /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 2F9CBEDB2A76C7EC009818FF /* TestAppUITests.swift in Sources */, + A959B7FC2C2D769A00ACA775 /* BluetoothViewsTests.swift in Sources */, + A959B7F02C2D602C00ACA775 /* PairedDevicesTests.swift in Sources */, + 2F8A431329130A8C005D2B8F /* HealthMeasurementsTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -361,22 +304,6 @@ target = 2F6D139128F5F384007C25D6 /* TestApp */; targetProxy = 2F6D13AD28F5F386007C25D6 /* PBXContainerItemProxy */; }; - 2F9CBEC12A76C412009818FF /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 2F9CBEA52A76C40E009818FF /* TestAppWatchApp */; - targetProxy = 2F9CBEC02A76C412009818FF /* PBXContainerItemProxy */; - }; - 2F9CBEC82A76C412009818FF /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - platformFilter = ios; - target = 2F9CBEA52A76C40E009818FF /* TestAppWatchApp */; - targetProxy = 2F9CBEC72A76C412009818FF /* PBXContainerItemProxy */; - }; - 2FF892302A770DE200903A5A /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 2F6D139128F5F384007C25D6 /* TestApp */; - targetProxy = 2FF8922F2A770DE200903A5A /* PBXContainerItemProxy */; - }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -417,6 +344,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -439,6 +367,7 @@ SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; TVOS_DEPLOYMENT_TARGET = 17.0; WATCHOS_DEPLOYMENT_TARGET = 10.0; XROS_DEPLOYMENT_TARGET = 1.0; @@ -482,6 +411,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -497,6 +427,7 @@ SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_STRICT_CONCURRENCY = complete; TVOS_DEPLOYMENT_TARGET = 17.0; VALIDATE_PRODUCT = YES; WATCHOS_DEPLOYMENT_TARGET = 10.0; @@ -527,16 +458,16 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.templatepackage.testapp; + PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezidevices.testapp; PRODUCT_NAME = "$(TARGET_NAME)"; - SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,3,7"; + TARGETED_DEVICE_FAMILY = "1,2,7"; }; name = Debug; }; @@ -563,16 +494,16 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.templatepackage.testapp; + PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezidevices.testapp; PRODUCT_NAME = "$(TARGET_NAME)"; - SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,3,7"; + TARGETED_DEVICE_FAMILY = "1,2,7"; }; name = Release; }; @@ -585,7 +516,7 @@ DEVELOPMENT_TEAM = 637867499T; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.templatepackage.testapp.uitests; + PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezidevices.testapp.testapp.uitests; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE = ""; SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx xros xrsimulator"; @@ -608,7 +539,7 @@ DEVELOPMENT_TEAM = 637867499T; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.templatepackage.testapp.uitests; + PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezidevices.testapp.testapp.uitests; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE = ""; SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx xros xrsimulator"; @@ -622,173 +553,6 @@ }; name = Release; }; - 2F9CBECB2A76C412009818FF /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 637867499T; - ENABLE_PREVIEWS = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - INFOPLIST_KEY_WKCompanionAppBundleIdentifier = edu.stanford.templatepackage.testapp; - INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = YES; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.templatepackage.testapp.watchkitapp; - PRODUCT_NAME = "TestApp Watch App"; - SDKROOT = watchos; - SKIP_INSTALL = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 4; - }; - name = Debug; - }; - 2F9CBECC2A76C412009818FF /* Test */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 637867499T; - ENABLE_PREVIEWS = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - INFOPLIST_KEY_WKCompanionAppBundleIdentifier = edu.stanford.templatepackage.testapp; - INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = YES; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.templatepackage.testapp.watchkitapp; - PRODUCT_NAME = "TestApp Watch App"; - SDKROOT = watchos; - SKIP_INSTALL = YES; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 4; - }; - name = Test; - }; - 2F9CBECD2A76C412009818FF /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 637867499T; - ENABLE_PREVIEWS = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - INFOPLIST_KEY_WKCompanionAppBundleIdentifier = edu.stanford.templatepackage.testapp; - INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = YES; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.templatepackage.testapp.watchkitapp; - PRODUCT_NAME = "TestApp Watch App"; - SDKROOT = watchos; - SKIP_INSTALL = YES; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 4; - }; - name = Release; - }; - 2F9CBED42A76C412009818FF /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 637867499T; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GENERATE_INFOPLIST_FILE = YES; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.templatepackage.testapp.watchkitapp.uitests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = watchos; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 4; - TEST_TARGET_NAME = TestAppWatchApp; - }; - name = Debug; - }; - 2F9CBED52A76C412009818FF /* Test */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 637867499T; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GENERATE_INFOPLIST_FILE = YES; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.templatepackage.testapp.watchkitapp.uitests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = watchos; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 4; - TEST_TARGET_NAME = TestAppWatchApp; - }; - name = Test; - }; - 2F9CBED62A76C412009818FF /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 637867499T; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GENERATE_INFOPLIST_FILE = YES; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.templatepackage.testapp.watchkitapp.uitests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = watchos; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 4; - TEST_TARGET_NAME = TestAppWatchApp; - }; - name = Release; - }; 2FB07587299DDB6000C0B37F /* Test */ = { isa = XCBuildConfiguration; buildSettings = { @@ -826,6 +590,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -848,6 +613,7 @@ SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = TEST; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; TVOS_DEPLOYMENT_TARGET = 17.0; WATCHOS_DEPLOYMENT_TARGET = 10.0; XROS_DEPLOYMENT_TARGET = 1.0; @@ -877,16 +643,16 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.templatepackage.testapp; + PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezidevices.testapp; PRODUCT_NAME = "$(TARGET_NAME)"; - SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,3,7"; + TARGETED_DEVICE_FAMILY = "1,2,7"; }; name = Test; }; @@ -899,7 +665,7 @@ DEVELOPMENT_TEAM = 637867499T; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.templatepackage.testapp.uitests; + PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.spezidevices.testapp.testapp.uitests; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE = ""; SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx xros xrsimulator"; @@ -946,36 +712,32 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 2F9CBECA2A76C412009818FF /* Build configuration list for PBXNativeTarget "TestAppWatchApp" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 2F9CBECB2A76C412009818FF /* Debug */, - 2F9CBECC2A76C412009818FF /* Test */, - 2F9CBECD2A76C412009818FF /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 2F9CBED32A76C412009818FF /* Build configuration list for PBXNativeTarget "TestAppWatchAppUITests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 2F9CBED42A76C412009818FF /* Debug */, - 2F9CBED52A76C412009818FF /* Test */, - 2F9CBED62A76C412009818FF /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; /* End XCConfigurationList section */ +/* Begin XCRemoteSwiftPackageReference section */ + A959B7F12C2D646500ACA775 /* XCRemoteSwiftPackageReference "XCTestExtensions" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/StanfordBDHG/XCTestExtensions.git"; + requirement = { + kind = upToNextMinorVersion; + minimumVersion = 0.4.11; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + /* Begin XCSwiftPackageProductDependency section */ - 2F68C3C7292EA52000B3E12C /* TemplatePackage */ = { + 2F68C3C7292EA52000B3E12C /* SpeziDevices */ = { + isa = XCSwiftPackageProductDependency; + productName = SpeziDevices; + }; + A922BB1B2C2CB0AB009DD0E1 /* SpeziDevicesUI */ = { isa = XCSwiftPackageProductDependency; - productName = TemplatePackage; + productName = SpeziDevicesUI; }; - 2F9CBED92A76C795009818FF /* TemplatePackage */ = { + A959B7F22C2D646500ACA775 /* XCTestExtensions */ = { isa = XCSwiftPackageProductDependency; - productName = TemplatePackage; + package = A959B7F12C2D646500ACA775 /* XCRemoteSwiftPackageReference "XCTestExtensions" */; + productName = XCTestExtensions; }; /* End XCSwiftPackageProductDependency section */ }; diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj.license b/Tests/UITests/UITests.xcodeproj/project.pbxproj.license index d77e33d..7f16969 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj.license +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj.license @@ -1,4 +1,4 @@ -This source file is part of the TemplatePackage open-source project +This source file is part of the Stanford Spezi open-source project SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) diff --git a/Tests/UITests/UITests.xcodeproj/project.xcworkspace/contents.xcworkspacedata.license b/Tests/UITests/UITests.xcodeproj/project.xcworkspace/contents.xcworkspacedata.license index d77e33d..7f16969 100644 --- a/Tests/UITests/UITests.xcodeproj/project.xcworkspace/contents.xcworkspacedata.license +++ b/Tests/UITests/UITests.xcodeproj/project.xcworkspace/contents.xcworkspacedata.license @@ -1,4 +1,4 @@ -This source file is part of the TemplatePackage open-source project +This source file is part of the Stanford Spezi open-source project SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) diff --git a/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist.license b/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist.license index d77e33d..7f16969 100644 --- a/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist.license +++ b/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist.license @@ -1,4 +1,4 @@ -This source file is part of the TemplatePackage open-source project +This source file is part of the Stanford Spezi open-source project SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) diff --git a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme index 2d60ede..4d7654b 100644 --- a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme +++ b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme @@ -1,6 +1,6 @@ @@ -81,6 +81,12 @@ ReferencedContainer = "container:UITests.xcodeproj"> + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestAppWatchApp.xcscheme.license b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestAppWatchApp.xcscheme.license index d77e33d..7f16969 100644 --- a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestAppWatchApp.xcscheme.license +++ b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestAppWatchApp.xcscheme.license @@ -1,4 +1,4 @@ -This source file is part of the TemplatePackage open-source project +This source file is part of the Stanford Spezi open-source project SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md)