Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
116 commits
Select commit Hold shift + click to select a range
3482ddb
Migrate from Spezi to Grove
lukaskollmer Aug 22, 2026
c89db74
Fall back to the bundled study bundle when the download fails too
lukaskollmer Aug 22, 2026
95006d7
Replace the remaining RWLocks with Mutex
lukaskollmer Aug 22, 2026
4d0d37f
Clean Up Grove Migration Whitespace
PSchmiedmayer Aug 30, 2026
9275d64
Complete the Grove Rebase Integration
PSchmiedmayer Aug 30, 2026
5be211d
Merge remote-tracking branch 'origin/main' into lukas/grove
PSchmiedmayer Sep 7, 2026
6431a1e
Merge Main Into the Grove Migration
PSchmiedmayer Sep 24, 2026
37c0ebd
Migrate the Stats and Achievements Code to Grove
PSchmiedmayer Sep 24, 2026
d295ef0
Pin Grove to 0.3.0-beta.8 and Adopt Its Renamed Account Events
PSchmiedmayer Sep 24, 2026
8f51bdd
Present Onboarding Actions Through Grove's Page Actions
PSchmiedmayer Sep 24, 2026
99c847a
Follow Grove's Floating Account and Consent Actions in the Onboarding…
PSchmiedmayer Sep 24, 2026
dc4f498
Convert Health Data through Grove Instead of the App's FHIR Layer
PSchmiedmayer Aug 28, 2026
2dcabf1
Adopt the Grove FHIR Exchange Contract
PSchmiedmayer Aug 30, 2026
9a6e3d6
Stabilize Health Upload Redelivery Testing
PSchmiedmayer Aug 30, 2026
e2030ee
Adopt Grove-owned FHIR producer preparation
PSchmiedmayer Aug 30, 2026
42a2506
Finish Grove questionnaire migration
PSchmiedmayer Aug 30, 2026
1fa67d2
Replace the Survey Parser With Grove's Questionnaire Extraction
PSchmiedmayer Aug 30, 2026
60a9e9c
Save Each Extracted Sample Independently
PSchmiedmayer Aug 30, 2026
c59a12b
Bind the Exchange Identity to the Synced Health Store
PSchmiedmayer Aug 30, 2026
87c064d
Extract Questionnaire Measurements Through the Exchange Bundle
PSchmiedmayer Aug 30, 2026
a69b748
Derive the Exchange Identities From the Live Scope
PSchmiedmayer Aug 31, 2026
53f7dbf
Adopt the Identity Secret a Concurrent First Use Minted
PSchmiedmayer Aug 31, 2026
a9413ba
Compute the Gateway Flag From the Recording Source
PSchmiedmayer Aug 31, 2026
bd7eab0
Hold Every Write to a Single Account Fence
PSchmiedmayer Aug 31, 2026
373c8f8
Refuse One Record Without Failing Its Batch
PSchmiedmayer Aug 31, 2026
70629c8
Replace the Deletion CSV With Grove Retraction Bundles
PSchmiedmayer Aug 31, 2026
5b44631
Isolate Staging Tests From the Host Exchange Ledger
PSchmiedmayer Aug 31, 2026
f2762f9
Restore the Nicotine Outcome Assertions
PSchmiedmayer Aug 31, 2026
2b4e855
Drop the Unit-Rejection Test the Sample Type Now Prevents
PSchmiedmayer Sep 8, 2026
e43d4f8
Cover the Coding System Used Before the Migration
PSchmiedmayer Sep 8, 2026
b68aaf0
Port the Health Data Exchange to the Producer-Aligned Grove
PSchmiedmayer Sep 23, 2026
024d4f4
Store Each HealthKit Graph Under Its Sample's Document Id
PSchmiedmayer Sep 23, 2026
5874e8e
Track the Aligned Study Definitions and Firebase Functions
PSchmiedmayer Sep 23, 2026
c5aee22
Drop the Refusal Field Nothing Reads
PSchmiedmayer Sep 23, 2026
0af56c9
Present Dashboard Questionnaires in Their Own Navigation Stack
PSchmiedmayer Sep 23, 2026
38a941e
Render and Export Questionnaires in the Participant's Language
PSchmiedmayer Sep 23, 2026
9100f59
Log Extraction Failures Without a Needless Await
PSchmiedmayer Sep 23, 2026
e52bc39
Pin Grove 66f0a88e and Store When Each Deletion Is Known to Follow
PSchmiedmayer Sep 24, 2026
0ab6fcb
Retract Deleted HealthKit Records Through Grove's Converter
PSchmiedmayer Sep 24, 2026
de94a55
Log Conversion Warnings per Exchange Graph
PSchmiedmayer Sep 24, 2026
22bbdb0
Lower-Case the Study UUID in Every Enrollment Identifier
PSchmiedmayer Sep 24, 2026
9e26494
x
lukaskollmer Sep 25, 2026
0dcb15b
Merge branch 'main' of https://github.com/SchmiedmayerLab/MyHeartCoun…
lukaskollmer Sep 25, 2026
d1c0b61
Merge branch 'lukas/grove' of https://github.com/SchmiedmayerLab/MyHe…
lukaskollmer Sep 25, 2026
bae88bb
make UI test logs show UI test output; try to improve test retry perf…
lukaskollmer Sep 25, 2026
484df34
fix consent loading; provide more debug info; etc
lukaskollmer Sep 26, 2026
749b060
temporarily fix the bundled study bundle
lukaskollmer Sep 26, 2026
a2f2dd7
improve xcresult handling
lukaskollmer Sep 26, 2026
0ba6f66
Skip CodeQL job
lukaskollmer Sep 26, 2026
bc8534b
temporarily skip CodeQL
lukaskollmer Sep 26, 2026
cffcf4b
try to fix UI tests
lukaskollmer Sep 26, 2026
738ca0e
fix consent loading; provide more debug info; etc
lukaskollmer Sep 26, 2026
7fcf19a
temporarily fix the bundled study bundle
lukaskollmer Sep 26, 2026
ea1b368
x
lukaskollmer Sep 26, 2026
065d9ef
Merge branch 'lukas/uk-study-variant-fixes' of https://github.com/Sch…
lukaskollmer Sep 26, 2026
a566d72
incorporate StudyVariant
lukaskollmer Sep 26, 2026
788f594
study variant override
lukaskollmer Sep 26, 2026
acf02e7
fix swiftlint
lukaskollmer Sep 26, 2026
2166374
fix unit test crash???
lukaskollmer Sep 26, 2026
2d6db36
Use native build system for Linux utils tests
lukaskollmer Sep 26, 2026
04d7ae7
Merge branch 'lukas/uk-study-variant-fixes' into lukas/grove-fhir-mig…
lukaskollmer Sep 28, 2026
52f932c
Adopt Grove retraction periods and export pause reasons
lukaskollmer Sep 28, 2026
77ed29a
iOS 27 adjustments
lukaskollmer Sep 28, 2026
6c894a0
Fetch the study bundle from Firebase by default again
lukaskollmer Sep 28, 2026
bafc393
Reserve FHIR exchange events per batch
lukaskollmer Sep 28, 2026
429b664
Keep one bad SensorKit record or batch from wedging its sensor
lukaskollmer Sep 28, 2026
e6336d3
Reserve single events without an extra ledger read
lukaskollmer Sep 28, 2026
c0ab6c2
Pin Grove and StudyDefinitions to the review fixes
lukaskollmer Sep 28, 2026
41b841c
Spell out the batch-reservation test's sample dates
lukaskollmer Sep 28, 2026
1f3915c
Export HealthKit Through the Grove Exporter
lukaskollmer Oct 3, 2026
79d70db
Expect the Workout Session Under Its Single Discriminator
lukaskollmer Oct 3, 2026
f4841fc
Reserve HealthKit Exchange Events in a Device-Bound SQLite Ledger
lukaskollmer Oct 4, 2026
6dbe533
Purge the Legacy HealthKit Reservations Once
lukaskollmer Oct 4, 2026
ef37a90
Reset the Exchange Ledger With the Previous Account's Data
lukaskollmer Oct 4, 2026
e5b04a0
State MHC as the Writer of the Samples It Wrote
lukaskollmer Oct 4, 2026
7d280b9
Never Discard a Ledger Database Another Connection Still Uses
lukaskollmer Oct 4, 2026
c5dcc8c
Test That a Ledger Found Corrupt When Opened Is Discarded
lukaskollmer Oct 4, 2026
d1517ac
Test forgetReservations on the SQLite Ledger
lukaskollmer Oct 4, 2026
b3357d5
Test the Keychain Device Binding the App Ships
lukaskollmer Oct 4, 2026
deefcd5
Test the Ledger Reset Through the Account-Data Cleanup Itself
lukaskollmer Oct 4, 2026
3a59e4a
Read the Identity Secret Once per HealthKit Exporter
lukaskollmer Oct 4, 2026
9ad0d01
Export Each HealthKit Batch in One Exporter Call
lukaskollmer Oct 4, 2026
e7b76d4
Test the Batched HealthKit Export End to End
lukaskollmer Oct 4, 2026
f570f24
Test HealthKit Batches Exported in Several Calls
lukaskollmer Oct 4, 2026
e2f8cc4
Run the Live and Bulk HealthKit Upload Paths in Their Tests
lukaskollmer Oct 4, 2026
132433c
Reserve HealthKit Events Through Grove's Exchange Producer
lukaskollmer Oct 4, 2026
b8a6b45
Export SensorKit Batches Through Grove's SensorKit Exporter
lukaskollmer Oct 4, 2026
90c4411
Export Questionnaire Responses Through Grove and Retire MHC's Event L…
lukaskollmer Oct 4, 2026
0df80d4
Test a Failed Live Upload File Stage and the Live Delivery's Anchor A…
lukaskollmer Oct 4, 2026
b2597c1
Check Migrated Retry Digests for Drift Before Re-Recording Them
lukaskollmer Oct 4, 2026
41ba35a
Fail SensorKit Batches Closed on Unprepared Records and ECG Session D…
lukaskollmer Oct 5, 2026
db1e195
Test How a SensorKit Batch Ends and Pin Each Exporter's Configuration
lukaskollmer Oct 5, 2026
a9d3de4
Adopt Grove's Separate Retraction Result and Flattened Identity Systems
lukaskollmer Oct 5, 2026
f34908c
Check a SensorKit Batch's Retry Digests in One Transaction
lukaskollmer Oct 5, 2026
1fe4bb0
Remove the Layout-0 Exchange State Migration and Legacy Purge
lukaskollmer Oct 5, 2026
b758e00
Log a HealthKit Batch's Export Warnings Once as a Report
lukaskollmer Oct 6, 2026
31e1056
Combine Staging's Testing Overrides Into One Optional
lukaskollmer Oct 6, 2026
2117085
Pin Grove and the Study Definitions to the FHIR Export Rework
lukaskollmer Oct 6, 2026
f7c7482
Convert Each Health Observation Through Its Own Type
lukaskollmer Oct 7, 2026
02fe00a
Keep One HealthKit Exporter per Account and Enrollment
lukaskollmer Oct 7, 2026
f47bb23
Provide HealthKit Records Through a RecordProvider and Share the Expo…
lukaskollmer Oct 7, 2026
ecc370d
Keep the Exchange Ledger in a Fully Synchronous Write-Ahead Log
lukaskollmer Oct 7, 2026
e6cb578
Show the HealthKit Exporter's Throughput When Launched to Measure It
lukaskollmer Oct 7, 2026
325d2c7
Await the Exporters' Async Export and Retraction
lukaskollmer Oct 8, 2026
b001cef
Corrupt the Ledger While It Is Closed in the Corruption Tests
lukaskollmer Oct 8, 2026
7b78b01
Name SensorKit Sidecars Where They Are Written and Decode Both PPG En…
lukaskollmer Oct 9, 2026
8038666
Pin Grove and the Study Definitions to the Latest FHIR Export Rework
lukaskollmer Oct 9, 2026
cb6983e
Remove the Legacy PPG Binary Coder Now That Grove Decodes Its Files
lukaskollmer Oct 10, 2026
c226cd4
Pin Grove and the Study Definitions to the Latest FHIR Export Rework
lukaskollmer Oct 15, 2026
5c4181f
Record HealthKit Batch Exports in an On-Device Log
lukaskollmer Oct 16, 2026
2611afd
Reread the File Upload Queue Every Two Seconds
lukaskollmer Oct 16, 2026
1eb54ca
x
lukaskollmer Oct 16, 2026
62d4d04
Pin Grove and the Study Definitions to the Latest FHIR Export Rework
lukaskollmer Oct 16, 2026
3239341
Pin Grove and the Study Definitions to the Re-Signed FHIR Export Rework
lukaskollmer Oct 16, 2026
c4fd1a6
try to fix consent tests
lukaskollmer Oct 17, 2026
3b6bc8e
Merge main after preceding PRs in the MyHeartCounts-iOS stack
lukaskollmer Oct 30, 2026
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
4 changes: 2 additions & 2 deletions MyHeartCounts.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1234,7 +1234,7 @@
repositoryURL = "https://github.com/SchmiedmayerLab/MyHeartCounts-StudyDefinitions.git";
requirement = {
kind = revision;
revision = d1cbe285185b197816d3a87ad997658b093c4a26;
revision = fa1b3eeec340c95f7ec94b2d48ae85da44d42641;
};
};
8076696C2D772C07001B980B /* XCRemoteSwiftPackageReference "swift-algorithms" */ = {
Expand Down Expand Up @@ -1298,7 +1298,7 @@
repositoryURL = "https://github.com/SchmiedmayerLab/Grove.git";
requirement = {
kind = revision;
revision = 611d0cd83b841aa51b8ba1b0cafef144b74a424c;
revision = 6e40cfbffcecafd59b253d6d1d9e2cfbdc89b1d0;
};
traits = (
Textual,
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

278 changes: 278 additions & 0 deletions MyHeartCounts/Health Import/HealthKitExportLog.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
//
// This source file is part of the My Heart Counts iOS open-source project
//
// SPDX-FileCopyrightText: 2026 Stanford University
//
// SPDX-License-Identifier: MIT
//

import Foundation
import GroveFHIRContract
import GroveHealthKitFHIR
import HealthKit
import OSLog
import Synchronization


/// An on-device record of the app's HealthKit batch exports, for analysing test runs off the device.
///
/// Written only when the app is launched with `--recordHealthKitExportLog`, which also turns on the exporter's
/// throughput measurement. Every batch appends one JSON object per line to ``fileURL``: a `batch` line with the
/// batch's outcome, then a `throughput` line with the exporter's running totals, which live ingestion and the
/// historical upload share. Every line names the app launch it belongs to, so the analysis can tell runs apart.
///
/// Fetch it from a connected device with `xcrun devicectl device copy from --device <device> --domain-type
/// appDataContainer --domain-identifier edu.stanford.MyHeartCounts --source "Library/Application
/// Support/HealthKitExportLog/export-log.jsonl" --destination export-log.jsonl`.
enum HealthKitExportLog {
/// The upload path that exported a batch.
enum Origin: String, Encodable, Sendable {
case live
case historical
}

/// The log file. Once it outgrows ``maximumFileSize`` it replaces `export-log.previous.jsonl` beside it.
static let fileURL = URL.applicationSupportDirectory
.appending(path: "HealthKitExportLog", directoryHint: .isDirectory)
.appending(path: "export-log.jsonl")

private static let maximumFileSize = 32 << 20
/// One identifier per app launch.
private static let run = UUID()
/// The hardware model, such as `iPhone18,2`.
private static let device: String = {
var system = utsname()
uname(&system)
return withUnsafeBytes(of: system.machine) { bytes in
String(decoding: bytes.prefix { $0 != 0 }, as: UTF8.self)
}
}()
/// The app's version and build, such as `3.1.0 (412)`.
private static let app: String = {
let info = Bundle.main.infoDictionary ?? [:]
return "\(info["CFBundleShortVersionString"] as? String ?? "?") (\(info["CFBundleVersion"] as? String ?? "?"))"
}()
/// Serializes the appends of batches that finish on different tasks.
private static let file = Mutex(())

/// Whether the log is written.
static var isEnabled: Bool {
FeatureFlags.recordHealthKitExportLog
}

/// The samples of `samples` by HealthKit type identifier.
static func counts(_ samples: some Collection<some HKSample>) -> [String: Int] {
samples.reduce(into: [:]) { counts, sample in
counts[sample.sampleType.identifier, default: 0] += 1
}
}

/// Runs one batch's export and, with the log on, records its outcome and then `exporter`'s throughput.
static func record(
_ origin: Origin,
samples: [String: Int],
exporter: HealthKitFHIRExporter,
_ body: () async throws -> HealthKitBatchExport
) async throws -> HealthKitBatchExport {
guard isEnabled else {
return try await body()
}
let start = ContinuousClock.now
do {
let export = try await body()
append(kind: "batch", Batch(origin: origin, samples: samples, duration: .now - start, export: export))
appendThroughput(of: exporter)
return export
} catch {
append(kind: "batch", Batch(origin: origin, samples: samples, duration: .now - start, error: error))
appendThroughput(of: exporter)
throw error
}
}

private static func appendThroughput(of exporter: HealthKitFHIRExporter) {
if let throughput = exporter.throughput {
append(kind: "throughput", Throughput(throughput))
}
}

private static func append(kind: String, _ payload: some Encodable) {
let line = Line(kind: kind, payload: payload)
file.withLock { _ in
do {
var data = try JSONEncoder.exportLog.encode(line)
data.append(0x0A)
try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true)
if let size = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize, size > maximumFileSize {
let previous = fileURL.deletingPathExtension().appendingPathExtension("previous.jsonl")
_ = try FileManager.default.replaceItemAt(previous, withItemAt: fileURL)
}
if !FileManager.default.fileExists(atPath: fileURL.path) {
FileManager.default.createFile(atPath: fileURL.path, contents: nil)
}
let handle = try FileHandle(forWritingTo: fileURL)
defer {
try? handle.close()
}
try handle.seekToEnd()
try handle.write(contentsOf: data)
} catch {
logger.error("Unable to append to the HealthKit export log: \(error)")
}
}
}
}


extension HealthKitExportLog {
/// One line: the run and device it was written on, then the payload under its kind.
private struct Line<Payload: Encodable>: Encodable {
private enum Keys: String, CodingKey {
case kind, run, time, device, app
case operatingSystem = "os"
}

private struct PayloadKey: CodingKey {
let stringValue: String
var intValue: Int? { nil }
init(stringValue: String) { self.stringValue = stringValue }
init?(intValue: Int) { nil }
}

let kind: String
let payload: Payload

func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: Keys.self)
try container.encode(kind, forKey: .kind)
try container.encode(HealthKitExportLog.run, forKey: .run)
try container.encode(Date.now.formatted(.iso8601.year().month().day().time(includingFractionalSeconds: true)), forKey: .time)
try container.encode(HealthKitExportLog.device, forKey: .device)
try container.encode(ProcessInfo.processInfo.operatingSystemVersionString, forKey: .operatingSystem)
try container.encode(HealthKitExportLog.app, forKey: .app)
var payloadContainer = encoder.container(keyedBy: PayloadKey.self)
try payloadContainer.encode(payload, forKey: PayloadKey(stringValue: kind))
}
}

