Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
99 changes: 71 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
<!--

This source file is part of the One Sec Stanford Study open-source project
Expand All @@ -11,37 +11,29 @@
# 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: []
Comment on lines +29 to +32
)
```

Then add the product dependency to the target that needs it:
Add the `OneSecStanfordStudy` product to your app target:

```swift
.target(
Expand All @@ -55,7 +47,7 @@

## Usage

Call `initializeOneSecStanfordStudy(_:launchOptions:healthExportConfig:)` from your app delegate's `application(_:willFinishLaunchingWithOptions:)` method:
Initialize the study in your app delegate:

```swift
import OneSecStanfordStudy
Expand All @@ -71,13 +63,12 @@
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.
}
)
)
Expand All @@ -86,7 +77,7 @@
}
```

Apply `.oneSecStanfordStudy()` to the root of your SwiftUI hierarchy:
Apply the view modifier at the root of your SwiftUI hierarchy:

```swift
WindowGroup {
Expand All @@ -95,18 +86,70 @@
}
```

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

Expand Down
44 changes: 25 additions & 19 deletions Sources/OneSecStanfordStudy/HKSampleToFHIRProcessor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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, *)
Expand All @@ -28,30 +27,37 @@ struct HKSampleToFHIRProcessor: BatchProcessor {
}

private func storeSamples<Sample>(_ samples: consuming [Sample], of sampleType: SampleType<Sample>) 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)
Comment on lines +55 to +61
}
}
89 changes: 89 additions & 0 deletions Sources/OneSecStanfordStudy/HealthExportAttempt.swift
Original file line number Diff line number Diff line change
@@ -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<URL, Never>) {
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))
}
}
28 changes: 28 additions & 0 deletions Sources/OneSecStanfordStudy/HealthExportBatchSummary.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading