diff --git a/Package.swift b/Package.swift index 80d741e..298c990 100644 --- a/Package.swift +++ b/Package.swift @@ -23,24 +23,31 @@ let package = Package( .library(name: "OneSecStanfordStudy", targets: ["OneSecStanfordStudy"]) ], dependencies: [ - .package(url: "https://github.com/SchmiedmayerLab/Spezi.git", exact: "0.1.7") + .package( + url: "https://github.com/SchmiedmayerLab/Grove.git", + branch: "feature/healthkit-export-integrity", + 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: "Grove", package: "Grove"), + .product(name: "GroveFoundation", package: "Grove"), + .product(name: "GroveHealthKit", package: "Grove"), + .product(name: "GroveHealthKitBulkExport", package: "Grove"), + .product(name: "GroveHealthKitFHIR", package: "Grove"), + .product(name: "FHIRModelsExtensions", package: "Grove") ], swiftSettings: swiftSettings ), .testTarget( name: "OneSecStanfordStudyTests", dependencies: [ - .target(name: "OneSecStanfordStudy") + .target(name: "OneSecStanfordStudy"), + .product(name: "GroveTesting", package: "Grove"), + .product(name: "GroveLocalStorage", package: "Grove") ], swiftSettings: swiftSettings ) diff --git a/README.md b/README.md index 842240b..ea1fcbc 100644 --- a/README.md +++ b/README.md @@ -11,37 +11,29 @@ SPDX-License-Identifier: MIT # OneSecStanfordStudy [![Build and Test](https://github.com/SchmiedmayerLab/OneSecStanfordStudy/actions/workflows/ci.yml/badge.svg)](https://github.com/SchmiedmayerLab/OneSecStanfordStudy/actions/workflows/ci.yml) +[![Codecov](https://codecov.io/gh/SchmiedmayerLab/OneSecStanfordStudy/branch/main/graph/badge.svg)](https://codecov.io/gh/SchmiedmayerLab/OneSecStanfordStudy) [![REUSE status](https://api.reuse.software/badge/github.com/SchmiedmayerLab/OneSecStanfordStudy)](https://api.reuse.software/info/github.com/SchmiedmayerLab/OneSecStanfordStudy) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE.md) 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 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. - +Supports iOS 15 and newer. Study features require iOS 18; initialization and the view modifier do nothing on earlier versions. ## 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 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: +Use a branch dependency for this package. Its Grove dependency uses the iOS 15 configuration with optional traits disabled: ```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: []) +.package( + url: "https://github.com/SchmiedmayerLab/Grove.git", + branch: "feature/healthkit-export-integrity", + traits: [] +) ``` -Then add the product dependency to the target that needs it: +Add the `OneSecStanfordStudy` product to your app target: ```swift .target( @@ -55,7 +47,7 @@ Then add the product dependency to the target that needs it: ## Usage -Call `initializeOneSecStanfordStudy(_:launchOptions:healthExportConfig:)` from your app delegate's `application(_:willFinishLaunchingWithOptions:)` method: +Initialize the study in your app delegate: ```swift import OneSecStanfordStudy @@ -71,13 +63,12 @@ final class AppDelegate: NSObject, UIApplicationDelegate { launchOptions: launchOptions, healthExportConfig: HealthExportConfiguration( destination: healthExportDirectory, - sampleTypes: sampleTypes, timeRange: timeRange, - didStartExport: { files in - // Upload or process the generated files. + didStartLocalExport: { attemptID, files in + // Consume files in a Task and save an upload job for each URL. }, - didEndExport: { - // Handle completion. + didFinishLocalExport: { result in + // Record result.attemptID and result.outcome. } ) ) @@ -86,7 +77,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate { } ``` -Apply `.oneSecStanfordStudy()` to the root of your SwiftUI hierarchy: +Apply the view modifier at the root of your SwiftUI hierarchy: ```swift WindowGroup { @@ -95,18 +86,70 @@ WindowGroup { } ``` -The runtime is configured directly and `OneSecStanfordStudyModule` is available through SwiftUI environment injection: +On iOS 18 and newer, access the module through the environment: ```swift @Environment(OneSecStanfordStudyModule.self) private var oneSec ``` +Use `makeOneSecStanfordStudySheet()` to present the survey. +Use `updateState(.available)` to reset enrollment in TestFlight. Export progress and files are kept. +Call module methods on `MainActor`. -## Testing +### Exports + +The package writes HealthKit data to local files. Your app uploads them and tracks backend receipts. + +- `didStartLocalExport(attemptID, files)` provides the file stream. Consume it in an app-owned task and save upload jobs as files arrive. +- `didFinishLocalExport(result)` reports the local outcome. It can arrive before file consumption or uploads finish, or without a start callback if startup fails. + +Both callbacks run on `MainActor`. Each started attempt gets a new ID; associate it with the participant and upload jobs. -The package includes unit tests in `Tests/OneSecStanfordStudyTests` and a consolidated iOS UI test app in `Tests/UITests`. +| Outcome | Meaning | +|---|---| +| `.succeeded(summary)` | All batches succeeded and progress was saved. | +| `.incomplete(summary)` | Processing paused or batches remain unfinished. | +| `.failedToPersist(summary, error)` | Saving progress failed. Check `error.category` before retrying. | +| `.cancelled(reason)` | The attempt was reset, terminated or cancelled. | +| `.failedToStart(error)` | Startup failed. The error is also thrown to the caller. | + +Batch counts cover the whole session, including earlier attempts. Empty queries count as successful and produce no file, including when HealthKit read access is denied. +Survey completion, local export success and confirmed uploads are separate. Report outcomes and upload status to your backend as needed. + +Keep files and upload jobs across attempts: resumed streams only yield newly processed files. +Callbacks are not saved or replayed after app termination, and starting an already-running or completed session produces no new callbacks. + +### Recovering an export + +Unfinished exports retry on the next launch, including failed starts. To retry sooner, resolve the error and call `triggerHealthExport()`. +There is no automatic retry loop. Storage errors may require freeing space, waiting for availability or correcting permissions; invalid configuration needs fixing. + +A normal retry keeps completed batches and the original sample types and date range. +After a failed progress save, restarting the app may repeat batches. Deduplicate samples by participant ID and HealthKit UUID. + +Use `triggerHealthExport(forceSessionReset: true)` to start over or change the export configuration. +Reset reports cancellation for an active attempt; its file stream may still be draining. + +### Sample types + +The default study sample types are listed below. Pass `sampleTypes` to override them; types Grove cannot represent fail startup. + +| Protocol measure | HealthKit type | +|---|---| +| Time in bed and awake/REM/core/deep sleep | `HKCategoryType(.sleepAnalysis)` | +| Steps | `HKQuantityType(.stepCount)` | +| Walking/running distance | `HKQuantityType(.distanceWalkingRunning)` | +| Flights climbed | `HKQuantityType(.flightsClimbed)` | +| Active energy | `HKQuantityType(.activeEnergyBurned)` | +| Workouts | `HKObjectType.workoutType()` | +| State of mind | `HKObjectType.stateOfMindType()` | +| Time in daylight | `HKQuantityType(.timeInDaylight)` | + + +## Testing -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. +Unit tests are in `Tests/OneSecStanfordStudyTests`. +Run UI tests with the `TestApp` scheme in `Tests/UITests/UITests.xcodeproj`; they cover app launch and web view alerts and confirmations. ## Contributing diff --git a/Sources/OneSecStanfordStudy/HKSampleToFHIRProcessor.swift b/Sources/OneSecStanfordStudy/HKSampleToFHIRProcessor.swift index fc0e323..85e43d2 100644 --- a/Sources/OneSecStanfordStudy/HKSampleToFHIRProcessor.swift +++ b/Sources/OneSecStanfordStudy/HKSampleToFHIRProcessor.swift @@ -6,14 +6,13 @@ // SPDX-License-Identifier: MIT // -// swiftlint:disable line_length - +private import FHIRModelsExtensions import Foundation -private import HealthKitOnFHIR +private import GroveHealthKitFHIR private import ModelsR4 -private import SpeziFoundation -import SpeziHealthKit -import SpeziHealthKitBulkExport +private import GroveFoundation +import GroveHealthKit +import GroveHealthKitBulkExport @available(iOS 18, *) @@ -28,30 +27,37 @@ struct HKSampleToFHIRProcessor: BatchProcessor { } private func storeSamples(_ samples: consuming [Sample], of sampleType: SampleType) throws -> URL { - let resources = try (consume samples).mapIntoResourceProxies() - for resource in resources { - resource.get(if: ModelsR4::DomainResource.self)?.stripDeviceNameMetadata() + var resources = try samples.mapIntoResourceProxies() + for index in resources.indices { + // Preserve absolute instants through the repeated DST hour. + if var observation = resources[index].get(if: ModelsR4::Observation.self) { + try observation.setEffective(startDate: samples[index].startDate, endDate: samples[index].endDate, timeZone: .gmt) + resources[index] = ResourceProxy(with: observation) + } + if var resource = resources[index].get() as? any ModelsR4::DomainResource { + resource.stripDeviceNameMetadata() + resources[index] = ResourceProxy(with: resource) + } } let encoded = try JSONEncoder().encode(consume resources) let compressed = try (consume encoded).compressed(using: Zlib.self) let compressedUrl = outputDirectory.appendingPathComponent("\(sampleType.id)_\(UUID().uuidString).json.zlib") - try (consume compressed).write(to: compressedUrl) + try (consume compressed).write(to: compressedUrl, options: .atomic) return compressedUrl } } +@available(iOS 18, *) extension ModelsR4::DomainResource { - nonisolated(unsafe) private static let sourceRevisionUrl = "https://bdh.stanford.edu/fhir/defs/sourceRevision".asFHIRURIPrimitive()! - nonisolated(unsafe) private static let sourceRevisionSourceUrl = "https://bdh.stanford.edu/fhir/defs/sourceRevision/source".asFHIRURIPrimitive()! - nonisolated(unsafe) private static let sourceRevisionSourceDeviceNameUrl = "https://bdh.stanford.edu/fhir/defs/sourceRevision/source/name".asFHIRURIPrimitive()! - /// Removes the `HKSourceRevision.source.name` metadata field from the resource, if it exists. - func stripDeviceNameMetadata() { - for sourceRevisionExt in self.extensions(for: Self.sourceRevisionUrl) { - for sourceExt in sourceRevisionExt.extensions(for: Self.sourceRevisionSourceUrl) { - sourceExt.removeAllExtensions(withUrl: Self.sourceRevisionSourceDeviceNameUrl) - } + mutating func stripDeviceNameMetadata() { + let revisionURL = FHIRExtensionURL.sourceRevision + let sourceURL = revisionURL.appending(component: "source") + guard let revision = self.extension?.firstIndex(where: { $0.url == revisionURL.r4 }), + let source = self.extension?[revision].extension?.firstIndex(where: { $0.url == sourceURL.r4 }) else { + return } + self.extension?[revision].extension?[source].removeAllExtensions(withUrl: sourceURL.appending(component: "name").r4) } } diff --git a/Sources/OneSecStanfordStudy/HealthExportAttempt.swift b/Sources/OneSecStanfordStudy/HealthExportAttempt.swift new file mode 100644 index 0000000..d9f8e74 --- /dev/null +++ b/Sources/OneSecStanfordStudy/HealthExportAttempt.swift @@ -0,0 +1,89 @@ +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// SPDX-License-Identifier: MIT +// + +import Foundation +import GroveHealthKitBulkExport +import Observation + +/// Delivers at most one start and one terminal result for an in-process attempt. +@MainActor +final class HealthExportAttempt { + let id = UUID() + private let configuration: HealthExportConfiguration + private var didStart = false + private(set) var didFinish = false + private var isResetting = false + private var deferredCompletion: (@MainActor () -> Void)? + + init(configuration: HealthExportConfiguration) { + self.configuration = configuration + } + + func start(files: AnyAsyncSequence) { + guard !didStart && !didFinish else { return } + didStart = true + configuration.didStartLocalExport(id, files) + } + + @available(iOS 18, *) + func trackCompletion( + of session: some BulkExportSession, + willFinish: @escaping @MainActor (HealthExportResult.Outcome) -> Void + ) { + guard !didFinish else { return } + if isResetting { + deferredCompletion = { self.trackCompletion(of: session, willFinish: willFinish) } + return + } + let state = session.state + if state == .running { + withObservationTracking { + _ = session.state + } onChange: { + Task { @MainActor in + self.trackCompletion(of: session, willFinish: willFinish) + } + } + return + } + let outcome: HealthExportResult.Outcome + if state == .terminated { + outcome = .cancelled(.sessionTerminated) + } else { + let summary = HealthExportBatchSummary( + totalBatches: session.numTotalBatches, + completedBatches: session.completedBatches.count, + failedBatches: session.failedBatches.count, + pendingBatches: session.pendingBatches.count + ) + if case .paused(reason: .failure(.checkpointWriteFailed(let error))) = state { + outcome = .failedToPersist(summary, error) + } else { + outcome = state == .completed && summary.allBatchesSucceeded ? .succeeded(summary) : .incomplete(summary) + } + } + willFinish(outcome) + finish(outcome) + } + + func resetSession(_ reset: @MainActor () async throws -> Void) async throws { + isResetting = true + defer { + isResetting = false + let completion = deferredCompletion + deferredCompletion = nil + completion?() + } + // Deletion may terminate the session before throwing a storage error. + try await reset() + finish(.cancelled(.sessionReset)) + } + + func finish(_ outcome: HealthExportResult.Outcome) { + guard !didFinish else { return } + didFinish = true + configuration.didFinishLocalExport(HealthExportResult(attemptID: id, outcome: outcome)) + } +} diff --git a/Sources/OneSecStanfordStudy/HealthExportBatchSummary.swift b/Sources/OneSecStanfordStudy/HealthExportBatchSummary.swift new file mode 100644 index 0000000..a203f12 --- /dev/null +++ b/Sources/OneSecStanfordStudy/HealthExportBatchSummary.swift @@ -0,0 +1,28 @@ +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// SPDX-License-Identifier: MIT +// + +/// Session-wide batch counts, including work completed by earlier attempts. +public struct HealthExportBatchSummary: Codable, Sendable, Equatable { + /// All batches in the session. + public let totalBatches: Int + /// Successful batches, including empty queries that produced no file. + public let completedBatches: Int + /// Attempted batches that failed. + public let failedBatches: Int + /// Batches still awaiting processing, excluding failed batches. + public let pendingBatches: Int + + /// Whether every batch succeeded. Empty sample queries count as successful. + public var allBatchesSucceeded: Bool { + totalBatches > 0 && completedBatches == totalBatches && failedBatches == 0 && pendingBatches == 0 + } + + public init(totalBatches: Int, completedBatches: Int, failedBatches: Int, pendingBatches: Int) { + self.totalBatches = totalBatches + self.completedBatches = completedBatches + self.failedBatches = failedBatches + self.pendingBatches = pendingBatches + } +} diff --git a/Sources/OneSecStanfordStudy/HealthExportConfiguration+SampleTypes.swift b/Sources/OneSecStanfordStudy/HealthExportConfiguration+SampleTypes.swift new file mode 100644 index 0000000..5f29822 --- /dev/null +++ b/Sources/OneSecStanfordStudy/HealthExportConfiguration+SampleTypes.swift @@ -0,0 +1,30 @@ +// +// This source file is part of the One Sec Stanford Study open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +public import HealthKit + +extension HealthExportConfiguration { + /// The default study sample types available on the current OS. + public static var defaultSampleTypes: Set { + var types: Set = [ + HKCategoryType(.sleepAnalysis), + HKQuantityType(.stepCount), + HKQuantityType(.distanceWalkingRunning), + HKQuantityType(.flightsClimbed), + HKQuantityType(.activeEnergyBurned), + HKObjectType.workoutType() + ] + if #available(iOS 17, *) { + types.insert(HKQuantityType(.timeInDaylight)) + } + if #available(iOS 18, *) { + types.insert(HKObjectType.stateOfMindType()) + } + return types + } +} diff --git a/Sources/OneSecStanfordStudy/HealthExportConfiguration.swift b/Sources/OneSecStanfordStudy/HealthExportConfiguration.swift new file mode 100644 index 0000000..87cc2cf --- /dev/null +++ b/Sources/OneSecStanfordStudy/HealthExportConfiguration.swift @@ -0,0 +1,69 @@ +// +// This source file is part of the One Sec Stanford Study open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +public import Foundation +import GroveHealthKit +public import HealthKit + +public struct HealthExportConfiguration: Sendable { + /// Called once an attempt opens its file stream; URLs arrive as nonempty batches succeed. + /// Consume the stream in an app-owned task. This callback does not wait for any files or uploads. + /// + /// - Parameters: + /// - attemptID: Identifies this attempt and its final result. + /// - files: An `AsyncSequence` yielding the URLs of local files created from individual export batches. + public typealias DidStartLocalExport = @Sendable @MainActor (_ attemptID: UUID, _ files: AnyAsyncSequence) -> Void + + /// Called once per attempt with its terminal local-processing outcome, including checkpoint failures. + /// It does not wait for the file consumer or uploads. Startup failure has no preceding start callback. + public typealias DidFinishLocalExport = @Sendable @MainActor (_ result: HealthExportResult) -> 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 invoked when an attempt opens its local file stream. + public let didStartLocalExport: DidStartLocalExport + /// Callback invoked with the attempt's final local-processing outcome. + public let didFinishLocalExport: DidFinishLocalExport + + /// Create a configuration with handlers for file delivery and local processing outcomes. + public init( + destination: URL, + sampleTypes: Set = HealthExportConfiguration.defaultSampleTypes, + timeRange: Range, + didStartLocalExport: @escaping DidStartLocalExport, + didFinishLocalExport: @escaping DidFinishLocalExport + ) { + self.destination = destination + self.sampleTypes = sampleTypes + self.timeRange = timeRange + self.didStartLocalExport = didStartLocalExport + self.didFinishLocalExport = didFinishLocalExport + } +} + + +extension HealthExportConfiguration { + /// Validates the destination, sample types, and time range. + func validate() throws { + guard destination.isFileURL else { throw ValidationError.invalidDestination } + guard !timeRange.isEmpty else { throw ValidationError.emptyTimeRange } + guard !sampleTypes.isEmpty else { throw ValidationError.emptySampleTypes } + guard sampleTypes.allSatisfy({ $0 is HKSampleType }) else { throw ValidationError.unsupportedObjectType } + if #available(iOS 18, *) { + guard sampleTypes.allSatisfy({ $0.sampleType != nil }) else { throw ValidationError.unsupportedObjectType } + } + } + + enum ValidationError: Error { + case invalidDestination, emptyTimeRange, emptySampleTypes, unsupportedObjectType + } +} diff --git a/Sources/OneSecStanfordStudy/HealthExportResult.swift b/Sources/OneSecStanfordStudy/HealthExportResult.swift new file mode 100644 index 0000000..0099e27 --- /dev/null +++ b/Sources/OneSecStanfordStudy/HealthExportResult.swift @@ -0,0 +1,38 @@ +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// SPDX-License-Identifier: MIT +// + +public import Foundation +public import GroveHealthKitBulkExport + +/// The terminal result of one local export attempt; it does not describe upload status. +public struct HealthExportResult: Sendable { + public enum Outcome: Sendable { + /// Every configured batch succeeded and the final checkpoint was stored. + case succeeded(HealthExportBatchSummary) + /// The session paused, or its batch counts do not establish completion. + case incomplete(HealthExportBatchSummary) + /// The final checkpoint could not be stored; retain files and retry state. + case failedToPersist(HealthExportBatchSummary, CheckpointWriteFailure) + /// The attempt was cancelled; files already emitted may still need uploading. + case cancelled(CancellationReason) + /// No file stream was opened. The error is also thrown to the initiating caller. + case failedToStart(any Error) + } + + public enum CancellationReason: Sendable { + case sessionReset + case sessionTerminated + case taskCancelled + } + + /// Matches the ID passed to `didStartLocalExport`; each retry receives a new ID. + public let attemptID: UUID + public let outcome: Outcome + + public init(attemptID: UUID, outcome: Outcome) { + self.attemptID = attemptID + self.outcome = outcome + } +} diff --git a/Sources/OneSecStanfordStudy/HealthExportStartCoordinator.swift b/Sources/OneSecStanfordStudy/HealthExportStartCoordinator.swift new file mode 100644 index 0000000..46e25b3 --- /dev/null +++ b/Sources/OneSecStanfordStudy/HealthExportStartCoordinator.swift @@ -0,0 +1,43 @@ +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// SPDX-License-Identifier: MIT +// + +import Foundation + +/// Coalesces starts, preserving a reset requested while ordinary startup is in flight. +@MainActor +final class HealthExportStartCoordinator { + private struct Start { + let id: UUID + let forceSessionReset: Bool + let task: Task + } + + private var current: Start? + + func start( + forceSessionReset: Bool, + operation: @escaping @MainActor (Bool) async throws -> Void + ) -> Task { + if let current, !forceSessionReset || current.forceSessionReset { + return current.task + } + let predecessor = current?.task + let id = UUID() + let task = Task { @MainActor in + defer { + if self.current?.id == id { + self.current = nil + } + } + // A failed ordinary start must not discard the queued reset. + if let predecessor { + _ = await predecessor.result + } + try await operation(forceSessionReset) + } + current = Start(id: id, forceSessionReset: forceSessionReset, task: task) + return task + } +} diff --git a/Sources/OneSecStanfordStudy/Integration.swift b/Sources/OneSecStanfordStudy/Integration.swift index a7cb336..3c599de 100644 --- a/Sources/OneSecStanfordStudy/Integration.swift +++ b/Sources/OneSecStanfordStudy/Integration.swift @@ -26,7 +26,7 @@ public func initializeOneSecStanfordStudy( guard #available(iOS 18, *) else { return } - OneSecStanfordStudy.initialize(application: application, launchOptions: launchOptions, healthExportConfig: healthExportConfig) + OneSecStanfordStudyModule.initialize(application: application, launchOptions: launchOptions, healthExportConfig: healthExportConfig) } extension View { @@ -37,15 +37,9 @@ extension View { @ViewBuilder public func oneSecStanfordStudy() -> some View { if #available(iOS 18, *) { - OneSecStanfordStudy.studyIntegrationViewModifier.applying(to: self) + OneSecStanfordStudyModule.integrate(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 deleted file mode 100644 index 3969fbf..0000000 --- a/Sources/OneSecStanfordStudy/OneSecStanfordStudy.swift +++ /dev/null @@ -1,195 +0,0 @@ -// -// This source file is part of the One Sec Stanford Study 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 SpeziFoundation -private import SpeziHealthKit -private import SpeziHealthKitBulkExport -import OSLog -import SwiftUI -import UIKit - -/// The OneSecStanfordStudy module. -@available(iOS 18, *) -@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 - - 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) { - 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(prefsStore[.oneSecStanfordStudyState]) - Task { - do { - 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() - if session.state != .completed { - try await triggerHealthExport(forceSessionReset: false) - } - } - } catch { - logger.error("\(error)") - } - } - } - - override func updateState(_ newState: OneSecStanfordStudyModule.State) { - if newState != state { - prefsStore[.oneSecStanfordStudyState] = newState - } - 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) - prefsStore[.didInitiateBulkExport] = 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 { - prefsStore[.didInitiateBulkExport] = false - healthExportConfig.didEndExport() - } - } -} - -// MARK: App Delegate and Standard - -@available(iOS 18, *) -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 18, *) -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 18, *) -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( - .init("state", in: .speziOneSec), - default: .available - ) - fileprivate static let didInitiateBulkExport = LocalPreferenceKey( - .init("didInitiateBulkExport", in: .speziOneSec), - default: false - ) -} diff --git a/Sources/OneSecStanfordStudy/OneSecStanfordStudyModule.swift b/Sources/OneSecStanfordStudy/OneSecStanfordStudyModule.swift index 9ff4d8c..40e2e58 100644 --- a/Sources/OneSecStanfordStudy/OneSecStanfordStudyModule.swift +++ b/Sources/OneSecStanfordStudy/OneSecStanfordStudyModule.swift @@ -6,14 +6,20 @@ // SPDX-License-Identifier: MIT // -// swiftlint:disable unavailable_function file_types_order +// swiftlint:disable file_types_order -public import HealthKit +public import Grove +import GroveFoundation +private import GroveHealthKit +import GroveHealthKitBulkExport +import OSLog public import SwiftUI -public import UIKit +import UIKit /// The Stanford study integration for the one sec app. /// +/// Available through the SwiftUI environment after initialization. +/// /// ## Topics /// /// ### Instance Properties @@ -22,10 +28,12 @@ public import UIKit /// /// ### Instance Methods /// - ``makeOneSecStanfordStudySheet()`` +/// - ``updateState(_:)`` +/// - ``triggerHealthExport(forceSessionReset:)`` @available(iOS 18, *) @Observable @MainActor -open class OneSecStanfordStudyModule: NSObject { +public final class OneSecStanfordStudyModule: Module, EnvironmentAccessible { 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) @@ -38,83 +46,231 @@ open class OneSecStanfordStudyModule: NSObject { case awaitingParentalConsent /// The study is currently active. case active - /// The study has been completed. + /// The survey flow has 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. + /// The URL of the survey the user should fill out to enroll in the study. /// - /// This URL should be constructed by the app, based on the survey and the token obtained from REDCap. + /// The host app constructs this URL from the survey and the participant's token obtained from REDCap. public var surveyUrl: URL? - /// The current state of the integration. + /// The current survey state. public private(set) var state: State = .unavailable - @_documentation(visibility: internal) - override public nonisolated init() {} - @_spi(APISupport) - open class func initialize( + private static var appDelegate: OneSecStanfordStudyAppDelegate? // swiftlint:disable:this weak_delegate + + static func integrate(_ view: some View) -> some View { + guard let appDelegate else { + preconditionFailure("Call initializeOneSecStanfordStudy before applying .oneSecStanfordStudy()") + } + return view.grove(appDelegate) + } + + @ObservationIgnored @Application(\.logger) var logger + @ObservationIgnored @Dependency(HealthKit.self) private var healthKit + @ObservationIgnored @Dependency(BulkHealthExporter.self) private var bulkExporter + + nonisolated private let healthExportConfig: HealthExportConfiguration + nonisolated(unsafe) private let fileManager = FileManager.default + private let prefsStore: LocalPreferencesStore + @ObservationIgnored private let exportStartCoordinator = HealthExportStartCoordinator() + /// Identity rejects stale completion callbacks from previous attempts. + @ObservationIgnored private var activeExportAttempt: HealthExportAttempt? + + + /// Creates the module with its export configuration and preference store. + nonisolated init(healthExportConfig: HealthExportConfiguration, preferences: LocalPreferencesStore = .standard) { + self.healthExportConfig = healthExportConfig + self.prefsStore = preferences + } + + static func initialize( application: UIApplication, launchOptions: [UIApplication.LaunchOptionsKey: Any]?, // swiftlint:disable:this discouraged_optional_collection healthExportConfig: HealthExportConfiguration ) { - fatalError("implemented in OneSecStanfordStudy") + let appDelegate = OneSecStanfordStudyAppDelegate(healthExportConfig: healthExportConfig) + _ = appDelegate.application(application, willFinishLaunchingWithOptions: launchOptions) + self.appDelegate = appDelegate } - open func makeOneSecStanfordStudySheet() -> AnyView { - fatalError("implemented in OneSecStanfordStudy") + public func configure() { + updateState(prefsStore[.oneSecStanfordStudyState]) + Task { + do { + 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. + try await triggerHealthExport() + } + } catch { + logger.error("\(error)") + } + } } - @_spi(APISupport) - open func updateState(_ newState: State) { + /// Updates and persists the survey state. Use `.available` to reset enrollment for testing. + /// This does not cancel or reset an export, delete exported files, or change upload state. + public func updateState(_ newState: State) { + if newState != state { + prefsStore[.oneSecStanfordStudyState] = newState + } state = newState } - @_spi(APISupport) - open func triggerHealthExport(forceSessionReset: Bool) async throws { - fatalError("implemented in OneSecStanfordStudy") + /// Presents the enrollment survey using `surveyUrl`. + public func makeOneSecStanfordStudySheet() -> AnyView { + AnyView(StudySurveySheet()) + } + + // MARK: HealthKit Data Collection + + /// Starts or resumes the export, retrying failed batches. Set `forceSessionReset` only for an intentional restart. + /// Files and the final processing result are delivered through the configured callbacks. + public func triggerHealthExport(forceSessionReset: Bool = false) async throws { + let task = exportStartCoordinator.start(forceSessionReset: forceSessionReset) { forceSessionReset in + try await self.startHealthExport(forceSessionReset: forceSessionReset) + } + try await task.value + } + + private func startHealthExport(forceSessionReset: Bool) async throws { + let attempt = HealthExportAttempt(configuration: healthExportConfig) + // Retain the request even if validation, authorization, or session setup fails. + prefsStore[.didInitiateBulkExport] = true + do { + try Task.checkCancellation() + try healthExportConfig.validate() + if forceSessionReset { + let reset: @MainActor () async throws -> Void = { + try await self.bulkExporter.deleteSessionRestorationInfo(for: .oneSecStanfordStudy) + } + if let previousAttempt = activeExportAttempt { + try await previousAttempt.resetSession(reset) + if activeExportAttempt === previousAttempt { + activeExportAttempt = nil + } + } else { + try await reset() + } + } + try fileManager.createDirectory(at: healthExportConfig.destination, withIntermediateDirectories: true) + try await healthKit.askForAuthorization(for: .init(read: healthExportConfig.sampleTypes)) + try Task.checkCancellation() + let session = try await healthExportSession() + try Task.checkCancellation() + guard session.state != .running else { return } + if clearRestorationFlagIfCompleted(session) { + return + } + // A retry can arrive before the previous attempt's queued completion callback. + if let previousAttempt = activeExportAttempt { + trackCompletion(of: session, attempt: previousAttempt) + } + let stream = try session.start(retryFailedBatches: true, concurrencyLevel: .limit(4)) + activeExportAttempt = attempt + attempt.start(files: AnyAsyncSequence(stream.compactMap(\.self))) + trackCompletion(of: session, attempt: attempt) + } catch { + if error is CancellationError { + attempt.finish(.cancelled(.taskCancelled)) + } else { + attempt.finish(.failedToStart(error)) + } + throw error + } + } + + func clearRestorationFlagIfCompleted(_ session: some BulkExportSession) -> Bool { + guard session.state == .completed, + session.numTotalBatches > 0, session.completedBatches.count == session.numTotalBatches, + session.failedBatches.isEmpty, session.pendingBatches.isEmpty else { + return false + } + prefsStore[.didInitiateBulkExport] = false + return true + } + + /// 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, attempt: HealthExportAttempt) { + attempt.trackCompletion(of: session) { [self] outcome in + guard activeExportAttempt === attempt else { return } + activeExportAttempt = nil + switch outcome { + case .succeeded: + prefsStore[.didInitiateBulkExport] = false + case .incomplete(let summary): + // Keep launch restoration enabled so failed batches remain retryable. + logger.error("Health export incomplete: \(summary.failedBatches) failed, \(summary.pendingBatches) pending batches") + case .failedToPersist(_, let error): + logger.error("Health export checkpoint failed: \(error)") + case .cancelled, .failedToStart: + break + } + } } + } -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 +// MARK: App Delegate and Standard + +@available(iOS 18, *) +private final class OneSecStanfordStudyAppDelegate: GroveAppDelegate { + private let healthExportConfig: HealthExportConfiguration + + override var configuration: Configuration { + Configuration(standard: OneSecStanfordStudyStandard()) { + HealthKit() + BulkHealthExporter() + OneSecStanfordStudyModule(healthExportConfig: healthExportConfig) + } } + + init(healthExportConfig: HealthExportConfiguration) { + self.healthExportConfig = healthExportConfig + } +} + +@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 {} +} + +// MARK: Utils + +@available(iOS 18, *) +extension BulkExportSessionIdentifier { + fileprivate static let oneSecStanfordStudy = Self("edu.stanford.OneSecStanfordStudy") +} + +@available(iOS 18, *) +extension LocalPreferenceKeys.Namespace { + // Preserve the namespace used by existing installations. + fileprivate static let oneSecStudy: Self = .custom("edu.stanford.SpeziOneSec") +} + +@available(iOS 18, *) +extension LocalPreferenceKeys { + fileprivate static let oneSecStanfordStudyState = LocalPreferenceKey( + .init("state", in: .oneSecStudy), + default: .available + ) + fileprivate static let didInitiateBulkExport = LocalPreferenceKey( + .init("didInitiateBulkExport", in: .oneSecStudy), + default: false + ) } diff --git a/Sources/OneSecStanfordStudy/StudySurveyCallback.swift b/Sources/OneSecStanfordStudy/StudySurveyCallback.swift new file mode 100644 index 0000000..4e944d1 --- /dev/null +++ b/Sources/OneSecStanfordStudy/StudySurveyCallback.swift @@ -0,0 +1,29 @@ +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// SPDX-License-Identifier: MIT +// + +import Foundation + +/// Supported survey callback URLs. +enum StudySurveyCallback: String { + // Keep callback URL values explicit so renaming a case cannot change the URL. + // swiftlint:disable redundant_string_enum_value + case success = "success" + case noteligible = "noteligible" + case waitingforconsent = "waitingforconsent" + // swiftlint:enable redundant_string_enum_value + + init?(url: URL) { + guard url.scheme?.lowercased() == "https", + url.host?.lowercased() == "one-sec.app" else { + return nil + } + let prefix = "/survey-callback/" + guard url.path.hasPrefix(prefix), + let callback = Self(rawValue: String(url.path.dropFirst(prefix.count))) else { + return nil + } + self = callback + } +} diff --git a/Sources/OneSecStanfordStudy/StudySurveySheet.swift b/Sources/OneSecStanfordStudy/StudySurveySheet.swift index be84aec..1791d56 100644 --- a/Sources/OneSecStanfordStudy/StudySurveySheet.swift +++ b/Sources/OneSecStanfordStudy/StudySurveySheet.swift @@ -6,13 +6,13 @@ // SPDX-License-Identifier: MIT // -private import Spezi +private import Grove import SwiftUI @available(iOS 18, *) struct StudySurveySheet: View { @Environment(\.dismiss) private var dismiss - @Environment(OneSecStanfordStudy.self) private var oneSecStanfordStudy + @Environment(OneSecStanfordStudyModule.self) private var oneSecStanfordStudy @State private var didCompleteInitialNavigation = false @State private var isShowingCancelAlert = false @@ -133,15 +133,15 @@ struct StudySurveySheet: View { } private func shouldNavigate(_ request: URLRequest) async -> Bool { - guard let url = request.url, url.host() == "one-sec.app" else { + guard let url = request.url, let callback = StudySurveyCallback(url: url) else { return true } - let path = url.path() - if path.contains("survey-callback/success") { + switch callback { + case .success: oneSecStanfordStudy.updateState(.completed) - } else if path.contains("survey-callback/noteligible") { + case .noteligible: oneSecStanfordStudy.updateState(.unavailable) - } else if path.contains("survey-callback/waitingforconsent") { + case .waitingforconsent: oneSecStanfordStudy.updateState(.awaitingParentalConsent) } isDone = true @@ -150,6 +150,11 @@ struct StudySurveySheet: View { } private func didNavigate(_ webView: WebViewProxy) async { + guard let expectedURL = oneSecStanfordStudy.surveyUrl, + let actualURL = webView.url, + actualURL.scheme == "https", + actualURL.host == expectedURL.host, + actualURL.port == expectedURL.port else { return } if await webView.pageContainsField(named: "healthkit_export_initiated") { oneSecStanfordStudy.updateState(.active) await initiateHealthExport() @@ -158,7 +163,7 @@ struct StudySurveySheet: View { private func initiateHealthExport() async { do { - try await oneSecStanfordStudy.triggerHealthExport(forceSessionReset: true) + try await oneSecStanfordStudy.triggerHealthExport() } catch { oneSecStanfordStudy.logger.error("Error initiating bulk health export: \(error)") healthExportErrorMessage = error.localizedDescription diff --git a/Sources/OneSecStanfordStudy/WebView.swift b/Sources/OneSecStanfordStudy/WebView.swift index 0270801..b8174d2 100644 --- a/Sources/OneSecStanfordStudy/WebView.swift +++ b/Sources/OneSecStanfordStudy/WebView.swift @@ -235,7 +235,9 @@ extension WebViewImpl { // MARK: WKNavigationDelegate func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy { - switch await parent.config.shouldNavigate(navigationAction.request) { + // Subframes must not change enrollment state or dismiss the survey. + guard navigationAction.targetFrame?.isMainFrame == true else { return .allow } + return switch await parent.config.shouldNavigate(navigationAction.request) { case true: navigationAction.shouldPerformDownload ? .download : .allow case false: diff --git a/Tests/OneSecStanfordStudyTests/ExportIntegrityTests.swift b/Tests/OneSecStanfordStudyTests/ExportIntegrityTests.swift new file mode 100644 index 0000000..11d9c8a --- /dev/null +++ b/Tests/OneSecStanfordStudyTests/ExportIntegrityTests.swift @@ -0,0 +1,147 @@ +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// SPDX-License-Identifier: MIT +// + +import FHIRModelsExtensions +import Foundation +import HealthKit +import GroveHealthKitFHIR +import ModelsR4 +import GroveFoundation +import GroveHealthKit +@testable import OneSecStanfordStudy +import Testing + +@Suite struct ExportIntegrityTests { + @Test @available(iOS 18, *) + func defaultTypesMatchDocumentedSet() throws { + let config = HealthExportConfiguration( + destination: URL(fileURLWithPath: "/tmp/export"), + timeRange: Date(timeIntervalSince1970: 0).. = [ + "HKCategoryTypeIdentifierSleepAnalysis", + "HKQuantityTypeIdentifierStepCount", + "HKQuantityTypeIdentifierDistanceWalkingRunning", + "HKQuantityTypeIdentifierFlightsClimbed", + "HKQuantityTypeIdentifierActiveEnergyBurned", + "HKWorkoutTypeIdentifier", + "HKDataTypeStateOfMind", + "HKQuantityTypeIdentifierTimeInDaylight" + ] + #expect(Set(config.sampleTypes.map(\.identifier)) == expected) + try config.validate() + } + + @Test func explicitTypesOverrideDefault() throws { + let config = HealthExportConfiguration( + destination: URL(fileURLWithPath: "/tmp/export"), + sampleTypes: [HKQuantityType(.stepCount)], + timeRange: Date(timeIntervalSince1970: 0)..( + .init("state", in: .custom("edu.stanford.SpeziOneSec")), default: .available + ) + let exportKey = LocalPreferenceKey( + .init("didInitiateBulkExport", in: .custom("edu.stanford.SpeziOneSec")), default: false + ) + let recorder = CallbackRecorder() + let module = OneSecStanfordStudyModule(healthExportConfig: recorder.configuration, preferences: preferences) + module.updateState(.completed) + preferences[exportKey] = true + + module.updateState(.available) + + #expect(module.state == .available) + let restoredPreferences = LocalPreferencesStore(defaults: try #require(UserDefaults(suiteName: suiteName))) + #expect(restoredPreferences[stateKey] == .available) + #expect(restoredPreferences[exportKey]) + #expect(recorder.startedIDs.isEmpty) + #expect(recorder.results.isEmpty) + } + + @Test(.timeLimit(.minutes(1))) @available(iOS 18, *) + func streamAndResultShareAttemptID() async throws { + let recorder = CallbackRecorder() + let attempt = HealthExportAttempt(configuration: recorder.configuration) + let (files, continuation) = AsyncStream.makeStream(of: URL.self) + let file = URL(fileURLWithPath: "/tmp/synthetic-export.json") + attempt.start(files: AnyAsyncSequence(files)) + attempt.start(files: AnyAsyncSequence(files)) + continuation.yield(file) + continuation.finish() + attempt.finish(.succeeded(Self.success)) + attempt.finish(.cancelled(.sessionReset)) + #expect(recorder.startedIDs == [attempt.id]) + #expect(recorder.results.map(\.attemptID) == [attempt.id]) + // A processing result does not mean the caller has consumed the file stream. + let stream = try #require(recorder.files) + var received: [URL] = [] + for try await url in stream { received.append(url) } + #expect(received == [file]) + } + + @Test(.timeLimit(.minutes(1)), arguments: [BulkExportSessionState.completed, .paused(reason: .failedBatches), .terminated]) + @available(iOS 18, *) + func reportsTerminalSessionState(_ terminalState: BulkExportSessionState) async throws { + let recorder = CallbackRecorder() + let session = StubExportSession() + let attempt = HealthExportAttempt(configuration: recorder.configuration) + attempt.start(files: AnyAsyncSequence(AsyncStream { $0.finish() })) + attempt.trackCompletion(of: session) { _ in } + switch terminalState { + case .completed: session.completedBatches = [Self.batch] + case .paused: session.failedBatches = [Self.batch] + case .terminated, .running: break + } + session.state = terminalState + var results = recorder.resultStream.makeAsyncIterator() + let result = try #require(await results.next()) + #expect(result.attemptID == attempt.id) + switch (terminalState, result.outcome) { + case (.completed, .succeeded(let summary)): #expect(summary == Self.success) + case (.paused, .incomplete(let summary)): + #expect(summary.failedBatches == 1) + #expect(!summary.allBatchesSucceeded) + case (.terminated, .cancelled(.sessionTerminated)): break + default: Issue.record("Unexpected terminal outcome") + } + attempt.trackCompletion(of: session) { _ in Issue.record("Duplicate completion") } + #expect(recorder.results.count == 1) + } + + @Test(.timeLimit(.minutes(1))) @available(iOS 18, *) + func checkpointFailureOverridesSuccessfulBatchCounts() async throws { + let recorder = CallbackRecorder() + let session = StubExportSession() + let attempt = HealthExportAttempt(configuration: recorder.configuration) + attempt.trackCompletion(of: session) { _ in } + session.completedBatches = [Self.batch] + session.state = .paused(reason: .failure(.checkpointWriteFailed(CheckpointWriteFailure(CocoaError(.fileWriteOutOfSpace))))) + var results = recorder.resultStream.makeAsyncIterator() + let result = try #require(await results.next()) + guard case .failedToPersist(let summary, let error) = result.outcome else { + Issue.record("A checkpoint failure must not report success") + return + } + #expect(summary == Self.success) + #expect(error.category == .insufficientSpace) + #expect(error.domain == NSCocoaErrorDomain) + #expect(error.code == CocoaError.fileWriteOutOfSpace.rawValue) + } + + @Test(.timeLimit(.minutes(1))) @available(iOS 18, *) + func requestedPauseDoesNotReportSuccessFromBatchCountsAlone() throws { + let recorder = CallbackRecorder() + let session = StubExportSession() + session.completedBatches = [Self.batch] + session.state = .paused(reason: .requested) + let attempt = HealthExportAttempt(configuration: recorder.configuration) + attempt.trackCompletion(of: session) { _ in } + guard case .incomplete(let summary) = try #require(recorder.results.first).outcome else { + Issue.record("A paused session must not report success") + return + } + #expect(summary == Self.success) + } + + @Test(.timeLimit(.minutes(1))) @available(iOS 18, *) + func resetSuppressesQueuedCompletionAndRetryGetsNewID() async throws { + let recorder = CallbackRecorder() + let session = StubExportSession() + let oldAttempt = HealthExportAttempt(configuration: recorder.configuration) + oldAttempt.start(files: AnyAsyncSequence(AsyncStream { $0.finish() })) + oldAttempt.trackCompletion(of: session) { _ in Issue.record("Stale completion") } + session.state = .paused(reason: .requested) // Queues a completion task on MainActor. + oldAttempt.finish(.cancelled(.sessionReset)) + session.state = .running + let retry = HealthExportAttempt(configuration: recorder.configuration) + retry.start(files: AnyAsyncSequence(AsyncStream { $0.finish() })) + retry.trackCompletion(of: session) { _ in } + session.completedBatches = [Self.batch] + session.state = .completed + var results = recorder.resultStream.makeAsyncIterator() + let cancelled = try #require(await results.next()) + let completed = try #require(await results.next()) + #expect(cancelled.attemptID == oldAttempt.id) + #expect(completed.attemptID == retry.id) + #expect(oldAttempt.id != retry.id) + guard case .cancelled(.sessionReset) = cancelled.outcome, + case .succeeded = completed.outcome else { + Issue.record("Expected reset cancellation followed by retry success") + return + } + #expect(recorder.results.count == 2) + } + + @Test(.timeLimit(.minutes(1))) @available(iOS 18, *) + func startupFailureReportsErrorWithoutOpeningStream() async throws { + let suiteName = "HealthExportLifecycleTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let recorder = CallbackRecorder() + let config = HealthExportConfiguration( + destination: try #require(URL(string: "https://example.invalid/export")), + timeRange: Self.timeRange, + didStartLocalExport: recorder.configuration.didStartLocalExport, + didFinishLocalExport: recorder.configuration.didFinishLocalExport + ) + let module = OneSecStanfordStudyModule(healthExportConfig: config, preferences: LocalPreferencesStore(defaults: defaults)) + await #expect(throws: HealthExportConfiguration.ValidationError.self) { + try await module.triggerHealthExport() + } + #expect(recorder.startedIDs.isEmpty) + #expect(recorder.results.count == 1) + let result = try #require(recorder.results.first) + guard case .failedToStart(let error) = result.outcome else { + Issue.record("Expected a startup failure") + return + } + #expect(error is HealthExportConfiguration.ValidationError) + } + + @Test(.timeLimit(.minutes(1)), arguments: [false, true]) @available(iOS 18, *) + func resetFailureRetainsActualSessionOutcome(terminatedBeforeFailure: Bool) async throws { + let recorder = CallbackRecorder() + let session = StubExportSession() + let attempt = HealthExportAttempt(configuration: recorder.configuration) + var completions = 0 + let willFinish: @MainActor (HealthExportResult.Outcome) -> Void = { _ in completions += 1 } + attempt.trackCompletion(of: session, willFinish: willFinish) + await #expect(throws: CocoaError.self) { + try await attempt.resetSession { + if terminatedBeforeFailure { session.state = .terminated } + attempt.trackCompletion(of: session, willFinish: willFinish) + #expect(recorder.results.isEmpty) + throw CocoaError(.fileWriteNoPermission) + } + } + if !terminatedBeforeFailure { + #expect(!attempt.didFinish) + #expect(recorder.results.isEmpty) + session.completedBatches = [Self.batch] + session.state = .completed + } + var results = recorder.resultStream.makeAsyncIterator() + let result = try #require(await results.next()) + switch (terminatedBeforeFailure, result.outcome) { + case (true, .cancelled(.sessionTerminated)), (false, .succeeded): break + default: Issue.record("Failed reset must preserve the actual session outcome") + } + attempt.trackCompletion(of: session, willFinish: willFinish) + #expect(completions == 1) + #expect(recorder.results.count == 1) + } + + @Test(.timeLimit(.minutes(1))) @available(iOS 18, *) + func successfulResetDefersTerminationCallback() async throws { + let recorder = CallbackRecorder() + let session = StubExportSession() + let attempt = HealthExportAttempt(configuration: recorder.configuration) + attempt.trackCompletion(of: session) { _ in Issue.record("Reset must own the terminal result") } + try await attempt.resetSession { + session.state = .terminated + attempt.trackCompletion(of: session) { _ in Issue.record("Premature termination") } + #expect(recorder.results.isEmpty) + } + let result = try #require(recorder.results.first) + guard case .cancelled(.sessionReset) = result.outcome else { + Issue.record("Expected successful reset cancellation") + return + } + attempt.trackCompletion(of: session) { _ in Issue.record("Duplicate completion") } + #expect(recorder.results.count == 1) + } + + @Test(.timeLimit(.minutes(1)), arguments: ["complete", "failed", "pending", "unaccounted", "empty", "checkpoint", "running"]) + @available(iOS 18, *) + func onlyFullyCompletedSessionClearsRestorationFlag(scenario: String) throws { + let suiteName = "HealthExportLifecycleTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let preferences = LocalPreferencesStore(defaults: defaults) + let key = LocalPreferenceKey( + .init("didInitiateBulkExport", in: .custom("edu.stanford.SpeziOneSec")), default: false + ) + preferences[key] = true + let module = OneSecStanfordStudyModule(healthExportConfig: CallbackRecorder().configuration, preferences: preferences) + let session = StubExportSession() + session.state = .completed + session.completedBatches = [Self.batch] + switch scenario { + case "failed": session.failedBatches = [Self.batch] + case "pending": session.pendingBatches = [Self.batch] + case "unaccounted": session.completedBatches = [] + case "empty": + session.numTotalBatches = 0 + session.completedBatches = [] + case "checkpoint": + session.state = .paused(reason: .failure(.checkpointWriteFailed(CheckpointWriteFailure(CocoaError(.fileWriteOutOfSpace))))) + case "running": session.state = .running + default: break + } + #expect(module.clearRestorationFlagIfCompleted(session) == (scenario == "complete")) + #expect(preferences[key] == (scenario != "complete")) + } + + private static let timeRange = Date(timeIntervalSince1970: 0)..? + var results: [HealthExportResult] = [] + let resultStream: AsyncStream + private let continuation: AsyncStream.Continuation + + init() { + (resultStream, continuation) = AsyncStream.makeStream(of: HealthExportResult.self) + } + + var configuration: HealthExportConfiguration { + HealthExportConfiguration( + destination: URL(fileURLWithPath: "/tmp/export"), + timeRange: Date(timeIntervalSince1970: 0).. AsyncStream { + state = .running + return AsyncStream { $0.finish() } + } + + func pause() async { state = .paused(reason: .requested) } + func _terminate() async { state = .terminated } // swiftlint:disable:this identifier_name +} diff --git a/Tests/OneSecStanfordStudyTests/HealthExportStartCoordinatorTests.swift b/Tests/OneSecStanfordStudyTests/HealthExportStartCoordinatorTests.swift new file mode 100644 index 0000000..a49486a --- /dev/null +++ b/Tests/OneSecStanfordStudyTests/HealthExportStartCoordinatorTests.swift @@ -0,0 +1,77 @@ +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// SPDX-License-Identifier: MIT +// + +@testable import OneSecStanfordStudy +import Testing + +@Suite @MainActor struct HealthExportStartCoordinatorTests { + @Test(.timeLimit(.minutes(1)), arguments: [false, true]) @available(iOS 16, *) + func resetWaitsForOrdinaryStartupAndCoalescesLaterRequests(firstStartFails: Bool) async throws { + let coordinator = HealthExportStartCoordinator() + let (started, startedContinuation) = AsyncStream.makeStream(of: Bool.self) + let (ordinaryGate, ordinaryContinuation) = AsyncStream.makeStream(of: Void.self) + let (resetGate, resetContinuation) = AsyncStream.makeStream(of: Void.self) + defer { + startedContinuation.finish() + ordinaryContinuation.finish() + resetContinuation.finish() + } + var calls: [Bool] = [] + var ordinaryFinished = false + let operation: @MainActor (Bool) async throws -> Void = { reset in + if reset { #expect(ordinaryFinished) } + defer { + if !reset { ordinaryFinished = true } + } + calls.append(reset) + startedContinuation.yield(reset) + for await _ in reset ? resetGate : ordinaryGate {} + if !reset && firstStartFails { throw StartupError.failed } + } + let ordinary = coordinator.start(forceSessionReset: false, operation: operation) + let duplicate = coordinator.start(forceSessionReset: false, operation: operation) + var events = started.makeAsyncIterator() + #expect(await events.next() == false) + let reset = coordinator.start(forceSessionReset: true, operation: operation) + let duplicateReset = coordinator.start(forceSessionReset: true, operation: operation) + #expect(calls == [false]) + ordinaryContinuation.finish() + #expect(await events.next() == true) + // The predecessor's cleanup must not clear the reset that is now running. + let laterReset = coordinator.start(forceSessionReset: true, operation: operation) + let laterOrdinary = coordinator.start(forceSessionReset: false, operation: operation) + resetContinuation.finish() + try await reset.value + try await duplicateReset.value + try await laterReset.value + try await laterOrdinary.value + for task in [ordinary, duplicate] { + if firstStartFails { + await #expect(throws: StartupError.self) { try await task.value } + } else { + try await task.value + } + } + #expect(calls == [false, true]) + } + + @Test(.timeLimit(.minutes(1))) @available(iOS 16, *) + func startupFailureAllowsAnotherAttempt() async throws { + let coordinator = HealthExportStartCoordinator() + let failed = coordinator.start(forceSessionReset: true) { _ in throw StartupError.failed } + await #expect(throws: StartupError.self) { try await failed.value } + var didRetry = false + let retry = coordinator.start(forceSessionReset: false) { reset in + #expect(!reset) + didRetry = true + } + try await retry.value + #expect(didRetry) + } +} + +private enum StartupError: Error { + case failed +} diff --git a/Tests/OneSecStanfordStudyTests/HealthExportStartupTests.swift b/Tests/OneSecStanfordStudyTests/HealthExportStartupTests.swift new file mode 100644 index 0000000..5938607 --- /dev/null +++ b/Tests/OneSecStanfordStudyTests/HealthExportStartupTests.swift @@ -0,0 +1,121 @@ +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// SPDX-License-Identifier: MIT +// + +import Foundation +import Grove +import GroveFoundation +import GroveHealthKit +import GroveHealthKitBulkExport +@testable import GroveLocalStorage +import GroveTesting +import HealthKit +@testable import OneSecStanfordStudy +import Testing + +@Suite @MainActor struct HealthExportStartupTests { + @Test(.timeLimit(.minutes(1)), arguments: [false, true], [false, true]) @available(iOS 18, *) + func directoryFailureReportsErrorAndRespectsReset(forceReset: Bool, destinationIsFile: Bool) async throws { + let namespace = StorageNamespace.custom("HealthExportStartupTests.\(UUID().uuidString)") + let storage = LocalStorage(namespace: namespace) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: directory) + try? FileManager.default.removeItem(at: namespace.containerURL()) + } + let blocker = directory.appendingPathComponent("file") + try Data("existing file".utf8).write(to: blocker) + let suiteName = "HealthExportStartupTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + var starts = 0 + var results: [HealthExportResult] = [] + let config = HealthExportConfiguration( + destination: destinationIsFile ? blocker : blocker.appendingPathComponent("export"), + sampleTypes: [HKQuantityType(.stepCount)], + timeRange: Date(timeIntervalSince1970: 0)..("HealthKit.bulkExport.edu.stanford.OneSecStanfordStudy", setting: .unencrypted()) + let existingCheckpoint = Data("existing checkpoint".utf8) + try storage.store(existingCheckpoint, for: checkpoint) + + var thrownError: NSError? + do { + try await module.triggerHealthExport(forceSessionReset: forceReset) + Issue.record("Export must fail when its output directory cannot be created") + } catch { + thrownError = error as NSError + } + #expect(starts == 0) + #expect(results.count == 1) + expectStartupFailure(try #require(results.first), matching: try #require(thrownError)) + #expect(try Data(contentsOf: blocker) == Data("existing file".utf8)) + #expect(try storage.load(checkpoint) == (forceReset ? nil : existingCheckpoint)) + let restorationKey = LocalPreferenceKey( + .init("didInitiateBulkExport", in: .custom("edu.stanford.SpeziOneSec")), default: false + ) + #expect(LocalPreferencesStore(defaults: defaults)[restorationKey]) + } + + private func expectStartupFailure(_ result: HealthExportResult, matching error: NSError) { + guard case .failedToStart(let reportedError) = result.outcome else { + Issue.record("Expected a startup failure") + return + } + #expect((reportedError as NSError).domain == error.domain) + #expect((reportedError as NSError).code == error.code) + } + + @Test(.timeLimit(.minutes(1))) @available(iOS 18, *) + func launchRetriesFailedStartup() async throws { + let suiteName = "HealthExportStartupTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let (results, continuation) = AsyncStream.makeStream(of: HealthExportResult.self) + defer { continuation.finish() } + let config = HealthExportConfiguration( + destination: try #require(URL(string: "https://example.invalid/export")), + timeRange: Date(timeIntervalSince1970: 0)..(_ addedSamples: some Collection, ofType sampleType: SampleType) async {} + func handleDeletedObjects(_ deletedObjects: some Collection, ofType sampleType: SampleType) async {} +} diff --git a/Tests/OneSecStanfordStudyTests/OneSecStanfordStudyTests.swift b/Tests/OneSecStanfordStudyTests/OneSecStanfordStudyTests.swift index 282d8c4..ae18225 100644 --- a/Tests/OneSecStanfordStudyTests/OneSecStanfordStudyTests.swift +++ b/Tests/OneSecStanfordStudyTests/OneSecStanfordStudyTests.swift @@ -25,8 +25,8 @@ struct OneSecStanfordStudyTests { destination: destination, sampleTypes: sampleTypes, timeRange: timeRange, - didStartExport: { _ in }, - didEndExport: {} + didStartLocalExport: { _, _ in }, + didFinishLocalExport: { _ in } ) #expect(configuration.destination == destination) @@ -107,6 +107,7 @@ struct OneSecStanfordStudyTests { let toJson = { try #require(String(bytes: try JSONEncoder().encode($0 as Observation), encoding: .utf8)) } + var observation = observation observation.stripDeviceNameMetadata() let containsNameAfter = try toJson(observation).localizedCaseInsensitiveContains("lukas") #expect(!containsNameAfter) @@ -143,58 +144,58 @@ extension Observation { { "extension" : [ { - "url" : "https://bdh.stanford.edu/fhir/defs/sourceDevice/name", + "url" : "https://grovealliance.org/fhir/core/StructureDefinition/sourceDevice/name", "valueString" : "Apple Watch" }, { - "url" : "https://bdh.stanford.edu/fhir/defs/sourceDevice/manufacturer", + "url" : "https://grovealliance.org/fhir/core/StructureDefinition/sourceDevice/manufacturer", "valueString" : "Apple Inc." }, { - "url" : "https://bdh.stanford.edu/fhir/defs/sourceDevice/model", + "url" : "https://grovealliance.org/fhir/core/StructureDefinition/sourceDevice/model", "valueString" : "Watch" }, { - "url" : "https://bdh.stanford.edu/fhir/defs/sourceDevice/hardwareVersion", + "url" : "https://grovealliance.org/fhir/core/StructureDefinition/sourceDevice/hardwareVersion", "valueString" : "Watch6,15" }, { - "url" : "https://bdh.stanford.edu/fhir/defs/sourceDevice/softwareVersion", + "url" : "https://grovealliance.org/fhir/core/StructureDefinition/sourceDevice/softwareVersion", "valueString" : "11.5" } ], - "url" : "https://bdh.stanford.edu/fhir/defs/sourceDevice" + "url" : "https://grovealliance.org/fhir/core/StructureDefinition/sourceDevice" }, { "extension" : [ { "extension" : [ { - "url" : "https://bdh.stanford.edu/fhir/defs/sourceRevision/source/name", + "url" : "https://grovealliance.org/fhir/core/StructureDefinition/sourceRevision/source/name", "valueString" : "Lukas' Apple Watch" }, { - "url" : "https://bdh.stanford.edu/fhir/defs/sourceRevision/source/bundleIdentifier", + "url" : "https://grovealliance.org/fhir/core/StructureDefinition/sourceRevision/source/bundleIdentifier", "valueString" : "com.apple.health.94C8E349-0D09-4184-BF6C-AF11692FA465" } ], - "url" : "https://bdh.stanford.edu/fhir/defs/sourceRevision/source" + "url" : "https://grovealliance.org/fhir/core/StructureDefinition/sourceRevision/source" }, { - "url" : "https://bdh.stanford.edu/fhir/defs/sourceRevision/version", + "url" : "https://grovealliance.org/fhir/core/StructureDefinition/sourceRevision/version", "valueString" : "11.5" }, { - "url" : "https://bdh.stanford.edu/fhir/defs/sourceRevision/productType", + "url" : "https://grovealliance.org/fhir/core/StructureDefinition/sourceRevision/productType", "valueString" : "Watch6,15" }, { - "url" : "https://bdh.stanford.edu/fhir/defs/sourceRevision/OSVersion", + "url" : "https://grovealliance.org/fhir/core/StructureDefinition/sourceRevision/OSVersion", "valueString" : "11.5.0" } ], - "url" : "https://bdh.stanford.edu/fhir/defs/sourceRevision" + "url" : "https://grovealliance.org/fhir/core/StructureDefinition/sourceRevision" } ], "id" : "549A247B-2769-4399-B0FF-8B5577314B1C", @@ -238,30 +239,30 @@ extension Observation { { "extension" : [ { - "url" : "https://bdh.stanford.edu/fhir/defs/sourceRevision/source/name", + "url" : "https://grovealliance.org/fhir/core/StructureDefinition/sourceRevision/source/name", "valueString" : "AutoSleep" }, { - "url" : "https://bdh.stanford.edu/fhir/defs/sourceRevision/source/bundleIdentifier", + "url" : "https://grovealliance.org/fhir/core/StructureDefinition/sourceRevision/source/bundleIdentifier", "valueString" : "com.tantsissa.AutoSleep" } ], - "url" : "https://bdh.stanford.edu/fhir/defs/sourceRevision/source" + "url" : "https://grovealliance.org/fhir/core/StructureDefinition/sourceRevision/source" }, { - "url" : "https://bdh.stanford.edu/fhir/defs/sourceRevision/version", + "url" : "https://grovealliance.org/fhir/core/StructureDefinition/sourceRevision/version", "valueString" : "6.14.0" }, { - "url" : "https://bdh.stanford.edu/fhir/defs/sourceRevision/productType", + "url" : "https://grovealliance.org/fhir/core/StructureDefinition/sourceRevision/productType", "valueString" : "iPhone16,2" }, { - "url" : "https://bdh.stanford.edu/fhir/defs/sourceRevision/OSVersion", + "url" : "https://grovealliance.org/fhir/core/StructureDefinition/sourceRevision/OSVersion", "valueString" : "18.5.0" } ], - "url" : "https://bdh.stanford.edu/fhir/defs/sourceRevision" + "url" : "https://grovealliance.org/fhir/core/StructureDefinition/sourceRevision" } ], "id" : "F8A59190-87C9-4652-BC3D-2C2653654C25", @@ -310,39 +311,39 @@ extension Observation { { "extension" : [ { - "url" : "https://bdh.stanford.edu/fhir/defs/sourceRevision/source/name", + "url" : "https://grovealliance.org/fhir/core/StructureDefinition/sourceRevision/source/name", "valueString" : "Lukas' Apple Watch" }, { - "url" : "https://bdh.stanford.edu/fhir/defs/sourceRevision/source/bundleIdentifier", + "url" : "https://grovealliance.org/fhir/core/StructureDefinition/sourceRevision/source/bundleIdentifier", "valueString" : "com.apple.health.B83FE7C9-B62D-44D9-92A8-5CB2AE037A06" } ], - "url" : "https://bdh.stanford.edu/fhir/defs/sourceRevision/source" + "url" : "https://grovealliance.org/fhir/core/StructureDefinition/sourceRevision/source" }, { - "url" : "https://bdh.stanford.edu/fhir/defs/sourceRevision/version", + "url" : "https://grovealliance.org/fhir/core/StructureDefinition/sourceRevision/version", "valueString" : "26.5" }, { - "url" : "https://bdh.stanford.edu/fhir/defs/sourceRevision/productType", + "url" : "https://grovealliance.org/fhir/core/StructureDefinition/sourceRevision/productType", "valueString" : "Watch7,12" }, { - "url" : "https://bdh.stanford.edu/fhir/defs/sourceRevision/OSVersion", + "url" : "https://grovealliance.org/fhir/core/StructureDefinition/sourceRevision/OSVersion", "valueString" : "26.5.0" } ], - "url" : "https://bdh.stanford.edu/fhir/defs/sourceRevision" + "url" : "https://grovealliance.org/fhir/core/StructureDefinition/sourceRevision" }, { "extension" : [ { - "url" : "https://bdh.stanford.edu/fhir/defs/metadata/HKTimeZone", + "url" : "https://grovealliance.org/fhir/core/StructureDefinition/metadata/HKTimeZone", "valueString" : "Europe/Berlin" } ], - "url" : "https://bdh.stanford.edu/fhir/defs/metadata" + "url" : "https://grovealliance.org/fhir/core/StructureDefinition/metadata" } ], "id" : "C7C7E250-F086-4C54-B9DF-8C9A52E65050", @@ -451,30 +452,30 @@ extension Observation { { "extension" : [ { - "url" : "https://bdh.stanford.edu/fhir/defs/sourceRevision/source/name", + "url" : "https://grovealliance.org/fhir/core/StructureDefinition/sourceRevision/source/name", "valueString" : "Lukas' Apple Watch" }, { - "url" : "https://bdh.stanford.edu/fhir/defs/sourceRevision/source/bundleIdentifier", + "url" : "https://grovealliance.org/fhir/core/StructureDefinition/sourceRevision/source/bundleIdentifier", "valueString" : "com.apple.health.B83FE7C9-B62D-44D9-92A8-5CB2AE037A06" } ], - "url" : "https://bdh.stanford.edu/fhir/defs/sourceRevision/source" + "url" : "https://grovealliance.org/fhir/core/StructureDefinition/sourceRevision/source" }, { - "url" : "https://bdh.stanford.edu/fhir/defs/sourceRevision/version", + "url" : "https://grovealliance.org/fhir/core/StructureDefinition/sourceRevision/version", "valueString" : "26.1" }, { - "url" : "https://bdh.stanford.edu/fhir/defs/sourceRevision/productType", + "url" : "https://grovealliance.org/fhir/core/StructureDefinition/sourceRevision/productType", "valueString" : "Watch7,12" }, { - "url" : "https://bdh.stanford.edu/fhir/defs/sourceRevision/OSVersion", + "url" : "https://grovealliance.org/fhir/core/StructureDefinition/sourceRevision/OSVersion", "valueString" : "26.1.0" } ], - "url" : "https://bdh.stanford.edu/fhir/defs/sourceRevision" + "url" : "https://grovealliance.org/fhir/core/StructureDefinition/sourceRevision" } ], "id" : "77E3A6C9-5272-4D6B-AC5F-82F48AABD190", diff --git a/Tests/UITests/TestApp.xctestplan b/Tests/UITests/TestApp.xctestplan index 32b4176..3f6fca6 100644 --- a/Tests/UITests/TestApp.xctestplan +++ b/Tests/UITests/TestApp.xctestplan @@ -9,15 +9,6 @@ } ], "defaultOptions" : { - "codeCoverage" : { - "targets" : [ - { - "containerPath" : "container:..\/..", - "identifier" : "OneSecStanfordStudy", - "name" : "OneSecStanfordStudy" - } - ] - }, "targetForVariableExpansion" : { "containerPath" : "container:UITests.xcodeproj", "identifier" : "2F6D139128F5F384007C25D6", diff --git a/Tests/UITests/TestApp/ContentView.swift b/Tests/UITests/TestApp/ContentView.swift index 3667e04..ae8efdd 100644 --- a/Tests/UITests/TestApp/ContentView.swift +++ b/Tests/UITests/TestApp/ContentView.swift @@ -6,7 +6,7 @@ // SPDX-License-Identifier: MIT // -@_spi(APISupport) import OneSecStanfordStudy +import OneSecStanfordStudy import SwiftUI @available(iOS 18, *) @@ -20,6 +20,9 @@ struct ContentView: View { Text("OneSecStanfordStudy active") .accessibilityIdentifier("integration-status-active") StudyButton() + Button("Reset Study State") { + oneSecStanfordStudy.updateState(.available) + } Button("Trigger Health Export") { Task { do { diff --git a/Tests/UITests/TestApp/TestAppDelegate.swift b/Tests/UITests/TestApp/TestAppDelegate.swift index 03feb23..dee88dc 100644 --- a/Tests/UITests/TestApp/TestAppDelegate.swift +++ b/Tests/UITests/TestApp/TestAppDelegate.swift @@ -11,17 +11,6 @@ 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 @@ -32,21 +21,20 @@ final class TestAppDelegate: NSObject, UIApplicationDelegate { launchOptions: launchOptions, healthExportConfig: .init( destination: FileManager.default.temporaryDirectory, - sampleTypes: sampleTypes, timeRange: calendar.date(byAdding: .year, value: -1, to: .now)!..) { + private static func handleLocalExportDidStart(_ attemptID: UUID, _ urls: AnyAsyncSequence) { Task { do { for try await url in urls { - print("Did create export batch \(url)") + print("Local export \(attemptID) created batch \(url)") } } catch { fatalError("Should not throw an error during testing here ...") @@ -55,7 +43,7 @@ final class TestAppDelegate: NSObject, UIApplicationDelegate { } @MainActor - private static func handleHealthExportDidEnd() { - print("Health Export complete") + private static func handleLocalExportDidFinish(_ result: HealthExportResult) { + print("Local export \(result.attemptID): \(result.outcome)") } }