/// A batch's outcome: what it exported and what Grove refused or warned about, counted by kind, or why it failed.
private struct Batch: Encodable {
struct Refusal: Encodable {
let code: String
let location: String
let count: Int
}

struct Warning: Encodable {
let code: String
let cause: String
let locations: [String]
let samples: [String: Int]
}

let origin: Origin
let samples: [String: Int]
let durationMs: Double
let outcome: String
let error: String?
let refusals: [Refusal]
let warnings: [Warning]

init(origin: Origin, samples: [String: Int], duration: Duration, export: HealthKitBatchExport) {
self.origin = origin
self.samples = samples
durationMs = duration.milliseconds
outcome = "exported"
error = nil
let refusals = Dictionary(grouping: export.refusals) { refusal in
let diagnostic = refusal.reason.diagnostic
return "\(diagnostic.code)\u{1F}\(diagnostic.location)"
}
self.refusals = refusals.map { key, refusals in
let parts = key.split(separator: "\u{1F}", omittingEmptySubsequences: false)
return Refusal(code: String(parts[0]), location: String(parts[1]), count: refusals.count)
}
.sorted { ($0.code, $0.location) < ($1.code, $1.location) }
warnings = export.warnings.kinds.map { kind in
Warning(code: kind.code, cause: kind.cause, locations: kind.locations.sorted(), samples: kind.sampleCountByType)
}
}

init(origin: Origin, samples: [String: Int], duration: Duration, error: any Error) {
self.origin = origin
self.samples = samples
durationMs = duration.milliseconds
outcome = "failed"
self.error = String(describing: error)
refusals = []
warnings = []
}
}

