From 571c528b5567b349b635e8f2172537c6744515f0 Mon Sep 17 00:00:00 2001 From: Paul Schmiedmayer Date: Tue, 7 Jul 2026 22:55:55 -0700 Subject: [PATCH 01/12] Set up OneSecStanfordStudy package --- .github/workflows/ci.yml | 47 ++ .gitignore | 16 + .spi.yml | 14 + .swiftlint.yml | 20 + CONTRIBUTORS.md | 21 + LICENSE.md | 9 + LICENSES/MIT.txt | 9 + Package.swift | 52 ++ README.md | 122 ++++ REUSE.toml | 12 + .../AnyAsyncSequence.swift | 101 +++ .../HKSampleToFHIRProcessor.swift | 38 + Sources/OneSecStanfordStudy/Integration.swift | 51 ++ .../OneSecStanfordStudy.swift | 182 +++++ .../OneSecStanfordStudyModule.swift | 120 ++++ .../StudySurveySheet.swift | 165 +++++ Sources/OneSecStanfordStudy/WebView.swift | 251 +++++++ .../OneSecStanfordStudyTests.swift | 52 ++ Tests/UITests/TestApp.xctestplan | 37 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 18 + .../TestApp/Assets.xcassets/Contents.json | 6 + Tests/UITests/TestApp/ContentView.swift | 35 + .../TestApp/Resources/alert-confirm-test.html | 82 +++ Tests/UITests/TestApp/StudyButton.swift | 26 + Tests/UITests/TestApp/TestApp.entitlements | 8 + Tests/UITests/TestApp/TestApp.swift | 37 + Tests/UITests/TestApp/TestAppDelegate.swift | 61 ++ .../WebViewAlertAndConfirmTestButton.swift | 27 + .../TestAppUITests/TestAppUITests.swift | 66 ++ .../UITests/UITests.xcodeproj/project.pbxproj | 658 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/xcschemes/TestApp.xcscheme | 109 +++ 34 files changed, 2478 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .spi.yml create mode 100644 .swiftlint.yml create mode 100644 CONTRIBUTORS.md create mode 100644 LICENSE.md create mode 100644 LICENSES/MIT.txt create mode 100644 Package.swift create mode 100644 README.md create mode 100644 REUSE.toml create mode 100644 Sources/OneSecStanfordStudy/AnyAsyncSequence.swift create mode 100644 Sources/OneSecStanfordStudy/HKSampleToFHIRProcessor.swift create mode 100644 Sources/OneSecStanfordStudy/Integration.swift create mode 100644 Sources/OneSecStanfordStudy/OneSecStanfordStudy.swift create mode 100644 Sources/OneSecStanfordStudy/OneSecStanfordStudyModule.swift create mode 100644 Sources/OneSecStanfordStudy/StudySurveySheet.swift create mode 100644 Sources/OneSecStanfordStudy/WebView.swift create mode 100644 Tests/OneSecStanfordStudyTests/OneSecStanfordStudyTests.swift create mode 100644 Tests/UITests/TestApp.xctestplan create mode 100644 Tests/UITests/TestApp/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Tests/UITests/TestApp/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Tests/UITests/TestApp/Assets.xcassets/Contents.json create mode 100644 Tests/UITests/TestApp/ContentView.swift create mode 100644 Tests/UITests/TestApp/Resources/alert-confirm-test.html create mode 100644 Tests/UITests/TestApp/StudyButton.swift create mode 100644 Tests/UITests/TestApp/TestApp.entitlements create mode 100644 Tests/UITests/TestApp/TestApp.swift create mode 100644 Tests/UITests/TestApp/TestAppDelegate.swift create mode 100644 Tests/UITests/TestApp/WebViewTests/WebViewAlertAndConfirmTestButton.swift create mode 100644 Tests/UITests/TestAppUITests/TestAppUITests.swift create mode 100644 Tests/UITests/UITests.xcodeproj/project.pbxproj create mode 100644 Tests/UITests/UITests.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..14e1711 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +# +# This source file is part of the OneSecStanfordStudy open-source project +# +# SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +# +# SPDX-License-Identifier: MIT +# + +name: CI + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + actions: read + contents: read + issues: write + pull-requests: write + +jobs: + swift-package-ci: + name: Swift Package CI + uses: SchmiedmayerLab/.github/.github/workflows/swift-package-ci.yml@v0.2 + secrets: inherit + + all-tests-pass: + name: All tests pass + if: always() + needs: swift-package-ci + runs-on: ubuntu-latest + steps: + - name: Verify CI results + run: | + echo "swift-package-ci = ${{ needs.swift-package-ci.result }}" + if [ "${{ needs.swift-package-ci.result }}" != "success" ]; then + echo "::error::Swift Package CI did not pass." + exit 1 + fi + echo "All required checks passed." diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b566afc --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.DS_Store + +# Swift Package Manager +.build/ +.swiftpm/ +Package.resolved + +# Xcode +DerivedData/ +xcuserdata/ +*.xcuserstate +*.xcscmblueprint + +# SwiftPM/Xcode editor output +.vscode/ + diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 0000000..9399e4a --- /dev/null +++ b/.spi.yml @@ -0,0 +1,14 @@ +# +# This source file is part of the OneSecStanfordStudy open-source project +# +# SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +# +# SPDX-License-Identifier: MIT +# + +version: 1 +builder: + configs: + - platform: ios + documentation_targets: + - OneSecStanfordStudy diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..a8e4a9c --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,20 @@ +# +# This source file is part of the OneSecStanfordStudy open-source project +# +# SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +# +# SPDX-License-Identifier: MIT +# + +excluded: + - .build + - .swiftpm + - .derivedData + - Tests/UITests/.derivedData + +disabled_rules: + - blanket_disable_command + +line_length: + warning: 150 + error: 150 diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md new file mode 100644 index 0000000..be46a92 --- /dev/null +++ b/CONTRIBUTORS.md @@ -0,0 +1,21 @@ + + +OneSecStanfordStudy contributors +==================== + +* [Lukas Kollmer](https://github.com/lukaskollmer) +* [Lennart Fischer](https://github.com/LambdaDigamma) +* [Paul Schmiedmayer](https://github.com/PSchmiedmayer) + + +## Attributions + +This package is based on the original [implementation repository](https://github.com/StanfordBDHG/OneSecStudySpeziIntegration) and [interface repository](https://github.com/StanfordBDHG/OneSecStudySpeziIntegrationInterface), which were published under the MIT License. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..6998b5f --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2022 Stanford University and the project authors (see CONTRIBUTORS.md) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/LICENSES/MIT.txt b/LICENSES/MIT.txt new file mode 100644 index 0000000..7bb8b45 --- /dev/null +++ b/LICENSES/MIT.txt @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2026 Stanford University and the project authors (see CONTRIBUTORS.md) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..84876ea --- /dev/null +++ b/Package.swift @@ -0,0 +1,52 @@ +// swift-tools-version:6.2 + +// +// This source file is part of the OneSecStanfordStudy open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import PackageDescription + +let package = Package( + name: "OneSecStanfordStudy", + platforms: [ + .iOS(.v15) + ], + products: [ + .library(name: "OneSecStanfordStudy", targets: ["OneSecStanfordStudy"]) + ], + dependencies: [ + .package(url: "https://github.com/SchmiedmayerLab/Spezi.git", branch: "oldiOSVersion", traits: []) + ], + targets: [ + .target( + name: "OneSecStanfordStudy", + dependencies: [ + .product(name: "Spezi", package: "Spezi"), + .product(name: "SpeziFoundation", package: "Spezi"), + .product(name: "SpeziHealthKit", package: "Spezi"), + .product(name: "SpeziHealthKitBulkExport", package: "Spezi"), + .product(name: "HealthKitOnFHIR", package: "Spezi"), + .product(name: "SpeziLocalStorage", package: "Spezi") + ], + swiftSettings: [ + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault") + ] + ), + .testTarget( + name: "OneSecStanfordStudyTests", + dependencies: [ + .target(name: "OneSecStanfordStudy") + ], + swiftSettings: [ + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault") + ] + ) + ], + swiftLanguageModes: [.v6] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..70f74d6 --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ + + +# OneSecStanfordStudy + +Stanford study integration module for the one sec app's Digital Interventions Outcome study. + + +## Overview + +This package combines the original interface and implementation packages into one package that depends on the Spezi monorepo. + +The old two-package setup worked around a deployment-target mismatch by dynamically loading the iOS 17 implementation from a separate framework while exposing an iOS 15 interface package. The monorepo-backed version no longer needs that workaround: apps can depend on this single package and link the implementation directly. + + +## Installation + +Add this package to your app and select the `OneSecStanfordStudy` product. The package can be added to app targets that support iOS 15 or newer. The study integration is active on iOS 17 and newer; on older iOS versions, initialization and the root view modifier are no-ops. + +This setup temporarily depends on the Spezi monorepo feature branch that adds the monorepo-backed deployment target support: + +```swift +.package(url: "https://github.com/SchmiedmayerLab/Spezi.git", branch: "oldiOSVersion", traits: []) +``` + +After the Spezi changes are merged and tagged, replace the branch dependency with the tagged `0.x` release range: + +```swift +.package(url: "https://github.com/SchmiedmayerLab/Spezi.git", "0.1.0"..<"0.2.0", traits: []) +``` + +Then add the product dependency to the target that needs it: + +```swift +.target( + name: "MyApp", + dependencies: [ + .product(name: "OneSecStanfordStudy", package: "OneSecStanfordStudy") + ] +) +``` + + +## Usage + +Call `initialize(_:launchOptions:healthExportConfig:)` from your app delegate's `application(_:willFinishLaunchingWithOptions:)` method: + +```swift +import OneSecStanfordStudy +import UIKit + +final class AppDelegate: NSObject, UIApplicationDelegate { + func application( + _ application: UIApplication, + willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + initialize( + application, + launchOptions: launchOptions, + healthExportConfig: HealthExportConfiguration( + destination: healthExportDirectory, + sampleTypes: sampleTypes, + timeRange: timeRange, + didStartExport: { files in + // Upload or process the generated files. + }, + didEndExport: { + // Handle completion. + } + ) + ) + return true + } +} +``` + +Apply `.oneSecStanfordStudy()` to the root of your SwiftUI hierarchy: + +```swift +WindowGroup { + ContentView() + .oneSecStanfordStudy() +} +``` + +The runtime is configured directly and `OneSecStanfordStudyModule` is available through SwiftUI environment injection: + +```swift +@Environment(OneSecStanfordStudyModule.self) private var oneSec +``` + + +## Testing + +The package includes unit tests in `Tests/OneSecStanfordStudyTests` and a consolidated iOS UI test app in `Tests/UITests`. + +Run the UI test app with the `TestApp` scheme in `Tests/UITests/UITests.xcodeproj`. The test app has an iOS 15 deployment target. On iOS 15 and iOS 16, the wrapper launch test validates that initialization and `.oneSecStanfordStudy()` are no-ops. On iOS 17 and newer, the same app validates the active integration and the web view alert/confirm hooks. + + +## Contributing + +Contributions to this project are welcome. Please make sure to read the [contribution guide](https://github.com/SchmiedmayerLab/Spezi/blob/main/Sources/Spezi/Spezi.docc/Contributing%20Guide.md) and the [Contributor Covenant Code of Conduct](https://github.com/SchmiedmayerLab/.github/blob/main/CODE_OF_CONDUCT.md) first. + + +## License + +This project is licensed under the MIT License. See [Licenses](LICENSES) for more information. + + +## Contributors + +The local [CONTRIBUTORS.md](CONTRIBUTORS.md) file records contributors from the original upstream repositories. + +![Stanford and Stanford Medicine logos](https://raw.githubusercontent.com/SchmiedmayerLab/.github/main/assets/stanford-footer-light.png#gh-light-mode-only) +![Stanford and Stanford Medicine logos](https://raw.githubusercontent.com/SchmiedmayerLab/.github/main/assets/stanford-footer-dark.png#gh-dark-mode-only) diff --git a/REUSE.toml b/REUSE.toml new file mode 100644 index 0000000..71703f7 --- /dev/null +++ b/REUSE.toml @@ -0,0 +1,12 @@ +version = 1 + +[[annotations]] +path = [ + ".gitignore", + "Tests/UITests/TestApp/Assets.xcassets/**", + "Tests/UITests/TestApp/TestApp.entitlements", + "Tests/UITests/TestApp.xctestplan", + "Tests/UITests/UITests.xcodeproj/**" +] +SPDX-FileCopyrightText = "2026 Stanford University and the project authors (see CONTRIBUTORS.md)" +SPDX-License-Identifier = "MIT" diff --git a/Sources/OneSecStanfordStudy/AnyAsyncSequence.swift b/Sources/OneSecStanfordStudy/AnyAsyncSequence.swift new file mode 100644 index 0000000..b749a30 --- /dev/null +++ b/Sources/OneSecStanfordStudy/AnyAsyncSequence.swift @@ -0,0 +1,101 @@ +// +// This source file is part of the OneSecStanfordStudy open source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +// swiftlint:disable file_types_order + +// MARK: AnyAsyncSequence + +/// A type-erased wrapper over an `AsyncSequence`. +public struct AnyAsyncSequence: AsyncSequence { + @usableFromInline let makeIterator: () -> AnyAsyncIterator + + /// Creates an `AnyAsyncSequence` that wraps the given `AsyncSequence` + @available(iOS 18.0, *) + @inlinable + public init(_ base: some AsyncSequence) { + makeIterator = { + AnyAsyncIterator(base.makeAsyncIterator()) + } + } + + /// Creates an `AnyAsyncSequence` that wraps the given `AsyncSequence` + @_disfavoredOverload + @inlinable + public init(_ base: S) where S.Element == Element, Failure == any Error { + makeIterator = { + AnyAsyncIterator(base.makeAsyncIterator()) + } + } + + /// Creates an `AnyAsyncSequence` that wraps the given `AsyncSequence` and assumes it will never throw. + /// + /// This initializer will forcibly coerce the input sequence into one of a type whose `Failure` is `Never`. + /// If the sequence does in fact end up throwing an error, that is a programmer error and will result in the program getting terminated. + /// + /// Use this initializer in pre-iOS 18 situations, where `AsyncSequence`'s `Failure` type isn't yet available. + @inlinable + public init(unsafelyAssumingDoesntThrow base: S) where S.Element == Element, Failure == Never { + makeIterator = { + AnyAsyncIterator(unsafelyAssumingDoesntThrow: base.makeAsyncIterator()) + } + } + + @inlinable + public func makeAsyncIterator() -> AnyAsyncIterator { + makeIterator() + } +} + +// MARK: AnyAsyncIterator + +/// A type-erased async iterator. +public struct AnyAsyncIterator: AsyncIteratorProtocol { + @usableFromInline var base: any AsyncIteratorProtocol + + /// Creates an async iterator that wraps a base iterator but whose type depends only on the base iterator's `Element` and `Failure` types. + @available(iOS 18, *) + @inlinable + public init(_ base: some AsyncIteratorProtocol) { + self.base = base + } + + /// Creates an async iterator that wraps a base iterator but whose type depends only on the base iterator's `Element` type. + @_disfavoredOverload + @inlinable + public init(_ base: I) where I.Element == Element, Failure == any Error { + self.base = base + } + + /// Creates an async iterator that wraps a base iterator but whose type depends only on the base + /// iterator's `Element` type, and assumes it will never throw. + /// + /// This initializer will forcibly coerce the input sequence into one of a type whose `Failure` is `Never`. + /// If the sequence does in fact end up throwing an error, that is a programmer error and will result in the program getting terminated. + /// + /// Use this initializer in pre-iOS 18 situations, where `AsyncSequence`'s `Failure` type isn't yet available. + @inlinable + public init(unsafelyAssumingDoesntThrow base: I) where I.Element == Element, Failure == Never { + self.base = base + } + + @inlinable + public mutating func next() async throws -> Element? { + try await base.next() as? Element + } + + @available(iOS 18.0, *) + @inlinable + public mutating func next(isolation actor: isolated (any Actor)?) async throws(Failure) -> Element? { + do { + return try await base.next(isolation: actor) as? Element + } catch { + // SAFETY: our initializers only allow creating `AnyAsyncIterator`s with a matching `Failure` type + throw error as! Failure // swiftlint:disable:this force_cast + } + } +} diff --git a/Sources/OneSecStanfordStudy/HKSampleToFHIRProcessor.swift b/Sources/OneSecStanfordStudy/HKSampleToFHIRProcessor.swift new file mode 100644 index 0000000..1e80392 --- /dev/null +++ b/Sources/OneSecStanfordStudy/HKSampleToFHIRProcessor.swift @@ -0,0 +1,38 @@ +// +// This source file was adapted from the My Heart Counts iOS application. +// +// SPDX-FileCopyrightText: 2026 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import Foundation +private import HealthKitOnFHIR +private import SpeziFoundation +import SpeziHealthKit +import SpeziHealthKitBulkExport + +@available(iOS 17, *) +struct HKSampleToFHIRProcessor: BatchProcessor { + let outputDirectory: URL + + func process(_ samples: consuming [Sample], of sampleType: SampleType) throws -> URL? { + guard !samples.isEmpty else { + return nil + } + return try storeSamples(samples, of: sampleType) + } + + private func storeSamples(_ samples: consuming [Sample], of sampleType: SampleType) throws -> URL { + let resources = try samples.mapIntoResourceProxies() + _ = consume samples + let encoded = try JSONEncoder().encode(resources) + _ = consume resources + let compressed = try encoded.compressed(using: Zlib.self) + _ = consume encoded + let compressedUrl = outputDirectory.appendingPathComponent("\(sampleType.id)_\(UUID().uuidString).json.zlib") + try compressed.write(to: compressedUrl) + _ = consume compressed + return compressedUrl + } +} diff --git a/Sources/OneSecStanfordStudy/Integration.swift b/Sources/OneSecStanfordStudy/Integration.swift new file mode 100644 index 0000000..bd4b75a --- /dev/null +++ b/Sources/OneSecStanfordStudy/Integration.swift @@ -0,0 +1,51 @@ +// +// This source file is part of the OneSecStanfordStudy open source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +public import SwiftUI +public import UIKit + +/// Initializes the Stanford study integration for the current app. +/// +/// This function should be called as part of the parent app's +/// `-[UIApplicationDelegate application:willFinishLaunchingWithOptions:]` method. +/// Once this function has returned, the ``SwiftUICore/View/oneSecStanfordStudy()`` view modifier is +/// available and should be called on the root level of the app's view hierarchy. +/// +/// - Note: If the device is running below iOS 17, this function has no effect. +@MainActor +public func initializeOneSecStanfordStudy( + _ application: UIApplication, + launchOptions: [UIApplication.LaunchOptionsKey: Any]?, // swiftlint:disable:this discouraged_optional_collection + healthExportConfig: HealthExportConfiguration +) { + guard #available(iOS 17, *) else { + return + } + OneSecStanfordStudy.initialize(application: application, launchOptions: launchOptions, healthExportConfig: healthExportConfig) +} + +extension View { + /// Injects the study runtime into the SwiftUI view hierarchy. + /// + /// - Note: This modifier should be called on the root view of the application. + /// If the device is running below iOS 17, this modifier has no effect. + @ViewBuilder + public func oneSecStanfordStudy() -> some View { + if #available(iOS 17, *) { + AnyView(OneSecStanfordStudy.studyIntegrationViewModifier.applying(to: self)) + } else { + self + } + } +} + +extension ViewModifier { + fileprivate func applying(to view: some View) -> some View { + view.modifier(self) + } +} diff --git a/Sources/OneSecStanfordStudy/OneSecStanfordStudy.swift b/Sources/OneSecStanfordStudy/OneSecStanfordStudy.swift new file mode 100644 index 0000000..4bdb7a1 --- /dev/null +++ b/Sources/OneSecStanfordStudy/OneSecStanfordStudy.swift @@ -0,0 +1,182 @@ +// +// This source file is part of the OneSecStanfordStudy open source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +// swiftlint:disable file_types_order + +@_spi(APISupport) import Spezi +private import SpeziHealthKit +private import SpeziHealthKitBulkExport +private import SpeziLocalStorage +import OSLog +import SwiftUI +import UIKit + +/// The OneSecStanfordStudy module. +@available(iOS 17, *) +@Observable +@MainActor +@objc(OneSecStanfordStudy) +final class OneSecStanfordStudy: OneSecStanfordStudyModule, Module, EnvironmentAccessible { + private static var appDelegate: OneSecStanfordStudyAppDelegate? // swiftlint:disable:this weak_delegate + + override static var studyIntegrationViewModifier: any ViewModifier { + struct OneSecStanfordStudyInjectionModifier: ViewModifier { + let runtime: Spezi + func body(content: Content) -> some View { + if let oneSecStanfordStudy = runtime.modules.lazy.compactMap({ $0 as? OneSecStanfordStudy }).first { + // SwiftUI's Environment mechanism seems to be using the static type of the parameter passed to `.environment()`, + // we need to inject the module a second time (the first time being the automatic runtime injection, + // as a result of the module's conformance to EnvironmentAccessible), and we need to explicitly specify the + // static type as that of our base class. + content.environment(oneSecStanfordStudy as OneSecStanfordStudyModule) + } else { + content + } + } + } + guard let runtime = SpeziAppDelegate.spezi else { + preconditionFailure("\(#function) accessed before 'initialize' was called!") + } + return SpeziViewModifier(runtime).concat(OneSecStanfordStudyInjectionModifier(runtime: runtime)) + } + + @ObservationIgnored @Application(\.logger) var logger + @ObservationIgnored @Dependency(HealthKit.self) private var healthKit + @ObservationIgnored @Dependency(BulkHealthExporter.self) private var bulkExporter + @ObservationIgnored @Dependency(LocalStorage.self) private var localStorage + + nonisolated private let healthExportConfig: HealthExportConfiguration + nonisolated(unsafe) private let fileManager = FileManager.default + + /// Creates a new instance of the `OneSecStanfordStudy` module + nonisolated init(healthExportConfig: HealthExportConfiguration) { + self.healthExportConfig = healthExportConfig + } + + override static func initialize( + application: UIApplication, + launchOptions: [UIApplication.LaunchOptionsKey: Any]?, // swiftlint:disable:this discouraged_optional_collection + healthExportConfig: HealthExportConfiguration + ) { + let appDelegate = OneSecStanfordStudyAppDelegate(healthExportConfig: healthExportConfig) + _ = appDelegate.application(application, willFinishLaunchingWithOptions: launchOptions) + self.appDelegate = appDelegate + } + + func configure() { + updateState((try? localStorage.load(.oneSecStanfordStudyState)) ?? .available) + Task { + do { + if try localStorage.load(.didInitiateBulkExport) == true { + // we've initiated the Health Export at some point in the past. + // we now check if it has completed, and, if not, tell it to continue. + let session = try await healthExportSession() + if session.state != .completed { + try await triggerHealthExport(forceSessionReset: false) + } + } + } catch { + logger.error("\(error)") + } + } + } + + override func updateState(_ newState: OneSecStanfordStudyModule.State) { + if newState != state { + try? localStorage.store(newState, for: .oneSecStanfordStudyState) + } + super.updateState(newState) + } + + override func makeOneSecStanfordStudySheet() -> AnyView { + AnyView(StudySurveySheet()) + } + + // MARK: HealthKit Data Collection + + override func triggerHealthExport(forceSessionReset: Bool) async throws { + if forceSessionReset { + try await bulkExporter.deleteSessionRestorationInfo(for: .oneSecStanfordStudy) + } + if !fileManager.itemExists(at: healthExportConfig.destination) { + try fileManager.createDirectory(at: healthExportConfig.destination, withIntermediateDirectories: true) + } + try await healthKit.askForAuthorization(for: .init(read: healthExportConfig.sampleTypes)) + let session = try await healthExportSession() + let stream = try session.start(retryFailedBatches: true) + if #available(iOS 18, *) { + healthExportConfig.didStartExport(AnyAsyncSequence(stream.compactMap(\.self))) + } else { + healthExportConfig.didStartExport(AnyAsyncSequence(unsafelyAssumingDoesntThrow: stream.compactMap(\.self))) + } + _trackCompletion(of: session) + } + + /// Obtains the bulk health export session. + private func healthExportSession() async throws -> some BulkExportSession { + try await bulkExporter.session( + withId: .oneSecStanfordStudy, + for: SampleTypesCollection(healthExportConfig.sampleTypes.compactMap { $0.sampleType }), + startDate: .absolute(healthExportConfig.timeRange.lowerBound), + endDate: healthExportConfig.timeRange.upperBound, + batchSize: .automatic, + using: HKSampleToFHIRProcessor(outputDirectory: healthExportConfig.destination) + ) + } + + private func _trackCompletion(of session: some BulkExportSession) { + let isCompleted = withObservationTracking { + session.state == .completed + } onChange: { + Task { @MainActor in + self._trackCompletion(of: session) + } + } + if isCompleted { + healthExportConfig.didEndExport() + } + } +} + +// MARK: App Delegate and Standard + +@available(iOS 17, *) +private final class OneSecStanfordStudyAppDelegate: SpeziAppDelegate { + private let healthExportConfig: HealthExportConfiguration + + override var configuration: Configuration { + Configuration(standard: OneSecStanfordStudyStandard()) { + HealthKit() + BulkHealthExporter() + OneSecStanfordStudy(healthExportConfig: healthExportConfig) + } + } + + init(healthExportConfig: HealthExportConfiguration) { + self.healthExportConfig = healthExportConfig + } +} + +@available(iOS 17, *) +private actor OneSecStanfordStudyStandard: Standard, HealthKitConstraint { + func handleNewSamples(_ addedSamples: some Collection, ofType sampleType: SampleType) async {} + func handleDeletedObjects(_ deletedObjects: some Collection, ofType sampleType: SampleType) async {} +} + +// MARK: Utils + +@available(iOS 17, *) +extension BulkExportSessionIdentifier { + fileprivate static let oneSecStanfordStudy = Self("edu.stanford.OneSecStanfordStudy") +} + +@available(iOS 17, *) +extension LocalStorageKeys { + fileprivate static let oneSecStanfordStudyState = LocalStorageKey("edu.stanford.OneSecStanfordStudy.state") + fileprivate static let didInitiateBulkExport = LocalStorageKey("edu.stanford.OneSecStanfordStudy.didInitiateBulkExport") +} diff --git a/Sources/OneSecStanfordStudy/OneSecStanfordStudyModule.swift b/Sources/OneSecStanfordStudy/OneSecStanfordStudyModule.swift new file mode 100644 index 0000000..08c8eff --- /dev/null +++ b/Sources/OneSecStanfordStudy/OneSecStanfordStudyModule.swift @@ -0,0 +1,120 @@ +// +// This source file is part of the OneSecStanfordStudy open source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +// swiftlint:disable unavailable_function file_types_order + +public import HealthKit +public import SwiftUI +public import UIKit + +/// The Stanford study integration for the one sec app. +/// +/// ## Topics +/// +/// ### Instance Properties +/// - ``state`` +/// - ``surveyUrl`` +/// +/// ### Instance Methods +/// - ``makeOneSecStanfordStudySheet()`` +@available(iOS 17, *) +@Observable +@MainActor +open class OneSecStanfordStudyModule: NSObject { + public enum State: Int, Hashable, Codable, Sendable { + /// The study is not available for this user. + /// This may occur if the user has been deemed ineligible (e.g., underage) + /// based on information from a previous survey attempt or other eligibility criteria. + case unavailable + /// The study is available but has not yet been initiated. + case available + /// The study was started by the participant, but they indicated being underage. + /// The study is currently on hold until a parent or guardian provides consent. + case awaitingParentalConsent + /// The study is currently active. + case active + /// The study has been completed. + case completed + } + + /// The `ViewModifier` that integrates OneSecStanfordStudy into the SwiftUI view hierarchy. + @_spi(APISupport) // swiftlint:disable:next attributes + open class var studyIntegrationViewModifier: any ViewModifier { + fatalError("implemented in OneSecStanfordStudy") + } + + /// The URL of the survey the user should fill out in order to enroll in the study. + /// + /// This URL should be constructed by the app, based on the survey and the token obtained from REDCap. + public var surveyUrl: URL? + + /// The current state of the integration. + public private(set) var state: State = .unavailable + + @_documentation(visibility: internal) + override public nonisolated init() {} + + @_spi(APISupport) + open class func initialize( + application: UIApplication, + launchOptions: [UIApplication.LaunchOptionsKey: Any]?, // swiftlint:disable:this discouraged_optional_collection + healthExportConfig: HealthExportConfiguration + ) { + fatalError("implemented in OneSecStanfordStudy") + } + + open func makeOneSecStanfordStudySheet() -> AnyView { + fatalError("implemented in OneSecStanfordStudy") + } + + @_spi(APISupport) + open func updateState(_ newState: State) { + state = newState + } + + @_spi(APISupport) + open func triggerHealthExport(forceSessionReset: Bool) async throws { + fatalError("implemented in OneSecStanfordStudy") + } +} + +public struct HealthExportConfiguration: Sendable { + /// Callback that lets the app know that the health export was started. + /// + /// - parameter files: An `AsyncSequence` that will yield the `URL`s of the local files created from the individual export batches. + public typealias DidStartExport = @Sendable @MainActor (_ files: AnyAsyncSequence) -> Void + + /// Callback that lets the app know that the health export has completed. + public typealias DidEndExport = @Sendable @MainActor () -> Void + + /// Directory to which the Health export files should be written. + public let destination: URL + /// The sample types that should be included in the export. + public let sampleTypes: Set + /// The time range for which health samples should be exported. + public let timeRange: Range + /// Callback that will be invoked when the health export is started. + public let didStartExport: DidStartExport + /// Callback that will be invoked when the health export is completed. + public let didEndExport: DidEndExport + + /// Create a new Health Export Configuration. + public init( + destination: URL, + sampleTypes: Set, + timeRange: Range, + didStartExport: @escaping DidStartExport, + didEndExport: @escaping DidEndExport + ) { + self.destination = destination + self.sampleTypes = sampleTypes + self.timeRange = timeRange + self.didStartExport = didStartExport + self.didEndExport = didEndExport + } +} diff --git a/Sources/OneSecStanfordStudy/StudySurveySheet.swift b/Sources/OneSecStanfordStudy/StudySurveySheet.swift new file mode 100644 index 0000000..846e684 --- /dev/null +++ b/Sources/OneSecStanfordStudy/StudySurveySheet.swift @@ -0,0 +1,165 @@ +// +// This source file is part of the OneSecStanfordStudy open source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +private import Spezi +import SwiftUI + +@available(iOS 17, *) +struct StudySurveySheet: View { + @Environment(\.dismiss) private var dismiss + @Environment(OneSecStanfordStudy.self) private var oneSecStanfordStudy + + @State private var didCompleteInitialNavigation = false + @State private var isShowingCancelAlert = false + @State private var isDone = false + @State private var currentPageLoadProgress: Double? + + var body: some View { + if let url = oneSecStanfordStudy.surveyUrl { + NavigationStack { + WebView(url: url, currentProgress: $currentPageLoadProgress) { request in + await shouldNavigate(request) + } didNavigate: { webView in + didCompleteInitialNavigation = true + await didNavigate(webView) + } + .overlay { + if !didCompleteInitialNavigation { + ProgressView("Loading…") + .controlSize(.large) + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if !isDone { + ToolbarItem(placement: .cancellationAction) { + cancelButton + } + } else { + ToolbarItem(placement: .confirmationAction) { + confirmButton + } + } + ToolbarItem(placement: .principal) { + VStack(spacing: 4) { + Text("Stanford Study") + .font(.headline) + if let progress = currentPageLoadProgress { + ProgressView(value: progress) + .progressViewStyle(.linear) + } + } + } + } + } + .interactiveDismissDisabled() + } else { + ContentUnavailableView("MISSING_URL", systemImage: "exclamationmark.triangle") + } + } + + @ViewBuilder private var cancelButton: some View { + Group { + let fallbackButton = Button("Cancel", role: .cancel) { + isShowingCancelAlert = true + } + #if compiler(>=6.2) + if #available(iOS 26, *) { + Button(role: .cancel) { + isShowingCancelAlert = true + } + } else { + fallbackButton + } + #else + fallbackButton + #endif + } + .alert("Cancel Enrollment", isPresented: $isShowingCancelAlert) { + Button("No", role: .cancel) { + isShowingCancelAlert = false + } + Button("Yes", role: .destructive) { + dismiss() + } + } message: { + Text( + """ + Are you sure you want to cancel enrolling in the study? + You can re-enroll at a later time if you feel like it. + """ + ) + } + } + + @ViewBuilder private var confirmButton: some View { + let fallbackButton = Button("Done") { + dismiss() + } + .bold() + #if compiler(>=6.2) + if #available(iOS 26, *) { + Button(role: .confirm) { + dismiss() + } + } else { + fallbackButton + } + #else + fallbackButton + #endif + } + + private func shouldNavigate(_ request: URLRequest) async -> Bool { + guard let url = request.url, url.host() == "one-sec.app" else { + return true + } + let path = url.path() + if path.contains("survey-callback/success") { + oneSecStanfordStudy.updateState(.completed) + } else if path.contains("survey-callback/noteligible") { + oneSecStanfordStudy.updateState(.unavailable) + } else if path.contains("survey-callback/waitingforconsent") { + oneSecStanfordStudy.updateState(.awaitingParentalConsent) + } + isDone = true + dismiss() + return false + } + + private func didNavigate(_ webView: WebViewProxy) async { + if await webView.pageContainsField(named: "healthkit_export_initiated") { + oneSecStanfordStudy.updateState(.active) + await initiateHealthExport() + } + } + + private func initiateHealthExport() async { + do { + try await oneSecStanfordStudy.triggerHealthExport(forceSessionReset: true) + } catch { + // Q how to handle this? (will depend on the specific error. eg for missing permissions we could throw up an alert, etc) + oneSecStanfordStudy.logger.error("Error initiating bulk health export: \(error)") + } + } +} + +@available(iOS 17, *) +extension WebViewProxy { + func pageContainsField(named variableName: String) async -> Bool { + (try? await evaluateJavaScript( + #"document.querySelector('div[data-mlm-field="\#(variableName)"]') !== null"# + ) as? Bool) == true + } + + func pageContainsElement(withId id: String) async -> Bool { + (try? await evaluateJavaScript( + "document.getElementById('\(id)') !== null" + ) as? Bool) == true + } +} diff --git a/Sources/OneSecStanfordStudy/WebView.swift b/Sources/OneSecStanfordStudy/WebView.swift new file mode 100644 index 0000000..cbf03fa --- /dev/null +++ b/Sources/OneSecStanfordStudy/WebView.swift @@ -0,0 +1,251 @@ +// +// This source file is part of the OneSecStanfordStudy open source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +// swiftlint:disable file_types_order + +import SwiftUI +private import WebKit + +@available(iOS 17, *) +struct WebView: View { + typealias ShouldNavigate = @MainActor (URLRequest) async -> Bool + typealias DidNavigate = @MainActor (WebViewProxy) async -> Void + + fileprivate struct Config: Sendable { + let initialUrl: URL + let shouldNavigate: ShouldNavigate + let didNavigate: DidNavigate + } + + fileprivate struct AlertConfig: Sendable { + let message: String + let continuation: CheckedContinuation + } + + fileprivate struct ConfirmationConfig: Sendable { + let message: String + let continuation: CheckedContinuation + } + + private let config: Config + @State private var currentUrl: URL? + @State private var alert: AlertConfig? + @State private var confirmation: ConfirmationConfig? + /// The estimated progress of the current navigation operation, if applicable. Otherwise `nil`. + @Binding private var currentProgress: Double? + + var body: some View { + WebViewImpl( + config: config, + currentUrl: $currentUrl, + currentProgress: $currentProgress, + onAlert: { message in + assert(alert == nil, "Already presenting an alert!") // Q: is this smth we need to worry about (stacked alerts...) + await withCheckedContinuation { continuation in + alert = .init(message: message, continuation: continuation) + } + }, + onConfirm: { message in + assert(confirmation == nil, "Already presenting an alert!") // Q: is this smth we need to worry about (stacked alerts...) + return await withCheckedContinuation { continuation in + confirmation = .init(message: message, continuation: continuation) + } + } + ) + .alert( + currentUrl?.host() ?? "", + isPresented: Binding { + self.alert != nil + } set: { newValue in + // Note that we intentionally ignore `newValue == true` in here! + if !newValue { + self.alert = nil + } + }, + presenting: alert + ) { config in + Button("OK") { + config.continuation.resume() + self.alert = nil + } + } message: { config in + Text(config.message) + } + .alert( + currentUrl?.host() ?? "", + isPresented: Binding { + self.confirmation != nil + } set: { newValue in + // Note that we intentionally ignore `newValue == true` in here! + if !newValue { + self.alert = nil + } + }, + presenting: confirmation + ) { config in + let cancelImp = { + config.continuation.resume(returning: false) + self.confirmation = nil + } + let confirmImp = { + config.continuation.resume(returning: true) + self.confirmation = nil + } + #if compiler(>=6.2) + if #available(iOS 26, *) { + Button("Cancel", role: .cancel, action: cancelImp) + .keyboardShortcut(.cancelAction) + Button("OK", role: .confirm, action: confirmImp) + .keyboardShortcut(.defaultAction) + } else { + Button("Cancel", role: .cancel, action: cancelImp) + .keyboardShortcut(.cancelAction) + Button("OK", action: confirmImp) + .keyboardShortcut(.defaultAction) + } + #else + Button("Cancel", role: .cancel, action: cancelImp) + .keyboardShortcut(.cancelAction) + Button("OK", action: confirmImp) + .keyboardShortcut(.defaultAction) + #endif + } message: { config in + Text(config.message) + } + } + + init( + url: URL, + currentProgress: Binding = .constant(nil), + shouldNavigate: @escaping ShouldNavigate, + didNavigate: @escaping DidNavigate + ) { + config = .init(initialUrl: url, shouldNavigate: shouldNavigate, didNavigate: didNavigate) + _currentProgress = currentProgress + } +} + +@available(iOS 17, *) +@MainActor +struct WebViewProxy { + private let wkWebView: WKWebView + + var url: URL? { + wkWebView.url + } + + fileprivate init(_ wkWebView: WKWebView) { + self.wkWebView = wkWebView + } + + func evaluateJavaScript(_ script: String) async throws -> Any? { + try await wkWebView.evaluateJavaScript(script) + } +} + +// MARK: WebViewImpl + +@available(iOS 17, *) +private struct WebViewImpl: UIViewRepresentable { + private let config: WebView.Config + @Binding private var currentUrl: URL? + @Binding private var currentProgress: Double? + private let onAlert: @MainActor (String) async -> Void + private let onConfirm: @MainActor (String) async -> Bool + + init( + config: WebView.Config, + currentUrl: Binding, + currentProgress: Binding, + onAlert: @MainActor @escaping (String) async -> Void, + onConfirm: @MainActor @escaping (String) async -> Bool, + ) { + self.config = config + _currentUrl = currentUrl + _currentProgress = currentProgress + self.onAlert = onAlert + self.onConfirm = onConfirm + } + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + func makeUIView(context: Context) -> WKWebView { + let coordinator = context.coordinator + let webView = WKWebView(frame: .zero) + webView.navigationDelegate = coordinator + webView.uiDelegate = coordinator + coordinator.kvoObservations.append(webView.observe(\.estimatedProgress, options: [.new]) { [weak coordinator] _, change in + guard let coordinator else { + return + } + MainActor.assumeIsolated { + currentProgress = coordinator.isNavigating ? change.newValue : nil + } + }) + webView.load(URLRequest(url: config.initialUrl)) + return webView + } + + func updateUIView(_ webView: WKWebView, context: Context) { + context.coordinator.parent = self + } +} + +@available(iOS 17, *) +extension WebViewImpl { + @MainActor + fileprivate final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate { + var parent: WebViewImpl + var kvoObservations: [NSKeyValueObservation] = [] + private(set) var isNavigating = false + + init(parent: WebViewImpl) { + self.parent = parent + } + + // MARK: WKNavigationDelegate + + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy { + switch await parent.config.shouldNavigate(navigationAction.request) { + case true: + navigationAction.shouldPerformDownload ? .download : .allow + case false: + .cancel + } + } + + func webView( + _ webView: WKWebView, + didStartProvisionalNavigation navigation: WKNavigation! // swiftlint:disable:this implicitly_unwrapped_optional + ) { + isNavigating = true + parent.currentProgress = 0 + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { // swiftlint:disable:this implicitly_unwrapped_optional + parent.currentUrl = webView.url + isNavigating = false + parent.currentProgress = nil + Task { + await parent.config.didNavigate(WebViewProxy(webView)) + } + } + + // MARK: WKUIDelegate + + func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo) async { + await parent.onAlert(message) + } + + func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo) async -> Bool { + await parent.onConfirm(message) + } + } +} diff --git a/Tests/OneSecStanfordStudyTests/OneSecStanfordStudyTests.swift b/Tests/OneSecStanfordStudyTests/OneSecStanfordStudyTests.swift new file mode 100644 index 0000000..3709362 --- /dev/null +++ b/Tests/OneSecStanfordStudyTests/OneSecStanfordStudyTests.swift @@ -0,0 +1,52 @@ +// +// This source file is part of the OneSecStanfordStudy open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +@testable import OneSecStanfordStudy +import Foundation +import HealthKit +import Testing + +@Suite +struct OneSecStanfordStudyTests { + @Test + func healthExportConfigurationStoresValues() { + let destination = URL(filePath: "/tmp/one-sec-export") + let sampleTypes: Set = [] + let timeRange = Date(timeIntervalSince1970: 0).. { continuation in + continuation.yield(1) + continuation.yield(2) + continuation.yield(3) + continuation.finish() + } + let sequence = AnyAsyncSequence(unsafelyAssumingDoesntThrow: stream) + + var values: [Int] = [] + for try await value in sequence { + values.append(value) + } + + #expect(values == [1, 2, 3]) + } +} diff --git a/Tests/UITests/TestApp.xctestplan b/Tests/UITests/TestApp.xctestplan new file mode 100644 index 0000000..32b4176 --- /dev/null +++ b/Tests/UITests/TestApp.xctestplan @@ -0,0 +1,37 @@ +{ + "configurations" : [ + { + "id" : "074FA9C1-7635-4C64-BF5D-90402604CC46", + "name" : "Default", + "options" : { + + } + } + ], + "defaultOptions" : { + "codeCoverage" : { + "targets" : [ + { + "containerPath" : "container:..\/..", + "identifier" : "OneSecStanfordStudy", + "name" : "OneSecStanfordStudy" + } + ] + }, + "targetForVariableExpansion" : { + "containerPath" : "container:UITests.xcodeproj", + "identifier" : "2F6D139128F5F384007C25D6", + "name" : "TestApp" + } + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:UITests.xcodeproj", + "identifier" : "2F6D13AB28F5F386007C25D6", + "name" : "TestAppUITests" + } + } + ], + "version" : 1 +} diff --git a/Tests/UITests/TestApp/Assets.xcassets/AccentColor.colorset/Contents.json b/Tests/UITests/TestApp/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Tests/UITests/TestApp/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/UITests/TestApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/Tests/UITests/TestApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..330a63e --- /dev/null +++ b/Tests/UITests/TestApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,18 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "idiom" : "universal", + "platform" : "watchos", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/UITests/TestApp/Assets.xcassets/Contents.json b/Tests/UITests/TestApp/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Tests/UITests/TestApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/UITests/TestApp/ContentView.swift b/Tests/UITests/TestApp/ContentView.swift new file mode 100644 index 0000000..fa84997 --- /dev/null +++ b/Tests/UITests/TestApp/ContentView.swift @@ -0,0 +1,35 @@ +// +// This source file is part of the OneSecStanfordStudy open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +@_spi(APISupport) import OneSecStanfordStudy +import SwiftUI + +@available(iOS 17, *) +struct ContentView: View { + @Environment(OneSecStanfordStudyModule.self) private var oneSecStanfordStudy + + var body: some View { + NavigationStack { + Form { + Section { + Text("OneSecStanfordStudy active") + .accessibilityIdentifier("integration-status-active") + StudyButton() + Button("Trigger Health Export") { + Task { + try await oneSecStanfordStudy.triggerHealthExport(forceSessionReset: true) + } + } + } + Section { + WebViewAlertAndConfirmTestButton() + } + } + } + } +} diff --git a/Tests/UITests/TestApp/Resources/alert-confirm-test.html b/Tests/UITests/TestApp/Resources/alert-confirm-test.html new file mode 100644 index 0000000..bc41611 --- /dev/null +++ b/Tests/UITests/TestApp/Resources/alert-confirm-test.html @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KindTriggerStatus
Alert + + Not triggered
Confirm + + + Not triggered
+ + diff --git a/Tests/UITests/TestApp/StudyButton.swift b/Tests/UITests/TestApp/StudyButton.swift new file mode 100644 index 0000000..d968295 --- /dev/null +++ b/Tests/UITests/TestApp/StudyButton.swift @@ -0,0 +1,26 @@ +// +// This source file is part of the OneSecStanfordStudy open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import OneSecStanfordStudy +import SwiftUI + +@available(iOS 17, *) +struct StudyButton: View { + @Environment(OneSecStanfordStudyModule.self) private var oneSecStanfordStudy + @State private var isShowingSheet = false + + var body: some View { + Button("Initiate Flow") { + oneSecStanfordStudy.surveyUrl = try? URL("https://redcap.stanford.edu/surveys/?s=R73YCP9CTL9MDYAW", strategy: .url) + isShowingSheet = true + } + .sheet(isPresented: $isShowingSheet) { + oneSecStanfordStudy.makeOneSecStanfordStudySheet() + } + } +} diff --git a/Tests/UITests/TestApp/TestApp.entitlements b/Tests/UITests/TestApp/TestApp.entitlements new file mode 100644 index 0000000..e10f430 --- /dev/null +++ b/Tests/UITests/TestApp/TestApp.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.developer.healthkit + + + diff --git a/Tests/UITests/TestApp/TestApp.swift b/Tests/UITests/TestApp/TestApp.swift new file mode 100644 index 0000000..4eac821 --- /dev/null +++ b/Tests/UITests/TestApp/TestApp.swift @@ -0,0 +1,37 @@ +// +// This source file is part of the OneSecStanfordStudy open-source project +// +// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import OneSecStanfordStudy +import SwiftUI + +@main +struct UITestsApp: App { + @UIApplicationDelegateAdaptor private var delegate: TestAppDelegate + + var body: some Scene { + WindowGroup { + rootContent + .oneSecStanfordStudy() + } + } + + @ViewBuilder private var rootContent: some View { + if #available(iOS 17, *) { + ContentView() + } else { + VStack(spacing: 12) { + Text("OneSecStanfordStudy inactive") + .font(.headline) + .accessibilityIdentifier("integration-status-inactive") + Text("The iOS 15 compatibility wrapper loaded successfully.") + .multilineTextAlignment(.center) + } + .padding() + } + } +} diff --git a/Tests/UITests/TestApp/TestAppDelegate.swift b/Tests/UITests/TestApp/TestAppDelegate.swift new file mode 100644 index 0000000..128ad58 --- /dev/null +++ b/Tests/UITests/TestApp/TestAppDelegate.swift @@ -0,0 +1,61 @@ +// +// This source file is part of the OneSecStanfordStudy open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import HealthKit +import OneSecStanfordStudy +import SwiftUI + +final class TestAppDelegate: NSObject, UIApplicationDelegate { + private var sampleTypes: Set { + var types: Set = [ + HKQuantityType(.stepCount), + HKCategoryType(.sleepAnalysis) + ] + if #available(iOS 18.0, *) { + types.insert(.stateOfMindType()) + } + return types + } + + func application( + _ application: UIApplication, + willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? // swiftlint:disable:this discouraged_optional_collection + ) -> Bool { + let calendar = Calendar.current + initializeOneSecStanfordStudy( + application, + launchOptions: launchOptions, + healthExportConfig: .init( + destination: FileManager.default.temporaryDirectory, + sampleTypes: sampleTypes, + timeRange: calendar.date(byAdding: .year, value: -1, to: .now)!..) { + Task { + do { + for try await url in urls { + print("Did create export batch \(url)") + } + } catch { + fatalError("Should not throw an error during testing here ...") + } + } + } + + @MainActor + private static func handleHealthExportDidEnd() { + print("Health Export complete") + } +} diff --git a/Tests/UITests/TestApp/WebViewTests/WebViewAlertAndConfirmTestButton.swift b/Tests/UITests/TestApp/WebViewTests/WebViewAlertAndConfirmTestButton.swift new file mode 100644 index 0000000..5891625 --- /dev/null +++ b/Tests/UITests/TestApp/WebViewTests/WebViewAlertAndConfirmTestButton.swift @@ -0,0 +1,27 @@ +// +// This source file is part of the OneSecStanfordStudy open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import OneSecStanfordStudy +import SwiftUI + +@available(iOS 17, *) +struct WebViewAlertAndConfirmTestButton: View { + @Environment(OneSecStanfordStudyModule.self) private var oneSecStanfordStudy + + @State private var isSheetPresented = false + + var body: some View { + Button("Test Alert/Confirm") { + oneSecStanfordStudy.surveyUrl = Bundle.main.url(forResource: "alert-confirm-test", withExtension: "html") + isSheetPresented = true + } + .sheet(isPresented: $isSheetPresented) { + oneSecStanfordStudy.makeOneSecStanfordStudySheet() + } + } +} diff --git a/Tests/UITests/TestAppUITests/TestAppUITests.swift b/Tests/UITests/TestAppUITests/TestAppUITests.swift new file mode 100644 index 0000000..0e0af97 --- /dev/null +++ b/Tests/UITests/TestAppUITests/TestAppUITests.swift @@ -0,0 +1,66 @@ +// +// This source file is part of the OneSecStanfordStudy open-source project +// +// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import OSLog +import XCTest + +class TestAppUITests: XCTestCase { + override func setUpWithError() throws { + try super.setUpWithError() + continueAfterFailure = false + } + + @MainActor + func testCompatibilityWrapperLaunches() { + let app = XCUIApplication() + app.launch() + XCTAssert(app.wait(for: .runningForeground, timeout: 2)) + + if #available(iOS 17, *) { + XCTAssert(app.staticTexts["integration-status-active"].waitForExistence(timeout: 5)) + } else { + XCTAssert(app.staticTexts["integration-status-inactive"].waitForExistence(timeout: 5)) + } + } + + @MainActor + func testWebViewAlertAndConfirmHooks() throws { + guard #available(iOS 17, *) else { + throw XCTSkip("The study sheet and web view integration are only active on iOS 17 and newer.") + } + + let app = XCUIApplication() + app.launch() + XCTAssert(app.wait(for: .runningForeground, timeout: 2)) + app.buttons["Test Alert/Confirm"].tap() + + let webView = app.webViews.firstMatch + let alertStatus = webView.otherElements["Alert Status"] + let confirmStatus = webView.otherElements["Confirm Status"] + + // for some reason, it takes forever for the web page to show up on the CI... + XCTAssert(alertStatus.staticTexts["Not triggered"].waitForExistence(timeout: 10)) + + webView.buttons["Trigger alert()"].tap() + // ideally we'd also assert that the alert (or confirm) status changes to "active" while presented, + // but we skip that bc for some reason the web view's contents aren't part of the view hierarchy while the alert/sheet is active. + XCTAssert(app.alerts.staticTexts["This is the window.alert() test!"].waitForExistence(timeout: 2)) + app.alerts.buttons["OK"].tap() + XCTAssert(alertStatus.staticTexts["Alert dismissed"].waitForExistence(timeout: 2)) + + XCTAssert(confirmStatus.staticTexts["Not triggered"].waitForExistence(timeout: 1)) + webView.buttons["Trigger confirm()"].tap() + XCTAssert(app.alerts.staticTexts["This is the window.confirm() test!"].waitForExistence(timeout: 2)) + app.alerts.buttons["OK"].tap() + XCTAssert(confirmStatus.staticTexts["Confirm dismissed; response=true"].waitForExistence(timeout: 2)) + webView.buttons["Trigger confirm()"].tap() + XCTAssert(app.alerts.staticTexts["This is the window.confirm() test!"].waitForExistence(timeout: 2)) + app.alerts.buttons["Cancel"].tap() + XCTAssert(confirmStatus.staticTexts["Confirm dismissed; response=false"].waitForExistence(timeout: 2)) + } +} diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj new file mode 100644 index 0000000..a4b79f3 --- /dev/null +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -0,0 +1,658 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 809757DC2E61D2AD009B747D /* OneSecStanfordStudy in Frameworks */ = {isa = PBXBuildFile; productRef = 809757DB2E61D2AD009B747D /* OneSecStanfordStudy */; }; + 80FCAA092E4E3BE700A054D6 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 80FCAA082E4E3BE700A054D6 /* WebKit.framework */; platformFilters = (ios, macos, xros, ); }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 2F6D13AD28F5F386007C25D6 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 2F6D138A28F5F384007C25D6 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 2F6D139128F5F384007C25D6; + remoteInfo = Example; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 2F9CBECE2A76C412009818FF /* Embed Watch Content */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; + dstSubfolderSpec = 16; + files = ( + ); + name = "Embed Watch Content"; + runOnlyForDeploymentPostprocessing = 0; + }; + 8027B07C2E562778001E0A8F /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 2F68C3C6292E9F8F00B3E12C /* OneSecStanfordStudy */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = OneSecStanfordStudy; path = ../..; sourceTree = ""; }; + 2F6D139228F5F384007C25D6 /* TestApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TestApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 2F6D13AC28F5F386007C25D6 /* TestAppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TestAppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 2FB0758A299DDB9000C0B37F /* TestApp.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestApp.xctestplan; sourceTree = ""; }; + 80FCAA082E4E3BE700A054D6 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 80B77CF62E422FB70089C6B4 /* TestApp */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = TestApp; + sourceTree = ""; + }; + 80B77D022E422FBB0089C6B4 /* TestAppUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = TestAppUITests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 2F6D138F28F5F384007C25D6 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 80FCAA092E4E3BE700A054D6 /* WebKit.framework in Frameworks */, + 809757DC2E61D2AD009B747D /* OneSecStanfordStudy in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2F6D13A928F5F386007C25D6 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 2F6D138928F5F384007C25D6 = { + isa = PBXGroup; + children = ( + 2FB0758A299DDB9000C0B37F /* TestApp.xctestplan */, + 2F68C3C6292E9F8F00B3E12C /* OneSecStanfordStudy */, + 80B77CF62E422FB70089C6B4 /* TestApp */, + 80B77D022E422FBB0089C6B4 /* TestAppUITests */, + 2F6D139328F5F384007C25D6 /* Products */, + 2F6D13C228F5F3BE007C25D6 /* Frameworks */, + ); + sourceTree = ""; + }; + 2F6D139328F5F384007C25D6 /* Products */ = { + isa = PBXGroup; + children = ( + 2F6D139228F5F384007C25D6 /* TestApp.app */, + 2F6D13AC28F5F386007C25D6 /* TestAppUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 2F6D13C228F5F3BE007C25D6 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 80FCAA082E4E3BE700A054D6 /* WebKit.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 2F6D139128F5F384007C25D6 /* TestApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = 2F6D13B628F5F386007C25D6 /* Build configuration list for PBXNativeTarget "TestApp" */; + buildPhases = ( + 2F6D138E28F5F384007C25D6 /* Sources */, + 2F6D138F28F5F384007C25D6 /* Frameworks */, + 2F6D139028F5F384007C25D6 /* Resources */, + 2F9CBECE2A76C412009818FF /* Embed Watch Content */, + 8027B07C2E562778001E0A8F /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 80B77CF62E422FB70089C6B4 /* TestApp */, + ); + name = TestApp; + packageProductDependencies = ( + 809757DB2E61D2AD009B747D /* OneSecStanfordStudy */, + ); + productName = Example; + productReference = 2F6D139228F5F384007C25D6 /* TestApp.app */; + productType = "com.apple.product-type.application"; + }; + 2F6D13AB28F5F386007C25D6 /* TestAppUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 2F6D13BC28F5F386007C25D6 /* Build configuration list for PBXNativeTarget "TestAppUITests" */; + buildPhases = ( + 2F6D13A828F5F386007C25D6 /* Sources */, + 2F6D13A928F5F386007C25D6 /* Frameworks */, + 2F6D13AA28F5F386007C25D6 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 2F6D13AE28F5F386007C25D6 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 80B77D022E422FBB0089C6B4 /* TestAppUITests */, + ); + name = TestAppUITests; + productName = ExampleUITests; + productReference = 2F6D13AC28F5F386007C25D6 /* TestAppUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 2F6D138A28F5F384007C25D6 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1640; + LastUpgradeCheck = 1600; + TargetAttributes = { + 2F6D139128F5F384007C25D6 = { + CreatedOnToolsVersion = 14.1; + }; + 2F6D13AB28F5F386007C25D6 = { + CreatedOnToolsVersion = 14.1; + TestTargetID = 2F6D139128F5F384007C25D6; + }; + }; + }; + buildConfigurationList = 2F6D138D28F5F384007C25D6 /* Build configuration list for PBXProject "UITests" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 2F6D138928F5F384007C25D6; + packageReferences = ( + ); + preferredProjectObjectVersion = 77; + productRefGroup = 2F6D139328F5F384007C25D6 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 2F6D139128F5F384007C25D6 /* TestApp */, + 2F6D13AB28F5F386007C25D6 /* TestAppUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 2F6D139028F5F384007C25D6 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2F6D13AA28F5F386007C25D6 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 2F6D138E28F5F384007C25D6 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 2F6D13A828F5F386007C25D6 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 2F6D13AE28F5F386007C25D6 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 2F6D139128F5F384007C25D6 /* TestApp */; + targetProxy = 2F6D13AD28F5F386007C25D6 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 2F6D13B428F5F386007C25D6 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 6.0; + TVOS_DEPLOYMENT_TARGET = 17.0; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + XROS_DEPLOYMENT_TARGET = 1.0; + }; + name = Debug; + }; + 2F6D13B528F5F386007C25D6 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 6.0; + TVOS_DEPLOYMENT_TARGET = 17.0; + VALIDATE_PRODUCT = YES; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + XROS_DEPLOYMENT_TARGET = 1.0; + }; + name = Release; + }; + 2F6D13B728F5F386007C25D6 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = TestApp/TestApp.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = ""; + DEVELOPMENT_TEAM = ""; + ENABLE_PREVIEWS = YES; + ENABLE_TESTING_SEARCH_PATHS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHealthShareUsageDescription = "We read samples in order to process them."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.OneSecStanfordStudy.testapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + 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; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + 2F6D13B828F5F386007C25D6 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = TestApp/TestApp.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = ""; + DEVELOPMENT_TEAM = ""; + ENABLE_PREVIEWS = YES; + ENABLE_TESTING_SEARCH_PATHS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHealthShareUsageDescription = "We read samples in order to process them."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.OneSecStanfordStudy.testapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + 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; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; + 2F6D13BD28F5F386007C25D6 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 637867499T; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.OneSecStanfordStudy.testapp.uitests; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE = ""; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = NO; + TARGETED_DEVICE_FAMILY = 1; + TEST_TARGET_NAME = TestApp; + }; + name = Debug; + }; + 2F6D13BE28F5F386007C25D6 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 637867499T; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.OneSecStanfordStudy.testapp.uitests; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE = ""; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = NO; + TARGETED_DEVICE_FAMILY = 1; + TEST_TARGET_NAME = TestApp; + }; + name = Release; + }; + 2FB07587299DDB6000C0B37F /* Test */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = TEST; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 6.0; + TVOS_DEPLOYMENT_TARGET = 17.0; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + XROS_DEPLOYMENT_TARGET = 1.0; + }; + name = Test; + }; + 2FB07588299DDB6000C0B37F /* Test */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = TestApp/TestApp.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = ""; + DEVELOPMENT_TEAM = ""; + ENABLE_PREVIEWS = YES; + ENABLE_TESTING_SEARCH_PATHS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHealthShareUsageDescription = "We read samples in order to process them."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.OneSecStanfordStudy.testapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + 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; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Test; + }; + 2FB07589299DDB6000C0B37F /* Test */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 637867499T; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = edu.stanford.OneSecStanfordStudy.testapp.uitests; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE = ""; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = NO; + TARGETED_DEVICE_FAMILY = 1; + TEST_TARGET_NAME = TestApp; + }; + name = Test; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 2F6D138D28F5F384007C25D6 /* Build configuration list for PBXProject "UITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2F6D13B428F5F386007C25D6 /* Debug */, + 2FB07587299DDB6000C0B37F /* Test */, + 2F6D13B528F5F386007C25D6 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 2F6D13B628F5F386007C25D6 /* Build configuration list for PBXNativeTarget "TestApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2F6D13B728F5F386007C25D6 /* Debug */, + 2FB07588299DDB6000C0B37F /* Test */, + 2F6D13B828F5F386007C25D6 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 2F6D13BC28F5F386007C25D6 /* Build configuration list for PBXNativeTarget "TestAppUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2F6D13BD28F5F386007C25D6 /* Debug */, + 2FB07589299DDB6000C0B37F /* Test */, + 2F6D13BE28F5F386007C25D6 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCSwiftPackageProductDependency section */ + 809757DB2E61D2AD009B747D /* OneSecStanfordStudy */ = { + isa = XCSwiftPackageProductDependency; + productName = OneSecStanfordStudy; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 2F6D138A28F5F384007C25D6 /* Project object */; +} diff --git a/Tests/UITests/UITests.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Tests/UITests/UITests.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Tests/UITests/UITests.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/Tests/UITests/UITests.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme new file mode 100644 index 0000000..b9fcb5d --- /dev/null +++ b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 0f0702ff2dd5716f31cff6be0e3323f88540afac Mon Sep 17 00:00:00 2001 From: Paul Schmiedmayer Date: Tue, 7 Jul 2026 23:08:35 -0700 Subject: [PATCH 02/12] Run CI on hosted macOS runners --- .github/workflows/ci.yml | 83 +++++++++++++++++++++++++++++++++++----- 1 file changed, 74 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14e1711..6c2ebc4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,22 +26,87 @@ permissions: pull-requests: write jobs: - swift-package-ci: - name: Swift Package CI - uses: SchmiedmayerLab/.github/.github/workflows/swift-package-ci.yml@v0.2 + setup: + name: Set Up Swift Package + uses: SchmiedmayerLab/.github/.github/workflows/swift-package-setup.yml@v0.2 + + static-analysis: + name: Analyze Swift Package + needs: setup + uses: SchmiedmayerLab/.github/.github/workflows/swift-package-static-analysis.yml@v0.2 + with: + library_products: ${{ needs.setup.outputs.library_products }} + platform_name: ${{ needs.setup.outputs.platform_name }} + platform_version: ${{ needs.setup.outputs.platform_version }} + diagnose_breaking_changes: false + + package-tests: + name: Test Swift Package + needs: setup + uses: SchmiedmayerLab/.github/.github/workflows/xcodebuild.yml@v0.2 + strategy: + fail-fast: false + matrix: + platform: ${{ fromJSON(needs.setup.outputs.platform_matrix) }} + config: [Debug, Release] + with: + job_name: ${{ format('{0} ({1})', matrix.platform.name, matrix.config) }} + scheme: ${{ needs.setup.outputs.scheme }} + destination: ${{ matrix.platform.destination }} + buildConfig: ${{ matrix.config }} + resultBundle: ${{ format('{0}-{1}-{2}.xcresult', needs.setup.outputs.package_name, matrix.platform.name, matrix.config) }} + + ui-tests: + name: Test UI App + needs: setup + if: needs.setup.outputs.ui_platform_matrix != '[]' + uses: SchmiedmayerLab/.github/.github/workflows/xcodebuild.yml@v0.2 + strategy: + fail-fast: false + matrix: + platform: ${{ fromJSON(needs.setup.outputs.ui_platform_matrix) }} + config: [Debug, Release] + with: + job_name: ${{ format('UI {0} ({1})', matrix.platform.name, matrix.config) }} + path: Tests/UITests + scheme: ${{ matrix.platform.scheme }} + destination: ${{ matrix.platform.destination }} + buildConfig: ${{ matrix.config }} + resultBundle: ${{ format('{0}-{1}-{2}.xcresult', matrix.platform.scheme, matrix.platform.name, matrix.config) }} + + coverage: + name: Upload Coverage + needs: [setup, package-tests, ui-tests] + if: always() && !cancelled() && !failure() + uses: SchmiedmayerLab/.github/.github/workflows/coverage.yml@v0.2 + with: + coveragereports: >- + ${{ format('{0}-*.xcresult', needs.setup.outputs.package_name) }} + TestApp-*.xcresult secrets: inherit all-tests-pass: name: All tests pass if: always() - needs: swift-package-ci + needs: [setup, static-analysis, package-tests, ui-tests, coverage] runs-on: ubuntu-latest steps: - name: Verify CI results run: | - echo "swift-package-ci = ${{ needs.swift-package-ci.result }}" - if [ "${{ needs.swift-package-ci.result }}" != "success" ]; then - echo "::error::Swift Package CI did not pass." - exit 1 - fi + echo "setup = ${{ needs.setup.result }}" + echo "static-analysis = ${{ needs.static-analysis.result }}" + echo "package-tests = ${{ needs.package-tests.result }}" + echo "ui-tests = ${{ needs.ui-tests.result }}" + echo "coverage = ${{ needs.coverage.result }}" + for result in \ + "${{ needs.setup.result }}" \ + "${{ needs.static-analysis.result }}" \ + "${{ needs.package-tests.result }}" \ + "${{ needs.ui-tests.result }}" \ + "${{ needs.coverage.result }}"; do + case "$result" in + success|skipped) ;; + *) echo "::error::One or more required jobs did not pass (result: $result)."; exit 1 ;; + esac + done echo "All required checks passed." From 6fd2fc944ddec846aea30fa24a18651f5341f807 Mon Sep 17 00:00:00 2001 From: Paul Schmiedmayer Date: Tue, 7 Jul 2026 23:14:24 -0700 Subject: [PATCH 03/12] Ignore generated SwiftPM artifacts --- .gitignore | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index b566afc..269eb0f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,16 +1,24 @@ .DS_Store # Swift Package Manager +/.build/ .build/ +**/.build/ +/.swiftpm/ .swiftpm/ +**/.swiftpm/ +/Package.resolved Package.resolved # Xcode DerivedData/ +**/DerivedData/ +.derivedData/ +**/.derivedData/ xcuserdata/ +**/xcuserdata/ *.xcuserstate *.xcscmblueprint # SwiftPM/Xcode editor output .vscode/ - From 08914e63e110e782a3dec6c116e58714ac5d4c9b Mon Sep 17 00:00:00 2001 From: Paul Schmiedmayer Date: Tue, 7 Jul 2026 23:22:22 -0700 Subject: [PATCH 04/12] Restore reusable Swift package CI wrapper --- .github/workflows/ci.yml | 83 +++++----------------------------------- 1 file changed, 9 insertions(+), 74 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c2ebc4..14e1711 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,87 +26,22 @@ permissions: pull-requests: write jobs: - setup: - name: Set Up Swift Package - uses: SchmiedmayerLab/.github/.github/workflows/swift-package-setup.yml@v0.2 - - static-analysis: - name: Analyze Swift Package - needs: setup - uses: SchmiedmayerLab/.github/.github/workflows/swift-package-static-analysis.yml@v0.2 - with: - library_products: ${{ needs.setup.outputs.library_products }} - platform_name: ${{ needs.setup.outputs.platform_name }} - platform_version: ${{ needs.setup.outputs.platform_version }} - diagnose_breaking_changes: false - - package-tests: - name: Test Swift Package - needs: setup - uses: SchmiedmayerLab/.github/.github/workflows/xcodebuild.yml@v0.2 - strategy: - fail-fast: false - matrix: - platform: ${{ fromJSON(needs.setup.outputs.platform_matrix) }} - config: [Debug, Release] - with: - job_name: ${{ format('{0} ({1})', matrix.platform.name, matrix.config) }} - scheme: ${{ needs.setup.outputs.scheme }} - destination: ${{ matrix.platform.destination }} - buildConfig: ${{ matrix.config }} - resultBundle: ${{ format('{0}-{1}-{2}.xcresult', needs.setup.outputs.package_name, matrix.platform.name, matrix.config) }} - - ui-tests: - name: Test UI App - needs: setup - if: needs.setup.outputs.ui_platform_matrix != '[]' - uses: SchmiedmayerLab/.github/.github/workflows/xcodebuild.yml@v0.2 - strategy: - fail-fast: false - matrix: - platform: ${{ fromJSON(needs.setup.outputs.ui_platform_matrix) }} - config: [Debug, Release] - with: - job_name: ${{ format('UI {0} ({1})', matrix.platform.name, matrix.config) }} - path: Tests/UITests - scheme: ${{ matrix.platform.scheme }} - destination: ${{ matrix.platform.destination }} - buildConfig: ${{ matrix.config }} - resultBundle: ${{ format('{0}-{1}-{2}.xcresult', matrix.platform.scheme, matrix.platform.name, matrix.config) }} - - coverage: - name: Upload Coverage - needs: [setup, package-tests, ui-tests] - if: always() && !cancelled() && !failure() - uses: SchmiedmayerLab/.github/.github/workflows/coverage.yml@v0.2 - with: - coveragereports: >- - ${{ format('{0}-*.xcresult', needs.setup.outputs.package_name) }} - TestApp-*.xcresult + swift-package-ci: + name: Swift Package CI + uses: SchmiedmayerLab/.github/.github/workflows/swift-package-ci.yml@v0.2 secrets: inherit all-tests-pass: name: All tests pass if: always() - needs: [setup, static-analysis, package-tests, ui-tests, coverage] + needs: swift-package-ci runs-on: ubuntu-latest steps: - name: Verify CI results run: | - echo "setup = ${{ needs.setup.result }}" - echo "static-analysis = ${{ needs.static-analysis.result }}" - echo "package-tests = ${{ needs.package-tests.result }}" - echo "ui-tests = ${{ needs.ui-tests.result }}" - echo "coverage = ${{ needs.coverage.result }}" - for result in \ - "${{ needs.setup.result }}" \ - "${{ needs.static-analysis.result }}" \ - "${{ needs.package-tests.result }}" \ - "${{ needs.ui-tests.result }}" \ - "${{ needs.coverage.result }}"; do - case "$result" in - success|skipped) ;; - *) echo "::error::One or more required jobs did not pass (result: $result)."; exit 1 ;; - esac - done + echo "swift-package-ci = ${{ needs.swift-package-ci.result }}" + if [ "${{ needs.swift-package-ci.result }}" != "success" ]; then + echo "::error::Swift Package CI did not pass." + exit 1 + fi echo "All required checks passed." From a00ff7b099b124f21d05e56a70b3ae8888b45f6a Mon Sep 17 00:00:00 2001 From: Paul Schmiedmayer Date: Tue, 7 Jul 2026 23:29:16 -0700 Subject: [PATCH 05/12] Address review feedback --- README.md | 4 ++-- Sources/OneSecStanfordStudy/OneSecStanfordStudy.swift | 2 ++ Sources/OneSecStanfordStudy/WebView.swift | 2 +- Tests/UITests/TestApp/Resources/alert-confirm-test.html | 2 +- Tests/UITests/TestAppUITests/TestAppUITests.swift | 1 - 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 70f74d6..6217063 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Then add the product dependency to the target that needs it: ## Usage -Call `initialize(_:launchOptions:healthExportConfig:)` from your app delegate's `application(_:willFinishLaunchingWithOptions:)` method: +Call `initializeOneSecStanfordStudy(_:launchOptions:healthExportConfig:)` from your app delegate's `application(_:willFinishLaunchingWithOptions:)` method: ```swift import OneSecStanfordStudy @@ -61,7 +61,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate { _ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { - initialize( + initializeOneSecStanfordStudy( application, launchOptions: launchOptions, healthExportConfig: HealthExportConfiguration( diff --git a/Sources/OneSecStanfordStudy/OneSecStanfordStudy.swift b/Sources/OneSecStanfordStudy/OneSecStanfordStudy.swift index 4bdb7a1..550d43c 100644 --- a/Sources/OneSecStanfordStudy/OneSecStanfordStudy.swift +++ b/Sources/OneSecStanfordStudy/OneSecStanfordStudy.swift @@ -109,6 +109,7 @@ final class OneSecStanfordStudy: OneSecStanfordStudyModule, Module, EnvironmentA try await healthKit.askForAuthorization(for: .init(read: healthExportConfig.sampleTypes)) let session = try await healthExportSession() let stream = try session.start(retryFailedBatches: true) + try localStorage.store(true, for: .didInitiateBulkExport) if #available(iOS 18, *) { healthExportConfig.didStartExport(AnyAsyncSequence(stream.compactMap(\.self))) } else { @@ -138,6 +139,7 @@ final class OneSecStanfordStudy: OneSecStanfordStudyModule, Module, EnvironmentA } } if isCompleted { + try? localStorage.store(false, for: .didInitiateBulkExport) healthExportConfig.didEndExport() } } diff --git a/Sources/OneSecStanfordStudy/WebView.swift b/Sources/OneSecStanfordStudy/WebView.swift index cbf03fa..2d07284 100644 --- a/Sources/OneSecStanfordStudy/WebView.swift +++ b/Sources/OneSecStanfordStudy/WebView.swift @@ -83,7 +83,7 @@ struct WebView: View { } set: { newValue in // Note that we intentionally ignore `newValue == true` in here! if !newValue { - self.alert = nil + self.confirmation = nil } }, presenting: confirmation diff --git a/Tests/UITests/TestApp/Resources/alert-confirm-test.html b/Tests/UITests/TestApp/Resources/alert-confirm-test.html index bc41611..1d188e2 100644 --- a/Tests/UITests/TestApp/Resources/alert-confirm-test.html +++ b/Tests/UITests/TestApp/Resources/alert-confirm-test.html @@ -70,7 +70,7 @@ Not triggered - Confirm + Confirm diff --git a/Tests/UITests/TestAppUITests/TestAppUITests.swift b/Tests/UITests/TestAppUITests/TestAppUITests.swift index 0e0af97..67400e9 100644 --- a/Tests/UITests/TestAppUITests/TestAppUITests.swift +++ b/Tests/UITests/TestAppUITests/TestAppUITests.swift @@ -6,7 +6,6 @@ // SPDX-License-Identifier: MIT // -import OSLog import XCTest class TestAppUITests: XCTestCase { From e031aba52c29ee0b9e562f586604749ddfce889c Mon Sep 17 00:00:00 2001 From: Paul Schmiedmayer Date: Wed, 8 Jul 2026 00:19:15 -0700 Subject: [PATCH 06/12] Update PR --- CONTRIBUTORS.md | 3 +- LICENSE.md | 4 +- Package.swift | 15 +++--- .../StudySurveySheet.swift | 38 ++++++++++++--- Sources/OneSecStanfordStudy/WebView.swift | 46 ++++++++++++++----- .../OneSecStanfordStudyTests.swift | 43 +++++++++++++++++ Tests/UITests/TestApp/ContentView.swift | 6 ++- .../UITests/UITests.xcodeproj/project.pbxproj | 6 +-- 8 files changed, 127 insertions(+), 34 deletions(-) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index be46a92..8558745 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -16,6 +16,7 @@ OneSecStanfordStudy contributors * [Paul Schmiedmayer](https://github.com/PSchmiedmayer) -## Attributions +Attributions +------------ This package is based on the original [implementation repository](https://github.com/StanfordBDHG/OneSecStudySpeziIntegration) and [interface repository](https://github.com/StanfordBDHG/OneSecStudySpeziIntegrationInterface), which were published under the MIT License. diff --git a/LICENSE.md b/LICENSE.md index 6998b5f..d16521d 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,9 +1,9 @@ MIT License -Copyright (c) 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +Copyright (c) 2026 Stanford University and the project authors (see CONTRIBUTORS.md) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Package.swift b/Package.swift index 84876ea..90a07bb 100644 --- a/Package.swift +++ b/Package.swift @@ -10,6 +10,11 @@ import PackageDescription +let swiftSettings: [SwiftSetting] = [ + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault") +] + let package = Package( name: "OneSecStanfordStudy", platforms: [ @@ -32,20 +37,14 @@ let package = Package( .product(name: "HealthKitOnFHIR", package: "Spezi"), .product(name: "SpeziLocalStorage", package: "Spezi") ], - swiftSettings: [ - .enableUpcomingFeature("ExistentialAny"), - .enableUpcomingFeature("InternalImportsByDefault") - ] + swiftSettings: swiftSettings ), .testTarget( name: "OneSecStanfordStudyTests", dependencies: [ .target(name: "OneSecStanfordStudy") ], - swiftSettings: [ - .enableUpcomingFeature("ExistentialAny"), - .enableUpcomingFeature("InternalImportsByDefault") - ] + swiftSettings: swiftSettings ) ], swiftLanguageModes: [.v6] diff --git a/Sources/OneSecStanfordStudy/StudySurveySheet.swift b/Sources/OneSecStanfordStudy/StudySurveySheet.swift index 846e684..adc6966 100644 --- a/Sources/OneSecStanfordStudy/StudySurveySheet.swift +++ b/Sources/OneSecStanfordStudy/StudySurveySheet.swift @@ -18,6 +18,7 @@ struct StudySurveySheet: View { @State private var isShowingCancelAlert = false @State private var isDone = false @State private var currentPageLoadProgress: Double? + @State private var healthExportErrorMessage: String? var body: some View { if let url = oneSecStanfordStudy.surveyUrl { @@ -58,6 +59,22 @@ struct StudySurveySheet: View { } } .interactiveDismissDisabled() + .alert( + "Unable to Start Health Export", + isPresented: Binding { + healthExportErrorMessage != nil + } set: { newValue in + if !newValue { + healthExportErrorMessage = nil + } + } + ) { + Button("OK") { + healthExportErrorMessage = nil + } + } message: { + Text(healthExportErrorMessage ?? "Please try again.") + } } else { ContentUnavailableView("MISSING_URL", systemImage: "exclamationmark.triangle") } @@ -143,8 +160,8 @@ struct StudySurveySheet: View { do { try await oneSecStanfordStudy.triggerHealthExport(forceSessionReset: true) } catch { - // Q how to handle this? (will depend on the specific error. eg for missing permissions we could throw up an alert, etc) oneSecStanfordStudy.logger.error("Error initiating bulk health export: \(error)") + healthExportErrorMessage = error.localizedDescription } } } @@ -152,14 +169,21 @@ struct StudySurveySheet: View { @available(iOS 17, *) extension WebViewProxy { func pageContainsField(named variableName: String) async -> Bool { - (try? await evaluateJavaScript( - #"document.querySelector('div[data-mlm-field="\#(variableName)"]') !== null"# - ) as? Bool) == true + let result = try? await callAsyncJavaScript( + """ + const elements = document.querySelectorAll("div[data-mlm-field]"); + return Array.from(elements).some(element => element.getAttribute("data-mlm-field") === name); + """, + arguments: ["name": variableName] + ) + return (result as? Bool) == true } func pageContainsElement(withId id: String) async -> Bool { - (try? await evaluateJavaScript( - "document.getElementById('\(id)') !== null" - ) as? Bool) == true + let result = try? await callAsyncJavaScript( + "return document.getElementById(id) !== null;", + arguments: ["id": id] + ) + return (result as? Bool) == true } } diff --git a/Sources/OneSecStanfordStudy/WebView.swift b/Sources/OneSecStanfordStudy/WebView.swift index 2d07284..657eaf2 100644 --- a/Sources/OneSecStanfordStudy/WebView.swift +++ b/Sources/OneSecStanfordStudy/WebView.swift @@ -45,13 +45,13 @@ struct WebView: View { currentUrl: $currentUrl, currentProgress: $currentProgress, onAlert: { message in - assert(alert == nil, "Already presenting an alert!") // Q: is this smth we need to worry about (stacked alerts...) + cancelPendingJavaScriptDialog() await withCheckedContinuation { continuation in alert = .init(message: message, continuation: continuation) } }, onConfirm: { message in - assert(confirmation == nil, "Already presenting an alert!") // Q: is this smth we need to worry about (stacked alerts...) + cancelPendingJavaScriptDialog() return await withCheckedContinuation { continuation in confirmation = .init(message: message, continuation: continuation) } @@ -64,14 +64,13 @@ struct WebView: View { } set: { newValue in // Note that we intentionally ignore `newValue == true` in here! if !newValue { - self.alert = nil + dismissAlert() } }, presenting: alert - ) { config in + ) { _ in Button("OK") { - config.continuation.resume() - self.alert = nil + dismissAlert() } } message: { config in Text(config.message) @@ -83,18 +82,16 @@ struct WebView: View { } set: { newValue in // Note that we intentionally ignore `newValue == true` in here! if !newValue { - self.confirmation = nil + dismissConfirmation(returning: false) } }, presenting: confirmation - ) { config in + ) { _ in let cancelImp = { - config.continuation.resume(returning: false) - self.confirmation = nil + dismissConfirmation(returning: false) } let confirmImp = { - config.continuation.resume(returning: true) - self.confirmation = nil + dismissConfirmation(returning: true) } #if compiler(>=6.2) if #available(iOS 26, *) { @@ -128,6 +125,27 @@ struct WebView: View { config = .init(initialUrl: url, shouldNavigate: shouldNavigate, didNavigate: didNavigate) _currentProgress = currentProgress } + + private func cancelPendingJavaScriptDialog() { + dismissAlert() + dismissConfirmation(returning: false) + } + + private func dismissAlert() { + guard let alert else { + return + } + self.alert = nil + alert.continuation.resume() + } + + private func dismissConfirmation(returning result: Bool) { + guard let confirmation else { + return + } + self.confirmation = nil + confirmation.continuation.resume(returning: result) + } } @available(iOS 17, *) @@ -146,6 +164,10 @@ struct WebViewProxy { func evaluateJavaScript(_ script: String) async throws -> Any? { try await wkWebView.evaluateJavaScript(script) } + + func callAsyncJavaScript(_ script: String, arguments: [String: Any] = [:]) async throws -> Any? { + try await wkWebView.callAsyncJavaScript(script, arguments: arguments, in: nil, contentWorld: .page) + } } // MARK: WebViewImpl diff --git a/Tests/OneSecStanfordStudyTests/OneSecStanfordStudyTests.swift b/Tests/OneSecStanfordStudyTests/OneSecStanfordStudyTests.swift index 3709362..f761748 100644 --- a/Tests/OneSecStanfordStudyTests/OneSecStanfordStudyTests.swift +++ b/Tests/OneSecStanfordStudyTests/OneSecStanfordStudyTests.swift @@ -49,4 +49,47 @@ struct OneSecStanfordStudyTests { #expect(values == [1, 2, 3]) } + + @Test + func anyAsyncSequencePropagatesErrors() async throws { + enum TestError: Error, Equatable { + case failure + } + + let stream = AsyncThrowingStream { continuation in + continuation.yield(1) + continuation.finish(throwing: TestError.failure) + } + let sequence = AnyAsyncSequence(stream) + + var values: [Int] = [] + var caughtError: (any Error)? + do { + for try await value in sequence { + values.append(value) + } + } catch { + caughtError = error + } + + #expect(values == [1]) + #expect(caughtError as? TestError == .failure) + } + + @available(iOS 18.0, *) + @Test + func anyAsyncSequenceSupportsIsolationAwareIteration() async { + let stream = AsyncStream { continuation in + continuation.yield(1) + continuation.finish() + } + let sequence = AnyAsyncSequence(stream) + var iterator = sequence.makeAsyncIterator() + + let firstValue = await iterator.next(isolation: nil) + let secondValue = await iterator.next(isolation: nil) + + #expect(firstValue == 1) + #expect(secondValue == nil) + } } diff --git a/Tests/UITests/TestApp/ContentView.swift b/Tests/UITests/TestApp/ContentView.swift index fa84997..5a6f3ec 100644 --- a/Tests/UITests/TestApp/ContentView.swift +++ b/Tests/UITests/TestApp/ContentView.swift @@ -22,7 +22,11 @@ struct ContentView: View { StudyButton() Button("Trigger Health Export") { Task { - try await oneSecStanfordStudy.triggerHealthExport(forceSessionReset: true) + do { + try await oneSecStanfordStudy.triggerHealthExport(forceSessionReset: true) + } catch { + fatalError("Health export failed: \(error)") + } } } } diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index a4b79f3..18db135 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -17,7 +17,7 @@ containerPortal = 2F6D138A28F5F384007C25D6 /* Project object */; proxyType = 1; remoteGlobalIDString = 2F6D139128F5F384007C25D6; - remoteInfo = Example; + remoteInfo = TestApp; }; /* End PBXContainerItemProxy section */ @@ -138,7 +138,7 @@ packageProductDependencies = ( 809757DB2E61D2AD009B747D /* OneSecStanfordStudy */, ); - productName = Example; + productName = TestApp; productReference = 2F6D139228F5F384007C25D6 /* TestApp.app */; productType = "com.apple.product-type.application"; }; @@ -159,7 +159,7 @@ 80B77D022E422FBB0089C6B4 /* TestAppUITests */, ); name = TestAppUITests; - productName = ExampleUITests; + productName = TestAppUITests; productReference = 2F6D13AC28F5F386007C25D6 /* TestAppUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; From ce8521df1082f85175d9784cb4bc8666c6dde5ed Mon Sep 17 00:00:00 2001 From: Paul Schmiedmayer Date: Wed, 8 Jul 2026 00:49:47 -0700 Subject: [PATCH 07/12] Fix Tests --- .../OneSecStanfordStudyTests.swift | 4 +-- .../TestAppUITests/TestAppUITests.swift | 28 +++++++++++-------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/Tests/OneSecStanfordStudyTests/OneSecStanfordStudyTests.swift b/Tests/OneSecStanfordStudyTests/OneSecStanfordStudyTests.swift index f761748..ba5f927 100644 --- a/Tests/OneSecStanfordStudyTests/OneSecStanfordStudyTests.swift +++ b/Tests/OneSecStanfordStudyTests/OneSecStanfordStudyTests.swift @@ -6,7 +6,7 @@ // SPDX-License-Identifier: MIT // -@testable import OneSecStanfordStudy +import OneSecStanfordStudy import Foundation import HealthKit import Testing @@ -15,7 +15,7 @@ import Testing struct OneSecStanfordStudyTests { @Test func healthExportConfigurationStoresValues() { - let destination = URL(filePath: "/tmp/one-sec-export") + let destination = URL(fileURLWithPath: "/tmp/one-sec-export") let sampleTypes: Set = [] let timeRange = Date(timeIntervalSince1970: 0).. Date: Sun, 12 Jul 2026 17:56:49 +0200 Subject: [PATCH 08/12] switch to new Spezi branch; iOS 18 --- Package.swift | 2 +- README.md | 7 ++++--- .../OneSecStanfordStudy/HKSampleToFHIRProcessor.swift | 2 +- Sources/OneSecStanfordStudy/Integration.swift | 10 +++++----- Sources/OneSecStanfordStudy/OneSecStanfordStudy.swift | 11 ++++++----- .../OneSecStanfordStudyModule.swift | 3 ++- Sources/OneSecStanfordStudy/StudySurveySheet.swift | 4 ++-- Sources/OneSecStanfordStudy/WebView.swift | 8 ++++---- Tests/UITests/TestApp/ContentView.swift | 2 +- Tests/UITests/TestApp/StudyButton.swift | 2 +- Tests/UITests/TestApp/TestApp.swift | 2 +- .../WebViewAlertAndConfirmTestButton.swift | 2 +- Tests/UITests/TestAppUITests/TestAppUITests.swift | 6 +++--- 13 files changed, 32 insertions(+), 29 deletions(-) diff --git a/Package.swift b/Package.swift index 90a07bb..c09b88d 100644 --- a/Package.swift +++ b/Package.swift @@ -24,7 +24,7 @@ let package = Package( .library(name: "OneSecStanfordStudy", targets: ["OneSecStanfordStudy"]) ], dependencies: [ - .package(url: "https://github.com/SchmiedmayerLab/Spezi.git", branch: "oldiOSVersion", traits: []) + .package(url: "https://github.com/SchmiedmayerLab/Spezi.git", revision: "513178d3f0356a2c0ce5c11c8882e6b59dd3f971", traits: []) ], targets: [ .target( diff --git a/README.md b/README.md index 6217063..45c31aa 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,13 @@ Stanford study integration module for the one sec app's Digital Interventions Ou This package combines the original interface and implementation packages into one package that depends on the Spezi monorepo. -The old two-package setup worked around a deployment-target mismatch by dynamically loading the iOS 17 implementation from a separate framework while exposing an iOS 15 interface package. The monorepo-backed version no longer needs that workaround: apps can depend on this single package and link the implementation directly. +The old two-package setup worked around a deployment-target mismatch by dynamically loading the iOS 18 implementation from a separate framework while exposing an iOS 15 interface package. +The new single-repo version no longer needs that workaround: apps can depend on this single package and link the implementation directly. ## Installation -Add this package to your app and select the `OneSecStanfordStudy` product. The package can be added to app targets that support iOS 15 or newer. The study integration is active on iOS 17 and newer; on older iOS versions, initialization and the root view modifier are no-ops. +Add this package to your app and select the `OneSecStanfordStudy` product. The package can be added to app targets that support iOS 15 or newer. The study integration is active on iOS 18 and newer; on older iOS versions, initialization and the root view modifier are no-ops. This setup temporarily depends on the Spezi monorepo feature branch that adds the monorepo-backed deployment target support: @@ -101,7 +102,7 @@ The runtime is configured directly and `OneSecStanfordStudyModule` is available The package includes unit tests in `Tests/OneSecStanfordStudyTests` and a consolidated iOS UI test app in `Tests/UITests`. -Run the UI test app with the `TestApp` scheme in `Tests/UITests/UITests.xcodeproj`. The test app has an iOS 15 deployment target. On iOS 15 and iOS 16, the wrapper launch test validates that initialization and `.oneSecStanfordStudy()` are no-ops. On iOS 17 and newer, the same app validates the active integration and the web view alert/confirm hooks. +Run the UI test app with the `TestApp` scheme in `Tests/UITests/UITests.xcodeproj`. The test app has an iOS 15 deployment target. On iOS 15 and iOS 16, the wrapper launch test validates that initialization and `.oneSecStanfordStudy()` are no-ops. On iOS 18 and newer, the same app validates the active integration and the web view alert/confirm hooks. ## Contributing diff --git a/Sources/OneSecStanfordStudy/HKSampleToFHIRProcessor.swift b/Sources/OneSecStanfordStudy/HKSampleToFHIRProcessor.swift index 1e80392..59546eb 100644 --- a/Sources/OneSecStanfordStudy/HKSampleToFHIRProcessor.swift +++ b/Sources/OneSecStanfordStudy/HKSampleToFHIRProcessor.swift @@ -12,7 +12,7 @@ private import SpeziFoundation import SpeziHealthKit import SpeziHealthKitBulkExport -@available(iOS 17, *) +@available(iOS 18, *) struct HKSampleToFHIRProcessor: BatchProcessor { let outputDirectory: URL diff --git a/Sources/OneSecStanfordStudy/Integration.swift b/Sources/OneSecStanfordStudy/Integration.swift index bd4b75a..8cf6f3b 100644 --- a/Sources/OneSecStanfordStudy/Integration.swift +++ b/Sources/OneSecStanfordStudy/Integration.swift @@ -16,14 +16,14 @@ public import UIKit /// Once this function has returned, the ``SwiftUICore/View/oneSecStanfordStudy()`` view modifier is /// available and should be called on the root level of the app's view hierarchy. /// -/// - Note: If the device is running below iOS 17, this function has no effect. +/// - Note: If the device is running below iOS 18, this function has no effect. @MainActor public func initializeOneSecStanfordStudy( _ application: UIApplication, launchOptions: [UIApplication.LaunchOptionsKey: Any]?, // swiftlint:disable:this discouraged_optional_collection healthExportConfig: HealthExportConfiguration ) { - guard #available(iOS 17, *) else { + guard #available(iOS 18, *) else { return } OneSecStanfordStudy.initialize(application: application, launchOptions: launchOptions, healthExportConfig: healthExportConfig) @@ -33,11 +33,11 @@ extension View { /// Injects the study runtime into the SwiftUI view hierarchy. /// /// - Note: This modifier should be called on the root view of the application. - /// If the device is running below iOS 17, this modifier has no effect. + /// If the device is running below iOS 18, this modifier has no effect. @ViewBuilder public func oneSecStanfordStudy() -> some View { - if #available(iOS 17, *) { - AnyView(OneSecStanfordStudy.studyIntegrationViewModifier.applying(to: self)) + if #available(iOS 18, *) { + OneSecStanfordStudy.studyIntegrationViewModifier.applying(to: self) } else { self } diff --git a/Sources/OneSecStanfordStudy/OneSecStanfordStudy.swift b/Sources/OneSecStanfordStudy/OneSecStanfordStudy.swift index 550d43c..33bdb68 100644 --- a/Sources/OneSecStanfordStudy/OneSecStanfordStudy.swift +++ b/Sources/OneSecStanfordStudy/OneSecStanfordStudy.swift @@ -16,8 +16,9 @@ import OSLog import SwiftUI import UIKit + /// The OneSecStanfordStudy module. -@available(iOS 17, *) +@available(iOS 18, *) @Observable @MainActor @objc(OneSecStanfordStudy) @@ -147,7 +148,7 @@ final class OneSecStanfordStudy: OneSecStanfordStudyModule, Module, EnvironmentA // MARK: App Delegate and Standard -@available(iOS 17, *) +@available(iOS 18, *) private final class OneSecStanfordStudyAppDelegate: SpeziAppDelegate { private let healthExportConfig: HealthExportConfiguration @@ -164,7 +165,7 @@ private final class OneSecStanfordStudyAppDelegate: SpeziAppDelegate { } } -@available(iOS 17, *) +@available(iOS 18, *) private actor OneSecStanfordStudyStandard: Standard, HealthKitConstraint { func handleNewSamples(_ addedSamples: some Collection, ofType sampleType: SampleType) async {} func handleDeletedObjects(_ deletedObjects: some Collection, ofType sampleType: SampleType) async {} @@ -172,12 +173,12 @@ private actor OneSecStanfordStudyStandard: Standard, HealthKitConstraint { // MARK: Utils -@available(iOS 17, *) +@available(iOS 18, *) extension BulkExportSessionIdentifier { fileprivate static let oneSecStanfordStudy = Self("edu.stanford.OneSecStanfordStudy") } -@available(iOS 17, *) +@available(iOS 18, *) extension LocalStorageKeys { fileprivate static let oneSecStanfordStudyState = LocalStorageKey("edu.stanford.OneSecStanfordStudy.state") fileprivate static let didInitiateBulkExport = LocalStorageKey("edu.stanford.OneSecStanfordStudy.didInitiateBulkExport") diff --git a/Sources/OneSecStanfordStudy/OneSecStanfordStudyModule.swift b/Sources/OneSecStanfordStudy/OneSecStanfordStudyModule.swift index 08c8eff..d27be4f 100644 --- a/Sources/OneSecStanfordStudy/OneSecStanfordStudyModule.swift +++ b/Sources/OneSecStanfordStudy/OneSecStanfordStudyModule.swift @@ -12,6 +12,7 @@ public import HealthKit public import SwiftUI public import UIKit + /// The Stanford study integration for the one sec app. /// /// ## Topics @@ -22,7 +23,7 @@ public import UIKit /// /// ### Instance Methods /// - ``makeOneSecStanfordStudySheet()`` -@available(iOS 17, *) +@available(iOS 18, *) @Observable @MainActor open class OneSecStanfordStudyModule: NSObject { diff --git a/Sources/OneSecStanfordStudy/StudySurveySheet.swift b/Sources/OneSecStanfordStudy/StudySurveySheet.swift index adc6966..0d704aa 100644 --- a/Sources/OneSecStanfordStudy/StudySurveySheet.swift +++ b/Sources/OneSecStanfordStudy/StudySurveySheet.swift @@ -9,7 +9,7 @@ private import Spezi import SwiftUI -@available(iOS 17, *) +@available(iOS 18, *) struct StudySurveySheet: View { @Environment(\.dismiss) private var dismiss @Environment(OneSecStanfordStudy.self) private var oneSecStanfordStudy @@ -166,7 +166,7 @@ struct StudySurveySheet: View { } } -@available(iOS 17, *) +@available(iOS 18, *) extension WebViewProxy { func pageContainsField(named variableName: String) async -> Bool { let result = try? await callAsyncJavaScript( diff --git a/Sources/OneSecStanfordStudy/WebView.swift b/Sources/OneSecStanfordStudy/WebView.swift index 657eaf2..bc1c579 100644 --- a/Sources/OneSecStanfordStudy/WebView.swift +++ b/Sources/OneSecStanfordStudy/WebView.swift @@ -11,7 +11,7 @@ import SwiftUI private import WebKit -@available(iOS 17, *) +@available(iOS 18, *) struct WebView: View { typealias ShouldNavigate = @MainActor (URLRequest) async -> Bool typealias DidNavigate = @MainActor (WebViewProxy) async -> Void @@ -148,7 +148,7 @@ struct WebView: View { } } -@available(iOS 17, *) +@available(iOS 18, *) @MainActor struct WebViewProxy { private let wkWebView: WKWebView @@ -172,7 +172,7 @@ struct WebViewProxy { // MARK: WebViewImpl -@available(iOS 17, *) +@available(iOS 18, *) private struct WebViewImpl: UIViewRepresentable { private let config: WebView.Config @Binding private var currentUrl: URL? @@ -220,7 +220,7 @@ private struct WebViewImpl: UIViewRepresentable { } } -@available(iOS 17, *) +@available(iOS 18, *) extension WebViewImpl { @MainActor fileprivate final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate { diff --git a/Tests/UITests/TestApp/ContentView.swift b/Tests/UITests/TestApp/ContentView.swift index 5a6f3ec..fb8e5b0 100644 --- a/Tests/UITests/TestApp/ContentView.swift +++ b/Tests/UITests/TestApp/ContentView.swift @@ -9,7 +9,7 @@ @_spi(APISupport) import OneSecStanfordStudy import SwiftUI -@available(iOS 17, *) +@available(iOS 18, *) struct ContentView: View { @Environment(OneSecStanfordStudyModule.self) private var oneSecStanfordStudy diff --git a/Tests/UITests/TestApp/StudyButton.swift b/Tests/UITests/TestApp/StudyButton.swift index d968295..f7d87e8 100644 --- a/Tests/UITests/TestApp/StudyButton.swift +++ b/Tests/UITests/TestApp/StudyButton.swift @@ -9,7 +9,7 @@ import OneSecStanfordStudy import SwiftUI -@available(iOS 17, *) +@available(iOS 18, *) struct StudyButton: View { @Environment(OneSecStanfordStudyModule.self) private var oneSecStanfordStudy @State private var isShowingSheet = false diff --git a/Tests/UITests/TestApp/TestApp.swift b/Tests/UITests/TestApp/TestApp.swift index 4eac821..75fa625 100644 --- a/Tests/UITests/TestApp/TestApp.swift +++ b/Tests/UITests/TestApp/TestApp.swift @@ -21,7 +21,7 @@ struct UITestsApp: App { } @ViewBuilder private var rootContent: some View { - if #available(iOS 17, *) { + if #available(iOS 18, *) { ContentView() } else { VStack(spacing: 12) { diff --git a/Tests/UITests/TestApp/WebViewTests/WebViewAlertAndConfirmTestButton.swift b/Tests/UITests/TestApp/WebViewTests/WebViewAlertAndConfirmTestButton.swift index 5891625..df14395 100644 --- a/Tests/UITests/TestApp/WebViewTests/WebViewAlertAndConfirmTestButton.swift +++ b/Tests/UITests/TestApp/WebViewTests/WebViewAlertAndConfirmTestButton.swift @@ -9,7 +9,7 @@ import OneSecStanfordStudy import SwiftUI -@available(iOS 17, *) +@available(iOS 18, *) struct WebViewAlertAndConfirmTestButton: View { @Environment(OneSecStanfordStudyModule.self) private var oneSecStanfordStudy diff --git a/Tests/UITests/TestAppUITests/TestAppUITests.swift b/Tests/UITests/TestAppUITests/TestAppUITests.swift index ea7e77d..db4bc85 100644 --- a/Tests/UITests/TestAppUITests/TestAppUITests.swift +++ b/Tests/UITests/TestAppUITests/TestAppUITests.swift @@ -27,7 +27,7 @@ class TestAppUITests: XCTestCase { app.launch() XCTAssert(app.wait(for: .runningForeground, timeout: Timeout.appLaunch)) - if #available(iOS 17, *) { + if #available(iOS 18, *) { XCTAssert(app.staticTexts["integration-status-active"].waitForExistence(timeout: 5)) } else { XCTAssert(app.staticTexts["integration-status-inactive"].waitForExistence(timeout: 5)) @@ -36,8 +36,8 @@ class TestAppUITests: XCTestCase { @MainActor func testWebViewAlertAndConfirmHooks() throws { - guard #available(iOS 17, *) else { - throw XCTSkip("The study sheet and web view integration are only active on iOS 17 and newer.") + guard #available(iOS 18, *) else { + throw XCTSkip("The study sheet and web view integration are only active on iOS 18 and newer.") } let app = XCUIApplication() From 8bcd35b42b60c6bc59047e4e30a67c5db31b0d7a Mon Sep 17 00:00:00 2001 From: Lukas Kollmer Date: Sun, 12 Jul 2026 18:34:36 +0200 Subject: [PATCH 09/12] ihatethis --- Sources/OneSecStanfordStudy/OneSecStanfordStudy.swift | 1 - Sources/OneSecStanfordStudy/OneSecStanfordStudyModule.swift | 1 - 2 files changed, 2 deletions(-) diff --git a/Sources/OneSecStanfordStudy/OneSecStanfordStudy.swift b/Sources/OneSecStanfordStudy/OneSecStanfordStudy.swift index 33bdb68..7161e2b 100644 --- a/Sources/OneSecStanfordStudy/OneSecStanfordStudy.swift +++ b/Sources/OneSecStanfordStudy/OneSecStanfordStudy.swift @@ -16,7 +16,6 @@ import OSLog import SwiftUI import UIKit - /// The OneSecStanfordStudy module. @available(iOS 18, *) @Observable diff --git a/Sources/OneSecStanfordStudy/OneSecStanfordStudyModule.swift b/Sources/OneSecStanfordStudy/OneSecStanfordStudyModule.swift index d27be4f..f343915 100644 --- a/Sources/OneSecStanfordStudy/OneSecStanfordStudyModule.swift +++ b/Sources/OneSecStanfordStudy/OneSecStanfordStudyModule.swift @@ -12,7 +12,6 @@ public import HealthKit public import SwiftUI public import UIKit - /// The Stanford study integration for the one sec app. /// /// ## Topics From 0248ea03e85e4831ff2e16b7ac7437f882089973 Mon Sep 17 00:00:00 2001 From: Lukas Kollmer Date: Sun, 12 Jul 2026 21:48:26 +0200 Subject: [PATCH 10/12] update Spezi release --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index c09b88d..6cbc399 100644 --- a/Package.swift +++ b/Package.swift @@ -24,7 +24,7 @@ let package = Package( .library(name: "OneSecStanfordStudy", targets: ["OneSecStanfordStudy"]) ], dependencies: [ - .package(url: "https://github.com/SchmiedmayerLab/Spezi.git", revision: "513178d3f0356a2c0ce5c11c8882e6b59dd3f971", traits: []) + .package(url: "https://github.com/SchmiedmayerLab/Spezi.git", revision: "d55dd3a84fc21fc4f6153cb90f08fbb381b6ec0d", traits: []) ], targets: [ .target( From afa53431b7714c9edf494bfc601b8e98aef0f60d Mon Sep 17 00:00:00 2001 From: Lukas Kollmer Date: Sun, 12 Jul 2026 22:18:34 +0200 Subject: [PATCH 11/12] switch from LocalStorage to LocalPreferences --- Package.swift | 3 +- .../OneSecStanfordStudy.swift | 33 +++++++++++++------ 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/Package.swift b/Package.swift index 6cbc399..69ca065 100644 --- a/Package.swift +++ b/Package.swift @@ -34,8 +34,7 @@ let package = Package( .product(name: "SpeziFoundation", package: "Spezi"), .product(name: "SpeziHealthKit", package: "Spezi"), .product(name: "SpeziHealthKitBulkExport", package: "Spezi"), - .product(name: "HealthKitOnFHIR", package: "Spezi"), - .product(name: "SpeziLocalStorage", package: "Spezi") + .product(name: "HealthKitOnFHIR", package: "Spezi") ], swiftSettings: swiftSettings ), diff --git a/Sources/OneSecStanfordStudy/OneSecStanfordStudy.swift b/Sources/OneSecStanfordStudy/OneSecStanfordStudy.swift index 7161e2b..1e496f1 100644 --- a/Sources/OneSecStanfordStudy/OneSecStanfordStudy.swift +++ b/Sources/OneSecStanfordStudy/OneSecStanfordStudy.swift @@ -9,9 +9,9 @@ // swiftlint:disable file_types_order @_spi(APISupport) import Spezi +private import SpeziFoundation private import SpeziHealthKit private import SpeziHealthKitBulkExport -private import SpeziLocalStorage import OSLog import SwiftUI import UIKit @@ -48,10 +48,10 @@ final class OneSecStanfordStudy: OneSecStanfordStudyModule, Module, EnvironmentA @ObservationIgnored @Application(\.logger) var logger @ObservationIgnored @Dependency(HealthKit.self) private var healthKit @ObservationIgnored @Dependency(BulkHealthExporter.self) private var bulkExporter - @ObservationIgnored @Dependency(LocalStorage.self) private var localStorage nonisolated private let healthExportConfig: HealthExportConfiguration nonisolated(unsafe) private let fileManager = FileManager.default + private let prefsStore = LocalPreferencesStore.standard /// Creates a new instance of the `OneSecStanfordStudy` module nonisolated init(healthExportConfig: HealthExportConfiguration) { @@ -69,10 +69,10 @@ final class OneSecStanfordStudy: OneSecStanfordStudyModule, Module, EnvironmentA } func configure() { - updateState((try? localStorage.load(.oneSecStanfordStudyState)) ?? .available) + updateState(prefsStore[.oneSecStanfordStudyState]) Task { do { - if try localStorage.load(.didInitiateBulkExport) == true { + if prefsStore[.didInitiateBulkExport] { // we've initiated the Health Export at some point in the past. // we now check if it has completed, and, if not, tell it to continue. let session = try await healthExportSession() @@ -88,7 +88,7 @@ final class OneSecStanfordStudy: OneSecStanfordStudyModule, Module, EnvironmentA override func updateState(_ newState: OneSecStanfordStudyModule.State) { if newState != state { - try? localStorage.store(newState, for: .oneSecStanfordStudyState) + prefsStore[.oneSecStanfordStudyState] = newState } super.updateState(newState) } @@ -109,7 +109,7 @@ final class OneSecStanfordStudy: OneSecStanfordStudyModule, Module, EnvironmentA try await healthKit.askForAuthorization(for: .init(read: healthExportConfig.sampleTypes)) let session = try await healthExportSession() let stream = try session.start(retryFailedBatches: true) - try localStorage.store(true, for: .didInitiateBulkExport) + prefsStore[.didInitiateBulkExport] = true if #available(iOS 18, *) { healthExportConfig.didStartExport(AnyAsyncSequence(stream.compactMap(\.self))) } else { @@ -139,7 +139,7 @@ final class OneSecStanfordStudy: OneSecStanfordStudyModule, Module, EnvironmentA } } if isCompleted { - try? localStorage.store(false, for: .didInitiateBulkExport) + prefsStore[.didInitiateBulkExport] = false healthExportConfig.didEndExport() } } @@ -177,8 +177,21 @@ extension BulkExportSessionIdentifier { fileprivate static let oneSecStanfordStudy = Self("edu.stanford.OneSecStanfordStudy") } + +@available(iOS 18, *) +extension LocalPreferenceKeys.Namespace { + fileprivate static let speziOneSec: Self = .custom("edu.stanford.SpeziOneSec") +} + + @available(iOS 18, *) -extension LocalStorageKeys { - fileprivate static let oneSecStanfordStudyState = LocalStorageKey("edu.stanford.OneSecStanfordStudy.state") - fileprivate static let didInitiateBulkExport = LocalStorageKey("edu.stanford.OneSecStanfordStudy.didInitiateBulkExport") +extension LocalPreferenceKeys { + fileprivate static let oneSecStanfordStudyState = LocalPreferenceKey( + .init("state", in: .speziOneSec), + default: .available + ) + fileprivate static let didInitiateBulkExport = LocalPreferenceKey( + .init("didInitiateBulkExport", in: .speziOneSec), + default: false + ) } From 57be20680d9c94b8450f85bd57402714c40db107 Mon Sep 17 00:00:00 2001 From: Lukas Kollmer Date: Sun, 12 Jul 2026 22:24:36 +0200 Subject: [PATCH 12/12] uhh --- Sources/OneSecStanfordStudy/OneSecStanfordStudy.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Sources/OneSecStanfordStudy/OneSecStanfordStudy.swift b/Sources/OneSecStanfordStudy/OneSecStanfordStudy.swift index 1e496f1..fa12e1d 100644 --- a/Sources/OneSecStanfordStudy/OneSecStanfordStudy.swift +++ b/Sources/OneSecStanfordStudy/OneSecStanfordStudy.swift @@ -177,13 +177,11 @@ extension BulkExportSessionIdentifier { fileprivate static let oneSecStanfordStudy = Self("edu.stanford.OneSecStanfordStudy") } - @available(iOS 18, *) extension LocalPreferenceKeys.Namespace { fileprivate static let speziOneSec: Self = .custom("edu.stanford.SpeziOneSec") } - @available(iOS 18, *) extension LocalPreferenceKeys { fileprivate static let oneSecStanfordStudyState = LocalPreferenceKey(