/// The exporter's running totals since it was created.
private struct Throughput: Encodable {
struct Phases: Encodable {
let plan: Double
let reserve: Double
let assemble: Double
let validate: Double
let receive: Double
}

struct SourceType: Encodable {
let records: Int
let graphs: Int
let refusals: Int
let bytes: Int
let durationMs: Double
}

let calls: Int
let records: Int
let busyMs: Double
let phasesMs: Phases
let types: [String: SourceType]

init(_ throughput: HealthKitFHIRExporter.Throughput) {
calls = throughput.calls
records = throughput.records
busyMs = throughput.busy.milliseconds
let phases = throughput.phases
phasesMs = Phases(
plan: phases.plan.milliseconds,
reserve: phases.reserve.milliseconds,
assemble: phases.assemble.milliseconds,
validate: phases.validate.milliseconds,
receive: phases.receive.milliseconds
)
types = throughput.sourceTypes.mapValues { totals in
SourceType(
records: totals.records,
graphs: totals.graphs,
refusals: totals.refusals,
bytes: totals.bytes,
durationMs: totals.duration.milliseconds
)
}
}
}
}


extension JSONEncoder {
/// Sorted keys, so a line's fields always appear in the same order.
fileprivate static var exportLog: JSONEncoder {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
return encoder
}
}


extension Duration {
/// The duration in milliseconds.
fileprivate var milliseconds: Double {
let components = components
return Double(components.seconds) * 1_000 + Double(components.attoseconds) / 1e15
}
}
Loading
Loading