diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index f5d97886..3f0bd125 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -10,6 +10,12 @@ name: Pull Request Checks on: pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + - converted_to_draft workflow_dispatch: concurrency: @@ -17,7 +23,89 @@ concurrency: cancel-in-progress: true jobs: + scoped_health_fhir: + if: github.event_name == 'pull_request' && github.event.pull_request.draft == true + name: Scoped Health/FHIR Development + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout Repository + uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Set Up JDK 21 + uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: "21" + cache: gradle + - name: Test Health Collection and FHIR Conversion + run: >- + ./gradlew + :health:testDebugUnitTest + :health-fhir:testDebugUnitTest + :health-fhir:lintDebug + --console=plain + + fhir_conformance: + name: FHIR R4 Conformance + runs-on: ubuntu-latest + permissions: + contents: read + env: + PYTHONDONTWRITEBYTECODE: "1" + steps: + - name: Checkout Repository + uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Checkout Grove FHIR 0.6.0 Development Contract + uses: actions/checkout@v7 + with: + repository: SchmiedmayerLab/grove-fhir + # Pinned so a contract change is a visible edit here, not a silent CI drift. + # Development verification follows the exact grove-fhir commit on main + # until the immutable 0.6.0 release artifacts are published. + ref: 31197e876161e9c6d207760d472343231da527e2 + path: .grove-fhir + persist-credentials: false + - name: Record the Exact Grove FHIR Revision + run: git -C .grove-fhir rev-parse --verify HEAD + - uses: actions/setup-node@v7 + with: + node-version: "24" + cache: npm + cache-dependency-path: .grove-fhir/package-lock.json + - uses: actions/setup-python@v7 + with: + python-version: "3.13.15" + - uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: "21.0.10+7.0.LTS" + cache: gradle + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.3.12" + bundler-cache: false + - name: Select the Locked Bundler + run: gem install bundler -v 4.0.16 --no-document + - name: Build the Applicable Grove FHIR Packages + working-directory: .grove-fhir + run: | + npm ci + ./Scripts/build-guides.sh mobile health-connect + - name: Validate Emitted Health Connect Resources + run: >- + ./Scripts/validate-health-connect-fhir-conformance.sh + --grove-fhir .grove-fhir + --mobile-package .grove-fhir/mobile/output/package.tgz + --health-connect-package .grove-fhir/health-connect/output/package.tgz + --validator-jar .grove-fhir/.build/fhir-tools/validator_cli.jar + android: + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false name: Android uses: SchmiedmayerLab/.github/.github/workflows/android.yml@v0.5 permissions: @@ -33,6 +121,7 @@ jobs: release_bundle: name: Signed Release Bundle + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false needs: - android runs-on: ubuntu-latest @@ -44,7 +133,7 @@ jobs: with: submodules: true - name: Set Up JDK 21 - uses: actions/setup-java@v5 + uses: actions/setup-java@v6 with: distribution: temurin java-version: "21" diff --git a/README.md b/README.md index 68a61e55..873bc6a9 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,9 @@ Kotlin & Android Version of the My Heart Counts ecosystem. - **Onboarding**: Provides Onboarding screens for the application. [View the module](./onboarding/) - **Contact**: Provides Contact screens. [View the module](./contact/) +- **Health Connect FHIR R4**: Produces deterministic Grove-conformant Mobile and + Health Connect resource graphs from the closed supported record inventory. + [View the module contract](./health-fhir/README.md) ### Study Bundle diff --git a/Scripts/generate-grove-fhir-kotlin-contract.py b/Scripts/generate-grove-fhir-kotlin-contract.py new file mode 100644 index 00000000..0624c9f3 --- /dev/null +++ b/Scripts/generate-grove-fhir-kotlin-contract.py @@ -0,0 +1,744 @@ +#!/usr/bin/env python3 +"""Project the Grove FHIR catalogs onto the Kotlin canonical contract. + +The Swift and TypeScript contracts have always been generated from the catalogs; this one was +hand-written, so it could fall behind without anything failing. Generating it closes the last +place where a catalog change reaches two languages and silently misses the third. +""" + +# This source file belongs to the My Heart Counts Android project +# +# SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +# +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path +from typing import Any + + +HEADER = """// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +// Generated by Scripts/generate-grove-fhir-kotlin-contract.py. Do not edit by hand. + +package org.grovealliance.health.fhir + +""" + +# Canonicals the contract needs that no catalog owns, because they belong to HL7 or a +# terminology authority rather than to Grove. +EXTERNAL_SYSTEMS: list[tuple[str, str, str]] = [ + ("OBSERVATION_BODY_POSITION", "http://hl7.org/fhir/StructureDefinition/observation-bodyPosition", ""), + ("LOINC", "http://loinc.org", ""), + ("SNOMED_CT", "http://snomed.info/sct", ""), + ("UCUM", "http://unitsofmeasure.org", ""), + ("MDC", "urn:iso:std:iso:11073:10101", ""), + ("APPLICATION_SOFTWARE_VERSION", "531975", "The ISO 11073 MDC software-revision code."), + ("OBSERVATION_CATEGORY", "http://terminology.hl7.org/CodeSystem/observation-category", ""), + ("RECORD_LIFECYCLE", "http://terminology.hl7.org/CodeSystem/iso-21089-lifecycle", ""), + ("PROVENANCE_PARTICIPANT", "http://terminology.hl7.org/CodeSystem/provenance-participant-type", ""), + ("RESEARCH_STUDY_EXTENSION", "http://hl7.org/fhir/StructureDefinition/workflow-researchStudy", ""), +] + +HEALTH_CONNECT_CANONICAL = "https://grovealliance.org/fhir/health-connect" +MOBILE_BASE = "$MOBILE_BASE" +HEALTH_CONNECT_BASE = "$HEALTH_CONNECT_BASE" + + +def load(catalog_directory: Path, name: str) -> dict[str, Any]: + return json.loads((catalog_directory / name).read_text(encoding="utf-8")) + + +def generate_test_vectors(catalog_directory: Path) -> str: + """Vendor the complete normative vector set that same-module Kotlin tests execute.""" + protocol = load(catalog_directory, "exchange-protocol.json") + kinds = protocol.get("opaqueIdentity", {}).get("identityKinds") + vectors = protocol.get("testVectors") + if not isinstance(kinds, list) or not isinstance(vectors, dict): + raise SystemExit("The exchange protocol must declare identity kinds and test vectors") + kind_names = [row.get("kind") for row in kinds if isinstance(row, dict)] + identities = vectors.get("identities") + invalid_identities = vectors.get("invalidIdentities") + vector_kinds = ( + [row.get("identityKind") for row in identities if isinstance(row, dict)] + if isinstance(identities, list) + else [] + ) + if ( + len(kind_names) != len(kinds) + or len(set(kind_names)) != len(kind_names) + or len(vector_kinds) != len(kind_names) + or set(vector_kinds) != set(kind_names) + or len(set(vector_kinds)) != len(vector_kinds) + or not isinstance(invalid_identities, list) + or len(invalid_identities) != 4 + ): + raise SystemExit( + "The normative test vectors must cover every closed identity kind exactly once " + "and retain all four invalid domain vectors" + ) + return json.dumps(vectors, ensure_ascii=False, indent=2) + "\n" + + +def constant_name( + identifier: str, suffix: str = "", prefix: str | None = None, strip: str | None = None +) -> str: + """`grove-mobile-heart-rate` becomes `MOBILE_HEART_RATE_PROFILE`. + + Profiles carry the package prefix a reader expects; the shared code systems keep the `GROVE_` + names the converter already uses, so generating this file changes no call site. + """ + stem = re.sub(strip or r"^(grove-mobile-|grove-|health-connect-)", "", identifier) + if prefix is None: + prefix = "MOBILE" if identifier.startswith(("grove-mobile-", "grove-")) else "HEALTH_CONNECT" + body = re.sub(r"[^A-Za-z0-9]+", "_", stem).strip("_").upper() + return f"{prefix}_{body}{suffix}" + + +def declaration(name: str, value: str, doc: str = "") -> str: + line = f' const val {name} = {value}' + # ktlint wants a blank line before a documented declaration. + prefix = f"\n /** {doc} */\n" if doc else "" + if len(line) <= 120: + return prefix + line + return prefix + f" const val {name} =\n {value}" + + +def profile_value(base: str, identifier: str) -> str: + return f'"{base}/StructureDefinition/{identifier}"' + + +def system_value(base: str, kind: str, identifier: str) -> str: + return f'"{base}/{kind}/{identifier}"' + + +def health_connect_outputs(catalogs: dict[str, Any]) -> list[str]: + """Every measurement the Health Connect adapter admits an output for.""" + return sorted( + { + output["measurement"] + for record in catalogs["health-connect"]["recordTypes"] + for output in record.get("outputs", []) + } + ) + + +def adapter_only_output_profiles(catalogs: dict[str, Any]) -> dict[str, str]: + """Exact direct profile required for each protocol-declared adapter-only output type. + + The exchange protocol owns the closed resource-type list while profile-claims.json owns the + adapter canonicals. Walk the claims structurally so adding another exact claim does not require + teaching the Kotlin generator a catalog property name. + """ + contract = catalogs["exchange-protocol"]["lifecycle"]["active"][ + "adapterOnlyOutputProfileClaims" + ] + if contract.get("authority") != "catalog/profile-claims.json": + raise SystemExit( + "adapterOnlyOutputProfileClaims must name catalog/profile-claims.json as its authority" + ) + resource_types = contract.get("resourceTypes") + if ( + not isinstance(resource_types, list) + or not resource_types + or any( + not isinstance(resource_type, str) or not resource_type + for resource_type in resource_types + ) + or len(resource_types) != len(set(resource_types)) + ): + raise SystemExit( + "adapterOnlyOutputProfileClaims.resourceTypes must be a unique nonempty string list" + ) + + candidates: dict[str, list[dict[str, Any]]] = { + resource_type: [] for resource_type in resource_types + } + + def visit(value: Any) -> None: + if isinstance(value, dict): + resource_type = value.get("resourceType") + if resource_type in candidates and isinstance(value.get("profile"), str): + candidates[resource_type].append(value) + for child in value.values(): + visit(child) + elif isinstance(value, list): + for child in value: + visit(child) + + visit(catalogs["profile-claims"]) + profiles: dict[str, str] = {} + for resource_type in resource_types: + matches = candidates[resource_type] + if len(matches) != 1: + raise SystemExit( + f"{resource_type}: expected exactly one adapter-only claim in profile-claims.json, " + f"found {len(matches)}" + ) + claim = matches[0] + profile = claim["profile"] + if ( + claim.get("cardinality") != 1 + or claim.get("otherProfilesAllowed") is not False + or not profile.startswith("https://") + ): + raise SystemExit( + f"{resource_type}: adapter-only claim must require one exact HTTPS profile and no others" + ) + profiles[resource_type] = profile + return dict(sorted(profiles.items())) + + +def grove_role_constant(role: str) -> str: + return "GroveIdentifierRole." + re.sub(r"[^A-Za-z0-9]+", "_", role).upper() + + +def exact_profile_claims(catalogs: dict[str, Any]) -> dict[str, Any]: + """Project the closed active-event profile modes needed at the Kotlin wire boundary.""" + claims = catalogs["profile-claims"] + protocol = catalogs["exchange-protocol"] + active = protocol["lifecycle"]["active"] + devices = claims["activeDeviceClaims"] + device_modes: dict[str, list[str]] = {} + for claim in devices: + profiles = claim.get("profiles") + if ( + claim.get("resourceType") != "Device" + or claim.get("cardinality") != 1 + or claim.get("otherProfilesAllowed") is not False + or not isinstance(profiles, list) + or len(profiles) != 1 + ): + raise SystemExit("Every active Device claim must be one exact closed profile") + device_modes[profiles[0]] = claim.get("requiredIdentifierRoles", []) + + documents = [ + claims["sensorRecordingDocumentClaim"], + claims["healthKitRecordingDocumentClaim"], + claims["healthKitClinicalRecordDocumentClaim"], + claims["sensorKitRecordingDocumentClaim"], + claims["providerRecordingDocumentClaim"], + ] + document_modes: list[tuple[list[str], list[str]]] = [] + for claim in documents: + profiles = claim.get("profiles") + if ( + claim.get("resourceType", "DocumentReference") != "DocumentReference" + or claim.get("cardinality") != len(profiles or []) + or claim.get("otherProfilesAllowed") is not False + or not profiles + or len(profiles) != len(set(profiles)) + ): + raise SystemExit("Every active DocumentReference claim must be an exact closed profile set") + document_modes.append((sorted(profiles), claim.get("requiredIdentifierRoles", []))) + + questionnaire = claims["activeQuestionnaireResponseClaim"] + questionnaire_profiles = questionnaire.get("profiles") + if ( + questionnaire.get("resourceType") != "QuestionnaireResponse" + or questionnaire.get("cardinality") != 1 + or questionnaire.get("otherProfilesAllowed") is not False + or not isinstance(questionnaire_profiles, list) + or len(questionnaire_profiles) != 1 + ): + raise SystemExit("The active QuestionnaireResponse claim must be one exact closed profile") + + health_connect_provenance = next( + claim["profile"] + for claim in claims["adapterConversionProvenanceClaims"] + if claim["adapter"] == "health-connect" + ) + retraction_target_roles = protocol["lifecycle"]["retraction"]["targetRoles"] + if not isinstance(retraction_target_roles, dict) or not retraction_target_roles: + raise SystemExit("The retraction target-role table must be a nonempty object") + for role, rule in retraction_target_roles.items(): + resource_types = rule.get("resourceTypes") + if ( + not re.fullmatch(r"[a-z][a-z0-9-]*", role) + or not isinstance(rule.get("identifierRole"), str) + or not isinstance(resource_types, list) + or not resource_types + or len(resource_types) != len(set(resource_types)) + ): + raise SystemExit(f"{role}: invalid retraction target-role rule") + entry_identifier_priority = protocol["entryIdentity"]["resourceIdentifierPriority"] + identity_roles = { + row["identifierRole"] for row in protocol["opaqueIdentity"]["identityKinds"] + } + if ( + not isinstance(entry_identifier_priority, list) + or not entry_identifier_priority + or len(entry_identifier_priority) != len(set(entry_identifier_priority)) + or any(role not in identity_roles for role in entry_identifier_priority) + ): + raise SystemExit("entryIdentity.resourceIdentifierPriority must be a unique closed role list") + return { + "outputResourceTypes": active["entryResourcePolicy"]["outputResourceTypes"], + "supportingResourceTypes": active["entryResourcePolicy"]["supportingResourceTypes"], + "lifecycleResourceType": active["entryResourcePolicy"]["lifecycleResourceType"], + "deviceModes": dict(sorted(device_modes.items())), + "documentModes": sorted(document_modes), + "questionnaireProfile": questionnaire_profiles[0], + # This module produces Health Connect graphs and consumes the adapter-neutral positive + # Mobile corpus. Do not make it a second validator for every other adapter package. + "provenanceProfiles": sorted( + {protocol["profiles"]["conversionProvenance"], health_connect_provenance} + ), + "healthConnectExclusiveObservationProfiles": sorted( + set(claims["healthConnectPlatformExclusiveClaims"]["profiles"]) + ), + "entryIdentifierPriority": entry_identifier_priority, + "retractionTargetRoles": dict(sorted(retraction_target_roles.items())), + } + + +def produced_quantity_semantics(catalogs: dict[str, Any], produced: list[str]) -> dict[str, tuple[str, str]]: + """Fixed Quantity system/code pairs keyed by each directly claimed semantic profile.""" + measurements = {row["id"]: row for row in catalogs["measurements"]["measurements"]} + adapter = {row["id"]: row for row in catalogs["health-connect"]["adapterMeasurements"]} + root = catalogs["graph"]["canonicalRoot"] + semantics: dict[str, tuple[str, str]] = {} + for identifier in produced: + row = adapter.get(identifier, measurements.get(identifier)) + if row is None or not isinstance(row.get("quantity"), dict): + continue + profile = row["profile"] + if not profile.startswith("https://"): + package = "health-connect" if row.get("owner") == "health-connect" else "mobile" + profile = f"{root}/{package}/StructureDefinition/{profile}" + quantity = row["quantity"] + semantics[profile] = (quantity["system"], quantity["code"]) + return dict(sorted(semantics.items())) + + +def kotlin_decimal(value: int | float | None) -> str: + if value is None: + return "null" + lexical = json.dumps(value, allow_nan=False, ensure_ascii=True) + return f'java.math.BigDecimal("{lexical}")' + + +def generate(catalog_directory: Path) -> str: + catalogs = { + "measurements": load(catalog_directory, "measurement-catalog.json"), + "health-connect": load(catalog_directory, "health-connect-adapter.json"), + "graph": load(catalog_directory, "package-graph.json"), + "exchange-protocol": load(catalog_directory, "exchange-protocol.json"), + "profile-claims": load(catalog_directory, "profile-claims.json"), + "providers": load(catalog_directory, "providers-adapter.json"), + } + measurements = {m["id"]: m for m in catalogs["measurements"]["measurements"]} + adapter_measurements = {m["id"]: m for m in catalogs["health-connect"]["adapterMeasurements"]} + produced = health_connect_outputs(catalogs) + profile_claims = exact_profile_claims(catalogs) + quantity_semantics = produced_quantity_semantics(catalogs, produced) + provider_rows = catalogs["providers"].get("providers") + if not isinstance(provider_rows, list) or not provider_rows: + raise SystemExit("Provider adapter catalog declares no closed provider codes") + provider_codes = [row.get("id") for row in provider_rows if isinstance(row, dict)] + if ( + len(provider_codes) != len(provider_rows) + or len(set(provider_codes)) != len(provider_codes) + or any( + not isinstance(code, str) or re.fullmatch(r"[a-z][a-z0-9-]*", code) is None + for code in provider_codes + ) + ): + raise SystemExit("Provider adapter codes must be unique lowercase protocol tokens") + provider_codes.sort() + + shared: list[tuple[str, str]] = [] + dietary: list[tuple[str, str]] = [] + adapter_specific: list[tuple[str, str]] = [] + for identifier in produced: + if identifier in adapter_measurements: + canonical = adapter_measurements[identifier]["profile"] + expected = f"{HEALTH_CONNECT_CANONICAL}/StructureDefinition/" + if not canonical.startswith(expected): + raise SystemExit( + f"{identifier}: {canonical} is not published by this adapter; the contract " + "cannot state it under the Health Connect base" + ) + profile = canonical[len(expected):] + adapter_specific.append((constant_name(profile, "_PROFILE"), profile)) + continue + measurement = measurements[identifier] + profile = measurement["profile"] + owner = measurement.get("owner", "mobile") + if owner not in ("mobile", "health-connect"): + raise SystemExit( + f"{identifier}: owner {owner!r} publishes outside the packages this contract names" + ) + target = adapter_specific if owner == "health-connect" else shared + if owner == "mobile" and identifier.startswith("dietary-"): + dietary.append((identifier, profile)) + continue + target.append((constant_name(profile, "_PROFILE"), profile)) + + lines: list[str] = [ + "/** Canonicals shared with the Grove FHIR Mobile and Health Connect packages. */", + "object HealthConnectContract {", + declaration("FHIR_VERSION", f'"{catalogs["measurements"]["fhirVersion"]}"'), + declaration("PACKAGE_VERSION", f'"{catalogs["measurements"]["version"]}"'), + declaration( + "CONVERSION_CONTRACT_VERSION", + f'"health-connect-r4-{catalogs["measurements"]["version"]}"', + ), + declaration("CANONICAL_ROOT", f'"{catalogs["graph"]["canonicalRoot"]}"'), + declaration("MOBILE_BASE", '"$CANONICAL_ROOT/mobile"'), + declaration("HEALTH_CONNECT_BASE", '"$CANONICAL_ROOT/health-connect"'), + "", + " /** Closed provider codes admitted by the provider-specific HMAC identity domains. */", + " internal val providerCodes: Set = setOf(", + *[f' "{code}",' for code in provider_codes], + " )", + "", + declaration("MOBILE_OBSERVATION_PROFILE", profile_value(MOBILE_BASE, "grove-mobile-observation")), + declaration( + "MOBILE_EXCHANGE_BUNDLE_PROFILE", profile_value(MOBILE_BASE, "grove-mobile-exchange-bundle") + ), + declaration( + "MOBILE_RETRACTION_BUNDLE_PROFILE", + profile_value(MOBILE_BASE, "grove-mobile-retraction-bundle"), + ), + declaration( + "MOBILE_RETRACTION_PROVENANCE_PROFILE", + profile_value(MOBILE_BASE, "grove-mobile-retraction-provenance"), + ), + declaration("MOBILE_APPLICATION_DEVICE_PROFILE", profile_value(MOBILE_BASE, "grove-application-device")), + declaration("MOBILE_HOST_DEVICE_PROFILE", profile_value(MOBILE_BASE, "grove-host-device")), + declaration("MOBILE_RECORDING_DEVICE_PROFILE", profile_value(MOBILE_BASE, "grove-recording-device")), + declaration( + "HEALTH_CONNECT_OBSERVATION_PROFILE", + profile_value(HEALTH_CONNECT_BASE, "health-connect-observation"), + ), + declaration( + "HEALTH_CONNECT_SPECIMEN_PROFILE", + profile_value(HEALTH_CONNECT_BASE, "health-connect-specimen"), + ), + declaration( + "HEALTH_CONNECT_PROVENANCE_PROFILE", + profile_value(HEALTH_CONNECT_BASE, "health-connect-conversion-provenance"), + ), + "", + ] + + for name, profile in shared: + lines.append(declaration(name, profile_value(MOBILE_BASE, profile))) + lines.append("") + for name, profile in adapter_specific: + lines.append(declaration(name, profile_value(HEALTH_CONNECT_BASE, profile))) + lines.append("") + + lines.append(" /** Every dietary nutrient the adapter emits, keyed by its catalog measurement id. */") + lines.append(" val mobileDietaryProfiles: Map = mapOf(") + for identifier, profile in dietary: + # The canonical comes from the catalog, never from the id: deriving it would let a + # renamed profile keep its old canonical with nothing failing. + lines.append(f' "{identifier}" to "$MOBILE_BASE/StructureDefinition/{profile}",') + lines.append(" )") + lines.append("") + + lines.append(" /** Profiles the shared Mobile package owns, which this adapter also claims. */") + lines.append(" val sharedMeasurementProfiles: Set = setOf(") + for name, _ in shared: + lines.append(f" {name},") + lines.append(" ) + mobileDietaryProfiles.values") + lines.append("") + lines.append(" /** Profiles only this adapter publishes. */") + lines.append(" val adapterSpecificObservationProfiles: Set = setOf(") + for name, _ in adapter_specific: + lines.append(f" {name},") + lines.append(" )") + lines.append("") + + lines.append(" /** Exact direct profile for every protocol-declared adapter-only active output type. */") + lines.append(" internal val adapterOnlyOutputProfiles: Map = mapOf(") + for resource_type, profile in adapter_only_output_profiles(catalogs).items(): + lines.append(f' "{resource_type}" to') + lines.append(f' "{profile}",') + lines.append(" )") + lines.append("") + + lines.append(" /** Closed resource types admitted in a Mobile active event graph. */") + lines.append(" internal val activeOutputResourceTypes: Set = setOf(") + for resource_type in profile_claims["outputResourceTypes"]: + lines.append(f' "{resource_type}",') + lines.append(" )") + lines.append(" internal val activeSupportingResourceTypes: Set = setOf(") + for resource_type in profile_claims["supportingResourceTypes"]: + lines.append(f' "{resource_type}",') + lines.append(" )") + lines.append( + declaration("ACTIVE_LIFECYCLE_RESOURCE_TYPE", f'"{profile_claims["lifecycleResourceType"]}"') + ) + lines.append("") + + lines.append(" /** Catalog-priority business Identifier used as each exchange entry key. */") + lines.append(" internal val entryIdentifierPriority: List = listOf(") + for role in profile_claims["entryIdentifierPriority"]: + lines.append(f" {grove_role_constant(role)},") + lines.append(" )") + lines.append("") + + lines.append(" /** Exact Device profile mode to its exact typed Grove identifier roles. */") + lines.append(" internal val activeDeviceProfileClaims: Map> = mapOf(") + for profile, roles in profile_claims["deviceModes"].items(): + lines.append(f' "{profile}" to') + lines.append(" setOf(") + for role in roles: + lines.append(f" {grove_role_constant(role)},") + lines.append(" ),") + lines.append(" )") + lines.append("") + + lines.append(" /** Exact DocumentReference profile mode to its required Grove identifier roles. */") + lines.append( + " internal val activeDocumentProfileClaims: " + "Map, Set> = mapOf(" + ) + for profiles, roles in profile_claims["documentModes"]: + lines.append(" setOf(") + for profile in profiles: + lines.append(f' "{profile}",') + lines.append(" ) to setOf(") + for role in roles: + lines.append(f" {grove_role_constant(role)},") + lines.append(" ),") + lines.append(" )") + lines.append( + declaration( + "ACTIVE_QUESTIONNAIRE_RESPONSE_PROFILE", + f'"{profile_claims["questionnaireProfile"]}"', + ) + ) + lines.append("") + + lines.append(" /** Exact retraction target role, Identifier role, and resource-type closure. */") + lines.append( + " internal val retractionTargetClaims: " + "Map = mapOf(" + ) + for role, rule in profile_claims["retractionTargetRoles"].items(): + enum_name = re.sub(r"[^A-Za-z0-9]+", "_", role).upper() + lines.append(f" HealthConnectRetractionTargetRole.{enum_name} to GroveRetractionTargetClaim(") + lines.append(f" identifierRole = {grove_role_constant(rule['identifierRole'])},") + lines.append(" resourceTypes = setOf(") + for resource_type in rule["resourceTypes"]: + lines.append(f' "{resource_type}",') + lines.append(" ),") + lines.append(" ),") + lines.append(" )") + lines.append("") + + for property_name, values, doc in [ + ( + "activeConversionProvenanceProfiles", + profile_claims["provenanceProfiles"], + "Exact Mobile or Health Connect profiles admitted on the active lifecycle Provenance.", + ), + ( + "activeHealthConnectExclusiveObservationProfiles", + profile_claims["healthConnectExclusiveObservationProfiles"], + "Health Connect Observation profiles whose complete claim is one direct profile.", + ), + ]: + lines.append(f" /** {doc} */") + lines.append(f" internal val {property_name}: Set = setOf(") + for value in values: + lines.append(f' "{value}",') + lines.append(" )") + lines.append("") + lines.append(" /** Fixed Quantity system/code pairs keyed by a produced semantic profile. */") + lines.append(" internal val quantitySemanticsByProfile: Map = mapOf(") + for profile, (system, code) in quantity_semantics.items(): + lines.append(f' "{profile}" to') + lines.append(f' QuantitySemantics("{system}", "{code}"),') + lines.append(" )") + lines.append("") + + lines.append(" /** Catalog-defined representational Quantity domains used by this adapter. */") + lines.append(" internal val quantityValueDomains: Map = mapOf(") + for identifier in produced: + measurement = measurements.get(identifier) + quantity = measurement.get("quantity") if measurement else None + domain = quantity.get("valueDomain") if isinstance(quantity, dict) else None + if domain is None: + continue + minimum = domain.get("minimum") + maximum = domain.get("maximum") + for label, boundary in (("minimum", minimum), ("maximum", maximum)): + if boundary is not None and boundary.get("inclusive") is not True: + raise SystemExit( + f"{identifier}: exclusive {label} cannot be projected by QuantityValueDomain" + ) + lines.extend( + [ + f' "{identifier}" to QuantityValueDomain(', + f' minimum = {kotlin_decimal(minimum.get("value") if minimum else None)},', + f' maximum = {kotlin_decimal(maximum.get("value") if maximum else None)},', + f' integerOnly = {str(domain.get("integerOnly", False)).lower()},', + " ),", + ] + ) + lines.append(" )") + lines.append("") + + data_origin = catalogs["health-connect"]["dataOriginApplication"] + expected_data_origin = { + "sourceField": "Metadata.dataOrigin.packageName", + "r4Element": "Provenance.entity.agent.who", + "referenceType": "Device", + "referenceMode": "identifier-only", + "literalReferenceAllowed": False, + "eventBundleEntryRequired": False, + "profileClaimRequired": False, + } + if any(data_origin.get(key) != value for key, value in expected_data_origin.items()): + raise SystemExit("Health Connect DataOrigin is no longer the admitted logical Device reference contract") + lines.append(declaration("ANDROID_PACKAGE_IDENTIFIER", f'"{data_origin["identifierSystem"]}"')) + lines.append("") + + lines.append( + declaration("WRITER_RECORD_VERSION", profile_value(MOBILE_BASE, "grove-writer-record-version")) + ) + lines.append( + declaration("RECORDING_METHOD_EXTENSION", profile_value(MOBILE_BASE, "grove-recording-method")) + ) + for name, identifier in [ + ("HEALTH_CONNECT_SESSION_TITLE", "health-connect-session-title"), + ("HEALTH_CONNECT_GLUCOSE_MEAL_CONTEXT", "health-connect-glucose-meal-context"), + ("HEALTH_CONNECT_RECORD_TYPE_EXTENSION", "health-connect-record-type"), + ]: + lines.append(declaration(name, profile_value(HEALTH_CONNECT_BASE, identifier))) + for name, identifier in [ + ("GROVE_EXCHANGE_ENTRY_NODE_KEY", "grove-exchange-entry-node-key"), + ("GROVE_RETRACTION_TARGET_ROLE", "grove-retraction-target-role"), + ]: + lines.append(declaration(name, profile_value(MOBILE_BASE, identifier))) + lines.append("") + + for name, identifier in mobile_code_systems(catalog_directory): + lines.append(declaration(name, system_value(MOBILE_BASE, "CodeSystem", identifier))) + for name, identifier in health_connect_code_systems(catalog_directory): + lines.append(declaration(name, system_value(HEALTH_CONNECT_BASE, "CodeSystem", identifier))) + lines.append("") + + for name, value, doc in EXTERNAL_SYSTEMS: + lines.append(declaration(name, f'"{value}"', doc)) + lines.append("}") + return HEADER + "\n".join(lines) + "\n" + + +def code_systems(guide_directory: Path) -> list[str]: + """Every CodeSystem id a guide publishes, read from its FSH rather than restated here. + + Every failure here is loud. An empty result reads exactly like a guide with no code systems, + and a contract missing its code systems still compiles. + """ + fsh = guide_directory / "input" / "fsh" + sources = sorted(fsh.glob("*.fsh")) + if not sources: + raise SystemExit(f"{fsh} holds no FSH; the contract cannot be projected from it") + found: set[str] = set() + for source in sources: + declared: str | None = None + identifier: str | None = None + for line in source.read_text(encoding="utf-8").splitlines(): + header = re.match(r"^CodeSystem:\s+(\S+)", line) + if header: + if declared and identifier is None: + raise SystemExit(f"{source}: CodeSystem {declared} declares no Id") + declared, identifier = header.group(1), None + continue + if re.match(r"^[A-Za-z]+:\s", line) and not line.startswith(("Id:", "Title:", "Description:")): + if declared and identifier is None: + raise SystemExit(f"{source}: CodeSystem {declared} declares no Id") + declared, identifier = None, None + continue + if declared and line.startswith("Id:"): + identifier = line.split(":", 1)[1].strip() + found.add(identifier) + if declared and identifier is None: + raise SystemExit(f"{source}: CodeSystem {declared} declares no Id") + if not found: + raise SystemExit(f"{fsh} declares no CodeSystem; the contract would silently lose them") + return sorted(found) + + +def mobile_code_systems(catalog_directory: Path) -> list[tuple[str, str]]: + guide = catalog_directory.parent / "mobile" + systems = [ + # A code system keeps its package word: `grove-mobile-measurement` is not `grove-measurement`. + (constant_name(identifier, prefix="GROVE", strip=r"^grove-"), identifier) + for identifier in code_systems(guide) + ] + return [ + ("GROVE_RETRACTION_TARGET_ROLE_CS" if name == "GROVE_RETRACTION_TARGET_ROLE" else name, identifier) + for name, identifier in systems + ] + + +def health_connect_code_systems(catalog_directory: Path) -> list[tuple[str, str]]: + guide = catalog_directory.parent / "health-connect" + return [(constant_name(identifier), identifier) for identifier in code_systems(guide)] + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--catalog-directory", type=Path, required=True) + parser.add_argument( + "--output", + type=Path, + default=Path("health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectContract.kt"), + ) + parser.add_argument( + "--test-vector-output", + type=Path, + default=Path("health-fhir/src/test/resources/grove-exchange-protocol-test-vectors.json"), + ) + parser.add_argument("--check", action="store_true") + arguments = parser.parse_args() + generated = generate(arguments.catalog_directory) + generated_test_vectors = generate_test_vectors(arguments.catalog_directory) + if arguments.check: + actual = arguments.output.read_text(encoding="utf-8") if arguments.output.exists() else "" + actual_test_vectors = ( + arguments.test_vector_output.read_text(encoding="utf-8") + if arguments.test_vector_output.exists() + else "" + ) + stale = False + if actual != generated: + print( + f"error: {arguments.output} is not synchronized with the grove-fhir catalogs", + file=sys.stderr, + ) + stale = True + if actual_test_vectors != generated_test_vectors: + print( + f"error: {arguments.test_vector_output} is not synchronized with the " + "grove-fhir exchange-protocol vectors", + file=sys.stderr, + ) + stale = True + return int(stale) + arguments.output.parent.mkdir(parents=True, exist_ok=True) + arguments.output.write_text(generated, encoding="utf-8") + arguments.test_vector_output.parent.mkdir(parents=True, exist_ok=True) + arguments.test_vector_output.write_text(generated_test_vectors, encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Scripts/validate-health-connect-fhir-conformance.sh b/Scripts/validate-health-connect-fhir-conformance.sh new file mode 100755 index 00000000..f4370d8f --- /dev/null +++ b/Scripts/validate-health-connect-fhir-conformance.sh @@ -0,0 +1,326 @@ +#!/usr/bin/env bash + +# This source file belongs to the My Heart Counts Android project +# +# SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +# +# SPDX-License-Identifier: MIT + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: Scripts/validate-health-connect-fhir-conformance.sh [options] + +Required inputs (option or matching environment variable): + --grove-fhir PATH GROVE_FHIR_ROOT + --mobile-package PATH GROVE_MOBILE_PACKAGE + --health-connect-package PATH GROVE_HEALTH_CONNECT_PACKAGE + --validator-jar PATH FHIR_VALIDATOR_JAR + +Optional inputs: + --export DIRECTORY GROVE_CONFORMANCE_EXPORT + --help + +The producer emits its complete deterministic R4 corpus, creates a producer-neutral +manifest, and delegates structural and official validation to the conformance kit +from the exact grove-fhir checkout. The validator remains offline. +EOF +} + +grove_fhir_root="${GROVE_FHIR_ROOT:-}" +mobile_package="${GROVE_MOBILE_PACKAGE:-}" +health_connect_package="${GROVE_HEALTH_CONNECT_PACKAGE:-}" +validator_jar="${FHIR_VALIDATOR_JAR:-}" +export_directory="${GROVE_CONFORMANCE_EXPORT:-}" + +while (( $# > 0 )); do + case "$1" in + --grove-fhir) + [[ $# -ge 2 ]] || { usage >&2; exit 2; } + grove_fhir_root="$2" + shift 2 + ;; + --mobile-package) + [[ $# -ge 2 ]] || { usage >&2; exit 2; } + mobile_package="$2" + shift 2 + ;; + --health-connect-package) + [[ $# -ge 2 ]] || { usage >&2; exit 2; } + health_connect_package="$2" + shift 2 + ;; + --validator-jar) + [[ $# -ge 2 ]] || { usage >&2; exit 2; } + validator_jar="$2" + shift 2 + ;; + --export) + [[ $# -ge 2 ]] || { usage >&2; exit 2; } + export_directory="$2" + shift 2 + ;; + --help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +script_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repository_root="$(cd "$script_directory/.." && pwd)" + +require_regular_file() { + local label="$1" + local path="$2" + if [[ -z "$path" || -L "$path" || ! -f "$path" ]]; then + echo "$label must name an existing non-linked regular file: ${path:-}" >&2 + exit 2 + fi +} + +if [[ -z "$grove_fhir_root" || -L "$grove_fhir_root" || ! -d "$grove_fhir_root" ]]; then + echo "grove-fhir root must name an existing non-linked directory: ${grove_fhir_root:-}" >&2 + exit 2 +fi +require_regular_file "Grove Mobile package" "$mobile_package" +require_regular_file "Grove Health Connect package" "$health_connect_package" +canonical_file() { + local path="$1" + local directory + directory="$(cd "$(dirname "$path")" && pwd -P)" + printf '%s/%s\n' "$directory" "$(basename "$path")" +} +grove_fhir_root="$(cd "$grove_fhir_root" && pwd -P)" +mobile_package="$(canonical_file "$mobile_package")" +health_connect_package="$(canonical_file "$health_connect_package")" +# The canonical contract is projected from the same catalogs the guides are built from, so a +# catalog change that never reached this repository has to fail here rather than at runtime. +python3 -B "$repository_root/Scripts/generate-grove-fhir-kotlin-contract.py" \ + --catalog-directory "$grove_fhir_root/catalog" \ + --output "$repository_root/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectContract.kt" \ + --test-vector-output "$repository_root/health-fhir/src/test/resources/grove-exchange-protocol-test-vectors.json" \ + --check + +require_regular_file "FHIR Validator jar" "$validator_jar" +validator_jar="$(canonical_file "$validator_jar")" +require_regular_file "grove-fhir producer validator" "$grove_fhir_root/Scripts/validate-producer.py" +require_regular_file "Grove Health Connect adapter catalog" "$grove_fhir_root/catalog/health-connect-adapter.json" +require_regular_file "Grove exchange-protocol catalog" "$grove_fhir_root/catalog/exchange-protocol.json" +require_regular_file "Grove measurement catalog" "$grove_fhir_root/catalog/measurement-catalog.json" + +producer_inputs=( + "Scripts/generate-grove-fhir-kotlin-contract.py" + "Scripts/validate-health-connect-fhir-conformance.sh" + "build-logic" + "build.gradle.kts" + "gradle" + "gradle.properties" + "gradlew" + "health" + "health-fhir" + "settings.gradle.kts" +) +assert_clean_producer_inputs() { + local dirty ignored + dirty="$(git -C "$repository_root" status --porcelain=v1 --untracked-files=all -- "${producer_inputs[@]}")" + if [[ -n "$dirty" ]]; then + echo "Producer inputs differ from the exact Git HEAD:" >&2 + echo "$dirty" >&2 + exit 2 + fi + ignored="$( + git -C "$repository_root" ls-files --others --ignored --exclude-standard -- \ + Scripts build-logic/src gradle health/src health-fhir/src + )" + if [[ -n "$ignored" ]]; then + echo "Ignored files exist inside executable producer input roots:" >&2 + echo "$ignored" >&2 + exit 2 + fi +} + +assert_clean_producer_inputs +producer_revision="$(git -C "$repository_root" rev-parse --verify HEAD)" +temporary_root="$(mktemp -d "${TMPDIR:-/tmp}/mhc-health-connect-fhir.XXXXXX")" +temporary_root="$(cd "$temporary_root" && pwd -P)" +trap 'rm -rf -- "$temporary_root"' EXIT + +generated_root="$temporary_root/generated" +conformance_root="$generated_root/conformance-fixtures" +wire_root="$generated_root/wire-fixtures" +capability_path="$generated_root/health-connect-capabilities.json" + +( + cd "$repository_root" + GROVE_CONFORMANCE_EXPORT="$conformance_root" \ + GROVE_WIRE_EXPORT="$wire_root" \ + GROVE_CAPABILITY_EXPORT="$capability_path" \ + GROVE_EXCHANGE_PROTOCOL_CATALOG="$grove_fhir_root/catalog/exchange-protocol.json" \ + GROVE_MOBILE_EXCHANGE_CORPUS_DIRECTORY="$grove_fhir_root/Conformance/corpora/mobile-exchange" \ + ./gradlew :health-fhir:testDebugUnitTest --rerun-tasks --console=plain +) +assert_clean_producer_inputs + +conformance_paths=() +while IFS= read -r path; do + conformance_paths+=("$path") +done < <(find "$conformance_root" -maxdepth 1 -type f -name '*.json' -print | LC_ALL=C sort) +wire_paths=() +while IFS= read -r path; do + wire_paths+=("$path") +done < <(find "$wire_root" -maxdepth 1 -type f -name '*.json' -print | LC_ALL=C sort) +if [[ "${#conformance_paths[@]}" -ne 75 || "${#wire_paths[@]}" -ne 4 ]]; then + echo "Expected exactly 75 conformance resources and 4 wire resources; found ${#conformance_paths[@]} and ${#wire_paths[@]}." >&2 + exit 1 +fi + +python3 -B - "$capability_path" "$grove_fhir_root/catalog/health-connect-adapter.json" <<'PY' +import json +import sys +from pathlib import Path + +capability_path = Path(sys.argv[1]) +catalog_path = Path(sys.argv[2]) +capability = json.loads(capability_path.read_text(encoding="utf-8")) +catalog = json.loads(catalog_path.read_text(encoding="utf-8")) + +if capability != { + "schemaVersion": 1, + "sourcePackage": catalog["source"]["package"], + "sourceVersion": catalog["source"]["version"], + "sourceTypeExtension": catalog["sourceTypeExtension"]["url"], + "fieldDispositionSourceVersion": catalog["source"]["version"], + "allRecordTypes": sorted(row["token"] for row in catalog["recordTypes"]), + "supportedRecordTypes": sorted( + row["token"] for row in catalog["recordTypes"] if row["status"] == "supported" + ), + "deferredRecordTypes": sorted( + row["token"] for row in catalog["recordTypes"] if row["status"] == "deferred" + ), +}: + raise SystemExit( + "Android producer capabilities do not exactly match the checked-out " + "Grove Health Connect adapter catalog" + ) +PY + +manifest_root="$temporary_root/producer" +mkdir "$manifest_root" +mkdir "$manifest_root/resources" +cp "$capability_path" "$manifest_root/health-connect-capabilities.json" +for source in "${conformance_paths[@]}"; do + cp "$source" "$manifest_root/resources/conformance-$(basename "$source")" +done +for source in "${wire_paths[@]}"; do + cp "$source" "$manifest_root/resources/wire-$(basename "$source")" +done + +python3 -B - "$manifest_root" "$producer_revision" \ + "$grove_fhir_root/catalog/measurement-catalog.json" <<'PY' +import json +import sys +from pathlib import Path + +root = Path(sys.argv[1]) +revision = sys.argv[2] +release_version = json.loads(Path(sys.argv[3]).read_text(encoding="utf-8"))["version"] +resources = [] +for path in sorted((root / "resources").glob("*.json")): + value = json.loads(path.read_text(encoding="utf-8")) + profiles = value.get("meta", {}).get("profile", []) + required = [ + profile for profile in profiles + if isinstance(profile, str) and profile.startswith("https://grovealliance.org/fhir/") + ] + if not required: + raise SystemExit(f"generated resource has no Grove profile: {path.name}") + resources.append({"path": f"resources/{path.name}", "requiredProfiles": required}) + +semantic_vector_ids = [ + "active-energy", + "basal-body-temperature", + "blood-pressure", + "body-height", + "body-temperature", + "body-weight", + "distance", + "heart-rate", + "oxygen-saturation", + "respiratory-rate", + "sleep-duration", + "sleep-stage", + "step-count", +] +semantic_vectors = [ + { + "id": identifier, + "path": ( + "resources/conformance-health-connect-semantic-" + f"{identifier}-observation.json" + ), + "resourcePointer": "", + } + for identifier in semantic_vector_ids +] +resource_paths = {resource["path"] for resource in resources} +missing_semantic_paths = { + binding["path"] for binding in semantic_vectors +} - resource_paths +if missing_semantic_paths: + raise SystemExit( + "semantic-vector resources were not generated: " + + ", ".join(sorted(missing_semantic_paths)) + ) + +manifest = { + "schemaVersion": 1, + "fhirVersion": "4.0.1", + "producer": { + "name": "My Heart Counts Android Health Connect", + "version": release_version, + "revision": revision, + }, + "packages": [ + { + "alias": "mobile", + "packageId": "org.grovealliance.fhir.mobile", + "version": release_version, + }, + { + "alias": "health-connect", + "packageId": "org.grovealliance.fhir.health-connect", + "version": release_version, + }, + ], + "resources": resources, + "semanticVectors": semantic_vectors, +} +(root / "manifest.json").write_text( + json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", +) +PY + +python3 -B "$grove_fhir_root/Scripts/validate-producer.py" \ + --manifest "$manifest_root/manifest.json" \ + --validator "$validator_jar" \ + --package "mobile=$mobile_package" \ + --package "health-connect=$health_connect_package" + +if [[ -n "$export_directory" ]]; then + if [[ -L "$export_directory" ]]; then + echo "Export directory must not be a symbolic link: $export_directory" >&2 + exit 2 + fi + mkdir -p "$export_directory" + cp -R "$manifest_root/." "$export_directory/" +fi + +echo "Validated 79 deterministic FHIR R4 producer resources against the exact grove-fhir package closure." diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f7837421..ce6885d8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,6 +25,7 @@ detekt = "1.23.8" # please adjust github action version as well in case of versi dokka = "2.2.0" espressoCore = "3.7.0" fhirDataCapture = "1.3.1" +fhirCore = "6.10.3" firebaseAuthKtx = "23.2.1" firebaseFirestoreKtx = "25.1.4" firebaseFunctionsKtx = "21.2.1" @@ -36,6 +37,7 @@ googleGmsGoogleServices = "4.5.0" googleid = "1.2.0" guava = "33.6.0-android" # is a fixed version which works with all other dependencies healthConnectClient = "1.1.0" +healthConnectTesting = "1.0.0-alpha04" junit = "4.13.2" junitVersion = "1.3.0" kotlin = "2.4.0" # capped by the CodeQL Kotlin extractor, which supports versions up to 2.4.0 @@ -46,6 +48,7 @@ minSdk = "31" navigation3 = "1.1.4" paparazziVersion = "2.0.0-alpha05" playServicesAuth = "21.6.0" +robolectric = "4.16.1" room = "2.8.4" rulesVersion = "1.7.0" runnerVersion = "1.7.0" @@ -73,6 +76,7 @@ androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-co androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragmentKtxVersion" } androidx-fragment-compose = { module = "androidx.fragment:fragment-compose", version.ref = "fragmentCompose" } androidx-health-connect-client = { module = "androidx.health.connect:connect-client", version.ref = "healthConnectClient" } +androidx-health-connect-testing = { module = "androidx.health.connect:connect-testing", version.ref = "healthConnectTesting" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleKtx" } androidx-lifecycle-view-model-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleKtx" } @@ -83,6 +87,7 @@ androidx-lifecycle-viewmodel-navigation3 = { group = "androidx.lifecycle", name androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } +androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "room" } androidx-security-crypto-ktx = { group = "androidx.security", name = "security-crypto-ktx", version.ref = "securityCryptoKtx" } androidx-test-core = { group = "androidx.test", name = "core", version.ref = "testCoreVersion" } androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "rulesVersion" } @@ -111,6 +116,7 @@ firebase-storage-ktx = { group = "com.google.firebase", name = "firebase-storage google-truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } googleid = { module = "com.google.android.libraries.identity.googleid:googleid", version.ref = "googleid" } guava = { group = "com.google.guava", name = "guava", version.ref = "guava" } +hl7-fhir-r4 = { module = "ca.uhn.hapi.fhir:org.hl7.fhir.r4", version.ref = "fhirCore" } junit = { group = "junit", name = "junit", version.ref = "junit" } kotlin-gradle = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } kotlin-reflect = { group = "org.jetbrains.kotlin", name = "kotlin-reflect", version.ref = "kotlin" } @@ -120,6 +126,7 @@ navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", ver navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" } paparazzi = { module = "app.cash.paparazzi:paparazzi", version.ref = "paparazziVersion" } play-services-auth = { group = "com.google.android.gms", name = "play-services-auth", version.ref = "playServicesAuth" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timberVersion" } zstd-jni = { module = "com.github.luben:zstd-jni", version.ref = "zstdJni" } diff --git a/health-fhir/README.md b/health-fhir/README.md new file mode 100644 index 00000000..f3ef253e --- /dev/null +++ b/health-fhir/README.md @@ -0,0 +1,303 @@ +# Module health-fhir + + + +`health-fhir` is the AndroidX Health Connect 1.1.0 producer for the Grove FHIR +R4 Mobile and Health Connect 0.6.0 contracts. One conversion event represents +exactly one immutable source-record revision and produces a FHIR collection +`Bundle`. A source removal is a separate Provenance-only retraction event; it is +an assertion for a configured sink, not a FHIR delete command. + +The module converts and coordinates export and includes a production Room +journal. It does not read Health Connect, select a repository schema, upload +data, manage cryptographic keys, or advance a Health Connect changes token. The +application opens the journal and supplies an idempotent sink. The coordinator +commits local state only after that sink acknowledges the exact serialized +event. + +This library is therefore not a standalone production exporter. Its +`RoomHealthConnectExportJournal` supplies exact-payload persistence, global +monotonic event sequences, revision compare-and-set, and renewable fenced source +and reconciliation leases across database instances without holding a database +transaction across sink I/O. It uses an exported versioned Room schema and never +falls back to destructive migration. The application still owns database +backup/retention policy, the managed HMAC key, Health Connect changes-token +coordination, credentials, and destination acknowledgement semantics. + +## Required configuration + +A deployment must persist all of the following independently of an app process: + +- one repository-scope `FhirIdentifierKey` for exactly one Health Connect + repository; +- one lowercase producer-instance UUID and one positive monotonic event counter; +- one managed HMAC-SHA-256 key, key id, positive epoch, and deployment-owned + identity-system family; +- distinct deployment-owned event and entry-node Identifier systems; and +- the export journal and pending payload bytes. + +Open one durable journal database and retain it for at least as long as an +emitted graph can be replayed or retracted: + +```kotlin +val journal = RoomHealthConnectExportJournal.open( + context = applicationContext, + databaseName = "study-health-connect-fhir-journal.db", +) + +val coordinator = HealthConnectExportCoordinator( + converter = converter, + journal = journal, + sink = idempotentDestinationSink, +) +``` + +The default lease timing is appropriate for ordinary on-device export. A caller +may supply `RoomHealthConnectJournalOptions` for an unusually slow or contended +deployment; shortening leases increases false lease-loss risk. Close the journal +only when the owning application component is permanently stopping. + +The public `00..1f` key in the Grove conformance vectors is rejected by the +production `GroveHmacIdentityKey` constructor. A production key must contain at +least 32 random bytes. Rotation changes both epoch and Identifier system; old +epochs must remain available while their outputs can be replayed or retracted. + +```kotlin +val identityKey = GroveHmacIdentityKey( + identifierSystemFamily = "https://study.example/fhir/NamingSystem/grove-opaque-v2", + keyId = managedKeyId, + epoch = managedKeyEpoch, + secret = managedKeyBytes, +) + +val scope = HealthConnectSynchronizationScope.create( + repositoryScope = persistedRepositoryScope, + producerInstance = persistedProducerInstanceUuid, + configurationFingerprint = exactReadFilterFingerprint, + identityKey = identityKey, +) + +val converter = HealthConnectConverter( + context = HealthConnectConversionContext( + subject = HealthConnectPatientSubject.Bundled(participantPatient), + assembler = converterApplicationDevice, + assemblerHost = currentHostFacts()?.let { host -> + HealthConnectHostDeviceResource( + sourceDeviceToken = host.governedSnapshotToken, + resource = host.fhirTemplate, + ) + }, + eventIdentifierSystem = "https://study.example/fhir/NamingSystem/grove-event-v2", + entryNodeIdentifierSystem = "https://study.example/fhir/NamingSystem/grove-entry-node-v2", + userAuthoredTextPolicy = HealthConnectUserAuthoredTextPolicy.RETAIN, + // Optional: omit this block unless wire-level native round-trip is required. + nativeIdentifierDisclosure = HealthConnectNativeIdentifierDisclosure( + system = + "https://study.example/fhir/identifiers/device-7/health-connect-records", + type = HealthConnectNativeIdentifierType( + text = "Health Connect repository record id", + ), + ), + recordingDevice = { sourceDevice -> + deviceRegistry.stablePerUnitToken(sourceDevice)?.let { token -> + HealthConnectRecordingDeviceResource( + stablePerUnitToken = token, + resource = recordingDeviceDescription(sourceDevice), + ) + } + }, + ), + synchronizationScope = scope, +) + +val outcome = converter.convertOutcome( + record = recordReadFromHealthConnect, + convertedAt = assemblyInstant, + eventSequence = persistedNextEventSequence, +) +``` + +`userAuthoredTextPolicy` is intentionally mandatory: choose `RETAIN` to emit +admitted Health Connect titles/notes or `OMIT` to suppress them. Neither behavior +is an implicit default or inferred privacy policy. + +Use `HealthConnectPatientSubject.Logical` with a complete, deployment-owned +pseudonymous `Identifier` when the receiver resolves the participant by logical +identity; Grove emits an identifier-only reference and does not fabricate a +`Patient` entry. Use `HealthConnectPatientSubject.Bundled` only when concrete +patient facts belong in the event Bundle. + +Use `convertOutcome` when record-data rejection is part of normal collection +flow. It distinguishes `Converted`, `Unsupported`, and `Rejected`. `convert` +retains the throwing boundary for callers that deliberately treat source-data +failures as exceptions. Producer configuration and graph-invariant failures are +programming errors in both APIs. + +## Identity and privacy + +Every produced Observation carries exactly two Grove-typed opaque identifiers: +`source-record` and `source-output`. A synthesized glucose Specimen carries the +same source-record identity plus its own `source-output` identity, whose +`specimen` discriminator is the exact admitted source enum. Writer records and +recording Devices have separate domains. Values use the normative +`v2:::` form over unsigned 32-bit +length-framed UTF-8 fields. Repository scope and stable physical-device tokens +are never serialized. Writer ids are emitted only as separately scoped opaque +writer-record identities. Raw Health Connect record ids are omitted by default. + +The closed protocol implementation also recognizes the Provider-specific +`provider-output` and `provider-artifact` domains even though this adapter emits +the generic Health Connect source domains. This keeps normative parsing and +vector behavior aligned with the complete Mobile exchange protocol rather than +silently treating Provider coordinates as generic source coordinates. + +When a deployment explicitly configures `nativeIdentifierDisclosure`, Grove +adds the exact `Metadata.id` under that caller-owned absolute repository/store +system to the one-to-one primary Observation. The native Identifier supplements +the two mandatory Grove identities; it is never an event, entry, or retraction +key. It is not repeated on heart-rate/sample outputs, sleep stages, workout +segments or laps, skin-temperature deltas, present-nutrient outputs, synthesized +Specimens, Devices, or Provenance. Series and fan-out records without a single +primary output therefore do not carry it. Grove event, entry-node, opaque +identity, and identifier-role systems are rejected for this purpose. + +Output identity is independent of mutable clinical values. Exactly-one outputs +use role `single` and their measurement id; sample outputs use `sample` and +`|`; sleep stages use `sleep-stage` and +`|||`; present nutrients use +`present-field` and their measurement id. This makes a corrected value retain +its source slot identity while same-time duplicates remain distinct. For ordered +Health Connect sample and stage lists, `occurrence` is assigned among identical +canonical coordinates in the exact platform-list order before output sorting. +The adapter never derives identity from a clinical value or unordered iteration. + +Event values are clear `e2::` identifiers. +Entries without a selected business identity use deterministic `n2:` entry-node +keys. Bundle `fullUrl` values are UUIDv5 over the length-framed complete +Identifier pair using the Grove namespace. UUIDv5 is graph addressing, not a +privacy control. + +A shared FHIR Device means one physical instance. Manufacturer and model are +descriptive only, so a recording Device is omitted unless the deployment +supplies a governed stable per-unit token. Grove HMACs that token into a stable +typed `recording-device` identity and a separate event-scoped `device-snapshot`; +the snapshot is the Bundle entry key. Recording Device templates cannot carry +caller identifiers, and `Device.serialNumber` is never admitted. + +Application and host hardware are also separate immutable snapshots. When an +`assemblerHost` is supplied, Grove emits one profiled host snapshot and links +the converter application snapshot to it through `Device.parent`; +operating-system facts stay on the host rather than being folded into an +application version. + +`Metadata.dataOrigin.packageName` has a deliberately different identity model. +The conversion Provenance carries it at `Provenance.entity.agent.who` as a typed, +identifier-only logical `Device` Reference using +`https://grovealliance.org/fhir/health-connect/NamingSystem/android-package-name`. +It identifies an application product, not an installation, host, account, +person, or physical recorder. Grove does not fabricate a Bundle Device entry or +claim a Device profile for that logical reference. + +HMAC identifiers do not de-identify the patient, clinical values, timestamps, +relationships, or payloads in a Bundle. + +## Clinical and lifecycle semantics + +- `Observation.effective[x]` comes from the source clinical time. Mobile + effective instants use the guide's millisecond half-even policy while identity + inputs retain exact nanoseconds. +- `Observation.issued` is `Metadata.lastModifiedTime`. +- A present `Metadata.id` must be nonblank Unicode-scalar text. Its + `clientRecordVersion` must be non-negative (including `0` and + `Long.MAX_VALUE`); a negative version fails closed. When `Metadata.id` is + absent, AndroidX exposes no presence bit for the default version, so neither + a writer id nor version is emitted. +- conversion `Provenance.occurred[x]` is the emitted source activity time or + span, `Provenance.recorded` is the assertion time, and `Bundle.timestamp` is + assembly time. +- `RestingHeartRateRecord` is a point `effectiveDateTime` with LOINC `40443-4`; + the producer does not invent a daily average. +- Mindfulness type and VO2 max measurement method retain their exact AndroidX + tokens in Health Connect code systems through `Observation.method`. The + configured text policy consistently retains or omits nonblank mindfulness, + exercise, and sleep titles through the one shared session-title extension and + notes through `Observation.note`. +- Cycling cadence, power, speed, step cadence, heart rate, and skin temperature + emit one Observation per source sample/delta. Nutrition emits one Observation + per present nutrient, including biotin. Blood glucose emits exactly one of the + four admitted specimen-profile alternatives and one deterministic Specimen; + unknown and tear sources are rejected. +- Updates first emit a separate retraction for every prior output/artifact and + event-scoped Device snapshot, then a new active event. Retractions contain one + profiled Provenance and no copied clinical resource or value. Each target is a + typed complete Identifier pair with a closed target-role extension. + +`HealthConnectFieldDispositions` inventories every public top-level, metadata, +and nested field in the pinned AndroidX 1.1.0 source API. Tests fail when a field +is added without a mapped, intentionally omitted, rejected, or unavailable +disposition. `PlannedExerciseSessionRecord` remains the only deferred type; the +other 40 source types are explicitly supported. + +## Retry and sink rules + +Use `HealthConnectExportCoordinator` for durable export. An exact retry reuses +the event Identifier, all times, entry keys, JSON bytes, and checksum. A content +or source-version change receives a new sequence. The sink must durably and +idempotently apply the complete batch before returning its acknowledgement and +must index complete `(Identifier.system, Identifier.value)` pairs. A collection +Bundle has no transaction or delete semantics; atomic application and lifecycle +policy belong to the sink. + +The journal implementation must issue renewable, monotonically fenced leases +across coordinator instances and processes sharing one device-local repository. A source transition checks +its live fence and base revision at stage, local storage, and exact-event +completion; a reconciliation additionally holds one repository/type fence while +it invokes the complete-read callback, drains pending events, upserts present +records, and derives absence. Do not read the complete source list before calling +`reconcile`: the callback API deliberately puts that read inside the fence. A +database-backed implementation must not keep a database transaction open across +the source read or sink network I/O. + +The adversarial in-memory journal is test-only. Production integrations should +use `RoomHealthConnectExportJournal`, which implements the same port with exact +payload persistence, global sequence allocation, revision CAS, and renewable +monotonic fences. The application must still integration-test its managed +identity key/epoch, persistent producer instance, exact read-filter fingerprint, +changes-token commit ordering, backup/retention policy, and idempotent +destination sink; those policies cannot be inferred by this FHIR library. + +## Conformance + +The producer tests lock the normative HMAC, Unicode, event, entry-node, and +UUIDv5 vectors; output-count rules; clinical mappings; immutable retry payloads; +and active/retraction graph shapes. The active boundary admits only the closed +output, supporting, and lifecycle resource type sets; prohibits contained +resources; resolves every literal reference inside the Bundle; closes direct +Observation, DocumentReference, Device, QuestionnaireResponse, and Provenance +profile modes; requires exactly one transform Provenance; and rejects +disconnected support. The shared structured corpus is pinned to its exact 31 +reviewed negative cases. The capability export reports the exact AndroidX +baseline and supported/deferred inventory. + +Run the offline official-validator lane from a clean producer revision with the +exact 0.6.0 packages: + +```bash +./Scripts/validate-health-connect-fhir-conformance.sh \ + --grove-fhir /path/to/grove-fhir \ + --mobile-package /path/to/mobile/package.tgz \ + --health-connect-package /path/to/health-connect/package.tgz \ + --validator-jar /path/to/validator_cli.jar +``` + +The script invokes the catalog generator by repository-absolute path and +deliberately refuses a dirty source tree—including generator changes—so generated +fixtures can be attributed to one exact producer revision. diff --git a/health-fhir/build.gradle.kts b/health-fhir/build.gradle.kts new file mode 100644 index 00000000..ef0967ef --- /dev/null +++ b/health-fhir/build.gradle.kts @@ -0,0 +1,86 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +plugins { + alias(libs.plugins.grove.library) + alias(libs.plugins.google.devtools.ksp) +} + +ksp { + arg("room.schemaLocation", layout.projectDirectory.dir("schemas").asFile.absolutePath) +} + +android { + namespace = "org.grovealliance.health.fhir" + + buildTypes { + debug { + // The FHIR reference implementation jars cannot be instrumented by JaCoCo. + enableAndroidTestCoverage = false + } + } + + testOptions { + unitTests.isIncludeAndroidResources = true + unitTests.all { test -> + test.extensions.configure { + // The reference implementation ships parser classes beyond JaCoCo's JVM class + // size ceiling. They are a dependency, not producer code, so do not transform them. + excludes = (excludes.orEmpty() + "org.hl7.fhir.*").distinct() + } + val configuredExport = System.getenv("GROVE_CONFORMANCE_EXPORT") + val defaultExport = layout.buildDirectory.dir("conformance-fixtures").get().asFile.absolutePath + test.systemProperty("grove.conformance.export", configuredExport ?: defaultExport) + val configuredWireExport = System.getenv("GROVE_WIRE_EXPORT") + val defaultWireExport = layout.buildDirectory.dir("wire-fixtures").get().asFile.absolutePath + test.systemProperty("grove.wire.export", configuredWireExport ?: defaultWireExport) + val configuredCapabilityExport = System.getenv("GROVE_CAPABILITY_EXPORT") + val defaultCapabilityExport = layout.buildDirectory + .file("producer-capabilities/health-connect.json") + .get() + .asFile + .absolutePath + test.systemProperty( + "grove.capability.export", + configuredCapabilityExport ?: defaultCapabilityExport, + ) + test.systemProperty( + "grove.health-connect.version", + libs.versions.healthConnectClient.get(), + ) + System.getenv("GROVE_EXCHANGE_PROTOCOL_CATALOG")?.let { catalog -> + test.systemProperty("grove.exchange-protocol.catalog", catalog) + } + System.getenv("GROVE_MOBILE_EXCHANGE_CORPUS_DIRECTORY")?.let { corpusDirectory -> + test.systemProperty("grove.mobile-exchange.corpus-directory", corpusDirectory) + } + } + } +} + +dependencies { + api(project(":health")) + api(libs.hl7.fhir.r4) + + implementation(libs.androidx.room.ktx) + implementation(libs.androidx.room.runtime) + implementation(libs.bundles.ktx.coroutines) + implementation(libs.kotlinx.serialization.json) + ksp(libs.androidx.room.compiler) + + // connect-testing currently declares a 1.2 alpha client transitively. Keep the FHIR producer's + // tests on the exact stable 1.1.0 API that the library compiles and claims in its capability + // manifest, so an alpha-only field cannot silently enter the supported source contract. + testImplementation(libs.androidx.health.connect.testing) { + exclude(group = "androidx.health.connect", module = "connect-client") + exclude(group = "androidx.health.connect", module = "connect-client-proto") + exclude(group = "androidx.health.connect", module = "connect-client-external-protobuf") + } + testImplementation(libs.androidx.room.testing) + testImplementation(libs.androidx.test.core) + testImplementation(libs.robolectric) +} diff --git a/health-fhir/schemas/org.grovealliance.health.fhir.RoomHealthConnectExportDatabase/1.json b/health-fhir/schemas/org.grovealliance.health.fhir.RoomHealthConnectExportDatabase/1.json new file mode 100644 index 00000000..103908c9 --- /dev/null +++ b/health-fhir/schemas/org.grovealliance.health.fhir.RoomHealthConnectExportDatabase/1.json @@ -0,0 +1,375 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "0acae55c94e6e7e8290d420078b2e590", + "entities": [ + { + "tableName": "health_connect_export_counter", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`singletonId` INTEGER NOT NULL, `nextEventSequence` TEXT NOT NULL, `nextFence` TEXT NOT NULL, PRIMARY KEY(`singletonId`))", + "fields": [ + { + "fieldPath": "singletonId", + "columnName": "singletonId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nextEventSequence", + "columnName": "nextEventSequence", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nextFence", + "columnName": "nextFence", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "singletonId" + ] + } + }, + { + "tableName": "health_connect_export_entries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repositoryScopeKey` TEXT NOT NULL, `recordType` TEXT NOT NULL, `healthConnectId` TEXT NOT NULL, `revision` TEXT NOT NULL, `encodedEntry` TEXT NOT NULL, PRIMARY KEY(`repositoryScopeKey`, `recordType`, `healthConnectId`))", + "fields": [ + { + "fieldPath": "repositoryScopeKey", + "columnName": "repositoryScopeKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recordType", + "columnName": "recordType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "healthConnectId", + "columnName": "healthConnectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encodedEntry", + "columnName": "encodedEntry", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repositoryScopeKey", + "recordType", + "healthConnectId" + ] + }, + "indices": [ + { + "name": "index_health_connect_export_entries_repositoryScopeKey_recordType", + "unique": false, + "columnNames": [ + "repositoryScopeKey", + "recordType" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_health_connect_export_entries_repositoryScopeKey_recordType` ON `${TABLE_NAME}` (`repositoryScopeKey`, `recordType`)" + } + ] + }, + { + "tableName": "health_connect_pending_exports", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repositoryScopeKey` TEXT NOT NULL, `recordType` TEXT NOT NULL, `healthConnectId` TEXT NOT NULL, `eventSequence` TEXT NOT NULL, `encodedPending` TEXT NOT NULL, PRIMARY KEY(`repositoryScopeKey`, `recordType`, `healthConnectId`))", + "fields": [ + { + "fieldPath": "repositoryScopeKey", + "columnName": "repositoryScopeKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recordType", + "columnName": "recordType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "healthConnectId", + "columnName": "healthConnectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventSequence", + "columnName": "eventSequence", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encodedPending", + "columnName": "encodedPending", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repositoryScopeKey", + "recordType", + "healthConnectId" + ] + }, + "indices": [ + { + "name": "index_health_connect_pending_exports_eventSequence", + "unique": true, + "columnNames": [ + "eventSequence" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_health_connect_pending_exports_eventSequence` ON `${TABLE_NAME}` (`eventSequence`)" + }, + { + "name": "index_health_connect_pending_exports_repositoryScopeKey_recordType", + "unique": false, + "columnNames": [ + "repositoryScopeKey", + "recordType" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_health_connect_pending_exports_repositoryScopeKey_recordType` ON `${TABLE_NAME}` (`repositoryScopeKey`, `recordType`)" + } + ] + }, + { + "tableName": "health_connect_source_leases", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repositoryScopeKey` TEXT NOT NULL, `recordType` TEXT NOT NULL, `healthConnectId` TEXT NOT NULL, `owner` TEXT NOT NULL, `fence` TEXT NOT NULL, `reconciliationFence` TEXT, `expiresAtEpochMillis` INTEGER NOT NULL, PRIMARY KEY(`repositoryScopeKey`, `recordType`, `healthConnectId`))", + "fields": [ + { + "fieldPath": "repositoryScopeKey", + "columnName": "repositoryScopeKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recordType", + "columnName": "recordType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "healthConnectId", + "columnName": "healthConnectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "owner", + "columnName": "owner", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fence", + "columnName": "fence", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reconciliationFence", + "columnName": "reconciliationFence", + "affinity": "TEXT" + }, + { + "fieldPath": "expiresAtEpochMillis", + "columnName": "expiresAtEpochMillis", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repositoryScopeKey", + "recordType", + "healthConnectId" + ] + }, + "indices": [ + { + "name": "index_health_connect_source_leases_repositoryScopeKey_recordType_expiresAtEpochMillis", + "unique": false, + "columnNames": [ + "repositoryScopeKey", + "recordType", + "expiresAtEpochMillis" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_health_connect_source_leases_repositoryScopeKey_recordType_expiresAtEpochMillis` ON `${TABLE_NAME}` (`repositoryScopeKey`, `recordType`, `expiresAtEpochMillis`)" + } + ] + }, + { + "tableName": "health_connect_reconciliation_leases", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repositoryScopeKey` TEXT NOT NULL, `recordType` TEXT NOT NULL, `owner` TEXT NOT NULL, `fence` TEXT NOT NULL, `expiresAtEpochMillis` INTEGER NOT NULL, PRIMARY KEY(`repositoryScopeKey`, `recordType`))", + "fields": [ + { + "fieldPath": "repositoryScopeKey", + "columnName": "repositoryScopeKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recordType", + "columnName": "recordType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "owner", + "columnName": "owner", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fence", + "columnName": "fence", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expiresAtEpochMillis", + "columnName": "expiresAtEpochMillis", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repositoryScopeKey", + "recordType" + ] + } + }, + { + "tableName": "health_connect_unmatched_deletions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repositoryScopeKey` TEXT NOT NULL, `projectionScopeKey` TEXT NOT NULL, `recordType` TEXT NOT NULL, `healthConnectId` TEXT NOT NULL, `observedAt` TEXT NOT NULL, PRIMARY KEY(`repositoryScopeKey`, `recordType`, `healthConnectId`))", + "fields": [ + { + "fieldPath": "repositoryScopeKey", + "columnName": "repositoryScopeKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "projectionScopeKey", + "columnName": "projectionScopeKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recordType", + "columnName": "recordType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "healthConnectId", + "columnName": "healthConnectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "observedAt", + "columnName": "observedAt", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repositoryScopeKey", + "recordType", + "healthConnectId" + ] + } + }, + { + "tableName": "health_connect_rejected_records", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repositoryScopeKey` TEXT NOT NULL, `projectionScopeKey` TEXT NOT NULL, `recordType` TEXT NOT NULL, `healthConnectId` TEXT NOT NULL, `sourceLastModified` TEXT NOT NULL, `observedAt` TEXT NOT NULL, `reason` TEXT NOT NULL, PRIMARY KEY(`repositoryScopeKey`, `recordType`, `healthConnectId`))", + "fields": [ + { + "fieldPath": "repositoryScopeKey", + "columnName": "repositoryScopeKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "projectionScopeKey", + "columnName": "projectionScopeKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recordType", + "columnName": "recordType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "healthConnectId", + "columnName": "healthConnectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceLastModified", + "columnName": "sourceLastModified", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "observedAt", + "columnName": "observedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reason", + "columnName": "reason", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repositoryScopeKey", + "recordType", + "healthConnectId" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0acae55c94e6e7e8290d420078b2e590')" + ] + } +} \ No newline at end of file diff --git a/health-fhir/schemas/org.grovealliance.health.fhir.RoomHealthConnectExportDatabase/1.json.license b/health-fhir/schemas/org.grovealliance.health.fhir.RoomHealthConnectExportDatabase/1.json.license new file mode 100644 index 00000000..d1ff981c --- /dev/null +++ b/health-fhir/schemas/org.grovealliance.health.fhir.RoomHealthConnectExportDatabase/1.json.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +SPDX-License-Identifier: MIT diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/ExportIdentifiers.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/ExportIdentifiers.kt new file mode 100644 index 00000000..474e73ea --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/ExportIdentifiers.kt @@ -0,0 +1,89 @@ +// +// This source file is part of the My Heart Counts Android open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +package org.grovealliance.health.fhir + +/** + * An opaque digest naming one export scope. + * + * Holding this as a type rather than a `String` means the digest is checked where it is minted + * instead of at every holder that stores it, and a repository scope can no longer be passed where + * a projection scope belongs. + */ +@JvmInline +value class ScopeKey(val value: String) { + init { + require(value.matches(PATTERN)) { "A scope key must be an opaque v1 digest." } + } + + override fun toString(): String = value + + private companion object { + val PATTERN = Regex("v1:[0-9a-f]{64}") + } +} + +/** + * The durable, strictly increasing position of one export event. + * + * The sequence participates in the exchange identity, so it is text rather than a number: a + * deployment's counter may outgrow [Long], and its canonical decimal spelling has to survive a + * round trip through the journal unchanged. + */ +@JvmInline +value class EventSequence(val value: String) : Comparable { + init { + require(value.matches(PATTERN)) { + "An event sequence must be a positive canonical decimal integer starting at one." + } + } + + /** + * Numeric order over canonical decimals: a longer spelling is always the larger number, and + * equal lengths compare lexicographically. + */ + override fun compareTo(other: EventSequence): Int = + compareValuesBy(this, other, { it.value.length }, { it.value }) + + override fun toString(): String = value + + private companion object { + val PATTERN = Regex("[1-9][0-9]*") + } +} + +/** Opaque content-derived revision used for journal compare-and-swap transitions. */ +@JvmInline +value class HealthConnectJournalRevision(val value: String) { + init { + require(value.matches(PATTERN)) { "A journal revision must be an opaque v1 SHA-256 digest." } + } + + override fun toString(): String = value + + private companion object { + val PATTERN = Regex("v1:[0-9a-f]{64}") + } +} + +/** Monotonic fencing token issued by a journal-owned cross-instance lease. */ +@JvmInline +value class HealthConnectJournalFence(val value: String) : Comparable { + init { + require(value.matches(PATTERN)) { "A journal fence must be a positive canonical decimal integer." } + } + + override fun compareTo(other: HealthConnectJournalFence): Int = + compareValuesBy(this, other, { it.value.length }, { it.value }) + + override fun toString(): String = value + + private companion object { + val PATTERN = Regex("[1-9][0-9]*") + } +} diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/FhirIdentifierKey.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/FhirIdentifierKey.kt new file mode 100644 index 00000000..f6ee04d8 --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/FhirIdentifierKey.kt @@ -0,0 +1,55 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import org.hl7.fhir.r4.model.Identifier +import java.net.URI + +/** An immutable, complete FHIR logical identifier; neither component may be discarded. */ +data class FhirIdentifierKey( + val system: String, + val value: String, +) : Comparable { + init { + GroveUnicode.requireScalarText(system, "Identifier.system") + GroveUnicode.requireScalarText(value, "Identifier.value") + require(system.isAbsoluteAsciiUri()) { + "Identifier.system must be a nonblank absolute ASCII RFC 3986 URI." + } + require(value.isNotBlank()) { "Identifier.value must not be blank." } + } + + fun identifier(): Identifier = Identifier().setSystem(system).setValue(value) + + override fun compareTo(other: FhirIdentifierKey): Int = + compareValuesBy(this, other, FhirIdentifierKey::system, FhirIdentifierKey::value) + + companion object { + fun from(identifier: Identifier): FhirIdentifierKey { + require(identifier.hasSystem() && identifier.hasValue()) { + "A complete Identifier.system and Identifier.value are required." + } + return FhirIdentifierKey(identifier.system, identifier.value) + } + } +} + +internal fun Identifier.key(): FhirIdentifierKey = FhirIdentifierKey.from(this) + +internal fun Identifier.hasGroveRole(role: GroveIdentifierRole): Boolean = + type.coding.any { + it.system == HealthConnectContract.GROVE_IDENTIFIER_ROLE && it.code == role.code + } + +/** FHIR identity systems use URI syntax, never an unescaped IRI or whitespace-bearing string. */ +internal fun String.isAbsoluteAsciiUri(): Boolean = + isNotBlank() && all { it.code in ASCII_URI_FIRST..ASCII_URI_LAST } && + runCatching { URI(this).isAbsolute }.getOrDefault(false) + +private const val ASCII_URI_FIRST = 0x21 +private const val ASCII_URI_LAST = 0x7e diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/GroveExchangeIdentity.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/GroveExchangeIdentity.kt new file mode 100644 index 00000000..f988328e --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/GroveExchangeIdentity.kt @@ -0,0 +1,84 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.Extension +import org.hl7.fhir.r4.model.Identifier +import org.hl7.fhir.r4.model.Resource +import java.nio.ByteBuffer +import java.security.MessageDigest +import java.util.UUID + +/** Implements the Grove Mobile exchange-entry identity contract exactly. */ +object GroveExchangeIdentity { + const val ALGORITHM = "uuid-v5-framed-identifier-v2" + const val ENTRY_IDENTIFIER_EXTENSION = HealthConnectContract.GROVE_EXCHANGE_ENTRY_NODE_KEY + + private val namespace = UUID.fromString("43df4575-bff7-5a57-9a80-2472cd2b0623") + + /** Returns the deterministic lowercase UUID URN for one complete business Identifier. */ + fun fullUrl(identifier: Identifier): String { + FhirIdentifierKey.from(identifier) + return "urn:uuid:${uuidV5(namespace, identifierName(identifier))}" + } + + /** Returns the required Bundle.entry extension containing the exact derivation Identifier. */ + fun entryIdentifierExtension(identifier: Identifier): Extension { + FhirIdentifierKey.from(identifier) + return Extension(ENTRY_IDENTIFIER_EXTENSION, identifier.copy()) + } + + internal fun identifierName(system: String, value: String): ByteArray = + identifierName(Identifier().setSystem(system).setValue(value)) + + /** + * The UUID-v5 name is the protocol's length-framed UTF-8 `[system, value]` pair. + */ + private fun identifierName(identifier: Identifier): ByteArray { + GroveUnicode.requireScalarText(identifier.system, "Identifier.system") + GroveUnicode.requireScalarText(identifier.value, "Identifier.value") + return GroveExchangeProtocol.frameFields(listOf(identifier.system, identifier.value)) + } + + private fun uuidV5(namespace: UUID, name: ByteArray): UUID { + val namespaceBytes = ByteBuffer.allocate(UUID_BYTE_COUNT) + .putLong(namespace.mostSignificantBits) + .putLong(namespace.leastSignificantBits) + .array() + val digest = MessageDigest.getInstance("SHA-1").apply { + update(namespaceBytes) + update(name) + }.digest() + digest[UUID_VERSION_BYTE_INDEX] = ( + digest[UUID_VERSION_BYTE_INDEX].toInt() and UUID_VERSION_CLEAR_MASK or UUID_VERSION_FIVE_BITS + ).toByte() + digest[UUID_VARIANT_BYTE_INDEX] = ( + digest[UUID_VARIANT_BYTE_INDEX].toInt() and UUID_VARIANT_CLEAR_MASK or UUID_IETF_VARIANT_BITS + ).toByte() + val buffer = ByteBuffer.wrap(digest, 0, UUID_BYTE_COUNT) + return UUID(buffer.long, buffer.long) + } + + private const val UUID_VERSION_BYTE_INDEX = 6 + private const val UUID_VARIANT_BYTE_INDEX = 8 + private const val UUID_VERSION_CLEAR_MASK = 0x0f + private const val UUID_VERSION_FIVE_BITS = 0x50 + private const val UUID_VARIANT_CLEAR_MASK = 0x3f + private const val UUID_IETF_VARIANT_BITS = 0x80 + private const val UUID_BYTE_COUNT = 16 +} + +internal fun Bundle.addGroveEntry( + entryIdentifier: Identifier, + entryResource: Resource, +): Bundle.BundleEntryComponent = addEntry().apply { + fullUrl = GroveExchangeIdentity.fullUrl(entryIdentifier) + addExtension(GroveExchangeIdentity.entryIdentifierExtension(entryIdentifier)) + resource = entryResource +} diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/GroveExchangeIdentityValidation.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/GroveExchangeIdentityValidation.kt new file mode 100644 index 00000000..894d8bb0 --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/GroveExchangeIdentityValidation.kt @@ -0,0 +1,136 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import org.hl7.fhir.r4.model.Base +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.Device +import org.hl7.fhir.r4.model.DocumentReference +import org.hl7.fhir.r4.model.Identifier +import org.hl7.fhir.r4.model.MedicationAdministration +import org.hl7.fhir.r4.model.MedicationStatement +import org.hl7.fhir.r4.model.Observation +import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.PlanDefinition +import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.ResearchStudy +import org.hl7.fhir.r4.model.ResearchSubject +import org.hl7.fhir.r4.model.Resource +import org.hl7.fhir.r4.model.Specimen +import org.hl7.fhir.r4.model.VisionPrescription +import java.util.Collections +import java.util.IdentityHashMap + +/** Enforces the catalog's role-priority selection for every deterministic exchange entry key. */ +internal fun Bundle.requireGroveEntryIdentitySelection() { + require(entry.map { it.fullUrl }.distinct().size == entry.size) { + "Exchange entry identity validation requires unique fullUrl values." + } + requireGroveIdentitySystemRoleSeparation() + entry.forEachIndexed { index, bundleEntry -> + val label = "Bundle.entry[$index]" + val extensions = bundleEntry.extension.filter { + it.url == GroveExchangeIdentity.ENTRY_IDENTIFIER_EXTENSION + } + val selected = extensions.singleOrNull()?.value as? Identifier + ?: throw IllegalArgumentException("$label requires one Identifier-valued Grove entry key extension.") + val selectedRole = selected.groveRole("$label entry key") + ?: throw IllegalArgumentException("$label entry key requires one known Grove Identifier role.") + selected.key() + require(bundleEntry.hasFullUrl() && bundleEntry.hasResource()) { + "$label requires a fullUrl and resource." + } + require(bundleEntry.fullUrl == GroveExchangeIdentity.fullUrl(selected)) { + "$label.fullUrl must derive from its exact selected entry Identifier." + } + + val resourceIdentifiers = bundleEntry.resource.typedGroveIdentifiers( + "$label ${bundleEntry.resource.fhirType()}", + ) + val expectedRole = HealthConnectContract.entryIdentifierPriority.firstOrNull(resourceIdentifiers::containsKey) + if (expectedRole == null) { + require(selectedRole == GroveIdentifierRole.ENTRY_NODE) { + "$label must use an entry-node key when its resource has no prioritized business Identifier." + } + } else { + require( + selectedRole == expectedRole && + resourceIdentifiers.getValue(expectedRole).matchesIdentifierPair(selected), + ) { + "$label must select the resource's exact ${expectedRole.code} Identifier by catalog priority." + } + } + } +} + +/** One event cannot reuse a namespace for another role or split one role across namespaces. */ +private fun Bundle.requireGroveIdentitySystemRoleSeparation() { + val systemsByRole = mutableMapOf() + val rolesBySystem = mutableMapOf() + visitPopulatedElements { element -> + val identifier = element as? Identifier ?: return@visitPopulatedElements + val role = identifier.groveRole("Exchange graph Identifier") ?: return@visitPopulatedElements + val key = identifier.key() + require(systemsByRole[role]?.let { it == key.system } != false) { + "One exchange event cannot split the ${role.code} role across Identifier.system values." + } + require(rolesBySystem[key.system]?.let { it == role } != false) { + "One exchange event cannot reuse an Identifier.system across Grove roles." + } + systemsByRole[role] = key.system + rolesBySystem[key.system] = role + } +} + +/** Traverses the populated FHIR object tree without assuming any resource-specific Identifier path. */ +private fun Base.visitPopulatedElements(visitor: (Base) -> Unit) { + val visited = Collections.newSetFromMap(IdentityHashMap()) + fun visit(element: Base) { + if (!visited.add(element)) return + visitor(element) + element.children().forEach { property -> property.values.forEach(::visit) } + } + visit(this) +} + +internal fun Resource.typedGroveIdentifiers(label: String): Map { + val result = linkedMapOf() + directIdentifiers().forEachIndexed { index, identifier -> + val role = identifier.groveRole("$label.identifier[$index]") ?: return@forEachIndexed + identifier.key() + require(result.put(role, identifier) == null) { "$label repeats the ${role.code} Identifier role." } + } + return result +} + +private fun Identifier.groveRole(label: String): GroveIdentifierRole? { + val codings = type.coding.filter { it.system == HealthConnectContract.GROVE_IDENTIFIER_ROLE } + if (codings.isEmpty()) return null + require(codings.size == 1) { "$label repeats the Grove role Coding." } + return GroveIdentifierRole.entries.singleOrNull { it.code == codings.single().code } + ?: throw IllegalArgumentException("$label uses an unknown Grove role.") +} + +private fun Resource.directIdentifiers(): List = when (this) { + is Observation -> identifier + is DocumentReference -> identifier + is Specimen -> identifier + is VisionPrescription -> identifier + is MedicationAdministration -> identifier + is MedicationStatement -> identifier + is Device -> identifier + is Patient -> identifier + is ResearchStudy -> identifier + is ResearchSubject -> identifier + is PlanDefinition -> identifier + is QuestionnaireResponse -> listOf(identifier) + else -> emptyList() +} + +internal fun Identifier.matchesIdentifierPair(other: Identifier): Boolean = + system == other.system && value == other.value diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/GroveExchangeProtocol.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/GroveExchangeProtocol.kt new file mode 100644 index 00000000..08cbeebe --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/GroveExchangeProtocol.kt @@ -0,0 +1,268 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.Device +import org.hl7.fhir.r4.model.DocumentReference +import org.hl7.fhir.r4.model.DomainResource +import org.hl7.fhir.r4.model.Identifier +import org.hl7.fhir.r4.model.MedicationAdministration +import org.hl7.fhir.r4.model.MedicationStatement +import org.hl7.fhir.r4.model.Observation +import org.hl7.fhir.r4.model.Provenance +import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.Resource +import org.hl7.fhir.r4.model.Specimen +import org.hl7.fhir.r4.model.VisionPrescription +import java.io.ByteArrayOutputStream +import java.io.DataOutputStream + +/** Shared byte-level primitives from the Grove 0.6.0 exchange protocol. */ +internal object GroveExchangeProtocol { + /** Unsigned 32-bit big-endian UTF-8 byte length followed by the exact bytes, per field. */ + fun frameFields(fields: Iterable): ByteArray = ByteArrayOutputStream().use { bytes -> + DataOutputStream(bytes).use { output -> + fields.forEach { field -> + GroveUnicode.requireScalarText(field, "Exchange-protocol field") + val encoded = field.toByteArray(Charsets.UTF_8) + output.writeInt(encoded.size) + output.write(encoded) + } + } + bytes.toByteArray() + } +} + +/** + * Enforces the catalog-closed Mobile active graph at the immutable export boundary. + * + * Official StructureDefinition validation still runs in CI. These producer checks cover the + * cross-resource and catalog constraints that a single-resource FHIR validator cannot prove. + */ +internal fun Bundle.requireGroveActiveExchangeContract(sourceRecordIdentifier: Identifier) { + requireGroveEntryIdentitySelection() + val outputTypes = HealthConnectContract.activeOutputResourceTypes + val supportingTypes = HealthConnectContract.activeSupportingResourceTypes + val admittedTypes = outputTypes + supportingTypes + HealthConnectContract.ACTIVE_LIFECYCLE_RESOURCE_TYPE + val resourcesByFullUrl = entry.associate { it.fullUrl to it.resource } + + require(resourcesByFullUrl.size == entry.size) { "An active graph requires unique entry fullUrl values." } + entry.forEachIndexed { index, bundleEntry -> + val resource = bundleEntry.resource + require(resource.fhirType() in admittedTypes) { + "Bundle.entry[$index] ${resource.fhirType()} is not admitted by the closed active resource-type set." + } + require((resource as? DomainResource)?.contained.isNullOrEmpty()) { + "Bundle.entry[$index] contains a Resource; Mobile active graphs prohibit contained resources." + } + resource.requireExactActiveProfileClaim(index) + } + + val outputs = entry.filter { it.resource.fhirType() in outputTypes } + require(outputs.isNotEmpty()) { "An exported active event must contain at least one source-derived output." } + outputs.forEachIndexed { index, output -> + val identifiers = output.resource.typedGroveIdentifiers("Active output[$index]") + require( + identifiers.keys.containsAll( + setOf(GroveIdentifierRole.SOURCE_RECORD, GroveIdentifierRole.SOURCE_OUTPUT), + ), + ) { "Every active output requires one typed source-record and source-output Identifier." } + require(identifiers.getValue(GroveIdentifierRole.SOURCE_RECORD).matchesIdentifierPair(sourceRecordIdentifier)) { + "Every active output must identify the export batch's exact source record." + } + } + + val provenance = entry.map { it.resource }.filterIsInstance().singleOrNull() + ?: throw IllegalArgumentException("An active event requires exactly one lifecycle Provenance.") + provenance.requireTransformLifecycle() + provenance.requireExactSourceEntity(sourceRecordIdentifier) + provenance.requireTargetsExactly(outputs.map(Bundle.BundleEntryComponent::getFullUrl).toSet()) + if (provenance.meta.profile.single().value == HealthConnectContract.HEALTH_CONNECT_PROVENANCE_PROFILE) { + provenance.requireHealthConnectDataOriginApplication() + } + + requireConnectedSupportingResources(resourcesByFullUrl, outputs.map { it.fullUrl }.toSet()) +} + +private fun Resource.requireExactActiveProfileClaim(index: Int) { + val profiles = meta.profile.map { it.value } + require(profiles.none { it.isNullOrBlank() } && profiles.size == profiles.toSet().size) { + "Bundle.entry[$index] ${fhirType()}.meta.profile must contain distinct nonblank canonicals." + } + when (this) { + is Observation -> requireExactObservationClaim(profiles) + is DocumentReference -> { + val requiredRoles = HealthConnectContract.activeDocumentProfileClaims[profiles.toSet()] + require(requiredRoles != null) { + "Active DocumentReference must directly claim one exact admitted document profile mode." + } + requireExactTypedRoles(requiredRoles, allowWriter = true, label = "Active DocumentReference") + } + is Device -> { + val profile = profiles.singleOrNull() + val requiredRoles = HealthConnectContract.activeDeviceProfileClaims[profile] + require(requiredRoles != null) { + "Active Device must directly claim exactly one admitted Device profile mode." + } + requireExactTypedRoles(requiredRoles, allowWriter = false, label = "Active Device") + } + is QuestionnaireResponse -> require( + profiles == listOf(HealthConnectContract.ACTIVE_QUESTIONNAIRE_RESPONSE_PROFILE), + ) { "Active QuestionnaireResponse must directly claim exactly its Grove profile." } + is Provenance -> require( + profiles.size == 1 && profiles.single() in HealthConnectContract.activeConversionProvenanceProfiles, + ) { "Active Provenance must directly claim one admitted Mobile or Health Connect profile." } + is Specimen, is VisionPrescription, is MedicationAdministration, is MedicationStatement -> { + val expected = HealthConnectContract.adapterOnlyOutputProfiles.getValue(fhirType()) + require(profiles == listOf(expected)) { + "Active ${fhirType()} must directly claim exactly its adapter-only profile $expected." + } + requireExactTypedRoles( + setOf(GroveIdentifierRole.SOURCE_RECORD, GroveIdentifierRole.SOURCE_OUTPUT), + allowWriter = true, + label = "Active ${fhirType()}", + ) + } + } +} + +private fun Observation.requireExactObservationClaim(profiles: List) { + val semanticProfiles = + HealthConnectContract.sharedMeasurementProfiles + HealthConnectContract.adapterSpecificObservationProfiles + val adapterNeutral = profiles.size == 1 && profiles.single() in semanticProfiles + val healthConnectAdapter = profiles.size == 2 && + profiles.count { it in HealthConnectContract.sharedMeasurementProfiles } == 1 && + profiles.count { it == HealthConnectContract.HEALTH_CONNECT_OBSERVATION_PROFILE } == 1 + val healthConnectExclusive = profiles.size == 1 && + profiles.single() in HealthConnectContract.activeHealthConnectExclusiveObservationProfiles + require(adapterNeutral || healthConnectAdapter || healthConnectExclusive) { + "Active Observation must carry one exact admitted semantic/profile-claim mode." + } + requireHealthConnectSourceMarkerClaim(profiles) + profiles.firstNotNullOfOrNull(HealthConnectContract.quantitySemanticsByProfile::get)?.let { expected -> + if (hasValueQuantity()) { + require(valueQuantity.system == expected.system && valueQuantity.code == expected.code) { + "Active Observation Quantity must preserve its catalog-fixed system/code pair." + } + } + } +} + +private fun Observation.requireHealthConnectSourceMarkerClaim(profiles: List) { + val markers = extension.filter { + it.url == HealthConnectContract.HEALTH_CONNECT_RECORD_TYPE_EXTENSION + } + val claimsHealthConnect = + HealthConnectContract.HEALTH_CONNECT_OBSERVATION_PROFILE in profiles || + profiles.any(HealthConnectContract.activeHealthConnectExclusiveObservationProfiles::contains) + val hasOneCompleteMarker = markers.singleOrNull()?.value?.primitiveValue()?.isNotBlank() == true + require( + if (claimsHealthConnect) hasOneCompleteMarker else markers.isEmpty(), + ) { + "The Health Connect record-type marker must appear exactly once on a catalog-owned " + + "Health Connect Observation and never on an adapter-neutral Observation." + } +} + +private fun Resource.requireExactTypedRoles( + required: Set, + allowWriter: Boolean, + label: String, +) { + val actual = typedGroveIdentifiers(label).keys + val allowed = required + setOfNotNull(GroveIdentifierRole.WRITER_RECORD.takeIf { allowWriter }) + require(actual.containsAll(required) && actual.all { it in allowed }) { + "$label has invalid typed Grove Identifier roles." + } +} + +private fun Provenance.requireTransformLifecycle() { + val iso = activity.coding.filter { it.system == HealthConnectContract.RECORD_LIFECYCLE } + val grove = activity.coding.filter { it.system == HealthConnectContract.GROVE_LIFECYCLE_EVENT } + require(iso.size == 1 && iso.single().code == "transform" && grove.isEmpty()) { + "The sole active Provenance requires exactly one ISO transform coding and no retraction coding." + } +} + +private fun Provenance.requireExactSourceEntity(sourceRecordIdentifier: Identifier) { + val source = entity.singleOrNull()?.takeIf { it.role == Provenance.ProvenanceEntityRole.SOURCE }?.what + ?: throw IllegalArgumentException("Active Provenance requires exactly one source entity.") + require(!source.hasReference() && source.hasIdentifier()) { + "Active Provenance source must be an identifier-only logical Reference." + } + require( + source.identifier.hasGroveRole(GroveIdentifierRole.SOURCE_RECORD) && + source.identifier.matchesIdentifierPair(sourceRecordIdentifier), + ) { "Active Provenance source must identify the event's exact source record." } +} + +private fun Provenance.requireTargetsExactly(outputFullUrls: Set) { + val targetUrls = target.map { reference -> + require(reference.hasReference() && !reference.reference.startsWith('#')) { + "Active Provenance targets must be literal references to Bundle output entries." + } + reference.reference + } + require(targetUrls.size == targetUrls.toSet().size && targetUrls.toSet() == outputFullUrls) { + "Active Provenance must target every and only source-derived output exactly once." + } +} + +private fun Provenance.requireHealthConnectDataOriginApplication() { + val agent = entity.single().agent.singleOrNull() + ?: throw IllegalArgumentException("Health Connect Provenance requires one DataOrigin enterer agent.") + val participantCodings = agent.type.coding.filter { + it.system == HealthConnectContract.PROVENANCE_PARTICIPANT + } + require(participantCodings.size == 1 && participantCodings.single().code == "enterer") { + "Health Connect DataOrigin requires exactly one enterer participation Coding." + } + val who = agent.who + require( + !who.hasReference() && who.type == "Device" && who.hasIdentifier() && + who.identifier.system == HealthConnectContract.ANDROID_PACKAGE_IDENTIFIER && + who.identifier.value.isNotBlank(), + ) { + "Health Connect DataOrigin must be an identifier-only typed logical Device Reference " + + "using the Android package NamingSystem." + } + who.identifier.key() + GroveUnicode.requireScalarText(who.identifier.value, "Metadata.dataOrigin.packageName") +} + +private fun Bundle.requireConnectedSupportingResources( + resourcesByFullUrl: Map, + outputFullUrls: Set, +) { + val adjacency = resourcesByFullUrl.keys.associateWith { linkedSetOf() } + resourcesByFullUrl.forEach { (sourceUrl, resource) -> + resource.groveReferenceNodes().forEach { reference -> + val targetUrl = reference.reference.takeIf(resourcesByFullUrl::containsKey) ?: return@forEach + adjacency.getValue(sourceUrl).add(targetUrl) + adjacency.getValue(targetUrl).add(sourceUrl) + } + } + val lifecycleUrls = entry.filter { + it.resource.fhirType() == HealthConnectContract.ACTIVE_LIFECYCLE_RESOURCE_TYPE + }.mapTo(linkedSetOf(), Bundle.BundleEntryComponent::getFullUrl) + val reachable = (outputFullUrls + lifecycleUrls).toMutableSet() + val pending = ArrayDeque(reachable) + while (pending.isNotEmpty()) { + adjacency.getValue(pending.removeFirst()).forEach { connected -> + if (reachable.add(connected)) pending.addLast(connected) + } + } + val disconnected = entry.filter { + it.resource.fhirType() in HealthConnectContract.activeSupportingResourceTypes && + it.fullUrl !in reachable + } + require(disconnected.isEmpty()) { + "Every active supporting resource must connect to an output or lifecycle Provenance." + } +} diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/GroveHmacIdentityKey.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/GroveHmacIdentityKey.kt new file mode 100644 index 00000000..53652769 --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/GroveHmacIdentityKey.kt @@ -0,0 +1,209 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import org.hl7.fhir.r4.model.CodeableConcept +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.Identifier +import java.net.URI +import java.util.Base64 +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +/** + * One deployment-owned Grove identity key epoch. + * + * The caller persists the secret and its metadata outside FHIR. Rotating either the key or its + * epoch intentionally creates a new Identifier.system and a new identity space; migration is an + * explicit retraction/republication operation, never an invisible in-place rewrite. + */ +class GroveHmacIdentityKey private constructor( + val identifierSystemFamily: String, + val keyId: String, + val epoch: String, + secret: ByteArray, + allowPublicConformanceKey: Boolean, +) { + private val secretSnapshot = secret.copyOf() + + constructor( + identifierSystemFamily: String, + keyId: String, + epoch: String, + secret: ByteArray, + ) : this(identifierSystemFamily, keyId, epoch, secret, false) + + init { + GroveUnicode.requireScalarText(identifierSystemFamily, "HMAC identity-system family") + GroveUnicode.requireScalarText(keyId, "HMAC key id") + require( + identifierSystemFamily.isAbsoluteAsciiUri() && + !identifierSystemFamily.endsWith('/') && + runCatching { + val uri = URI(identifierSystemFamily) + uri.rawQuery == null && uri.rawFragment == null + }.getOrDefault(false) + ) { + "The HMAC identity-system family must be a deployment-owned absolute ASCII " + + "RFC 3986 URI without a trailing slash, query, or fragment." + } + require(KEY_ID.matches(keyId)) { + "The HMAC key id must use only URI-safe unreserved characters." + } + require(POSITIVE_DECIMAL.matches(epoch)) { + "The HMAC key epoch must be a positive canonical decimal integer." + } + require(secretSnapshot.size >= MINIMUM_KEY_BYTES) { + "A Grove HMAC-SHA-256 identity key must contain at least 256 bits of secret material." + } + require(allowPublicConformanceKey || !secretSnapshot.contentEquals(PUBLIC_CONFORMANCE_KEY)) { + "The published Grove conformance key is prohibited in production identity configuration." + } + } + + /** Builds an opaque, role-typed FHIR Identifier from unambiguous length-framed UTF-8. */ + internal fun identifier( + identityKind: GroveOpaqueIdentityKind, + vararg components: String, + ): Identifier = identifier(identityKind, components.asList()) + + /** List overload avoids transient arrays when a converter extends an existing component tuple. */ + internal fun identifier( + identityKind: GroveOpaqueIdentityKind, + components: List, + ): Identifier = Identifier().apply { + system = identifierSystem(identityKind) + value = value(identityKind, components) + type = CodeableConcept( + Coding( + HealthConnectContract.GROVE_IDENTIFIER_ROLE, + identityKind.identifierRole.code, + identityKind.identifierRole.display, + ), + ) + } + + internal fun value(identityKind: GroveOpaqueIdentityKind, vararg components: String): String = + value(identityKind, components.asList()) + + internal fun value(identityKind: GroveOpaqueIdentityKind, components: List): String { + require(components.size == identityKind.componentCount) { + "${identityKind.code} requires exactly ${identityKind.componentCount} ordered components." + } + components.forEachIndexed { index, component -> + GroveUnicode.requireScalarText(component, "${identityKind.code} component[$index]") + require(component.isNotEmpty()) { + "${identityKind.code} component[$index] must not be empty." + } + } + val firstComponent = components.first() + when (identityKind) { + GroveOpaqueIdentityKind.PROVIDER_RECORD, + GroveOpaqueIdentityKind.PROVIDER_OUTPUT, + GroveOpaqueIdentityKind.PROVIDER_ARTIFACT, + -> require(firstComponent in HealthConnectContract.providerCodes) { + "${identityKind.code} component[0] must be an exact catalog provider code." + } + + GroveOpaqueIdentityKind.SOURCE_RECORD, + GroveOpaqueIdentityKind.SOURCE_OUTPUT, + GroveOpaqueIdentityKind.SOURCE_ARTIFACT, + -> require(firstComponent !in HealthConnectContract.providerCodes) { + "Provider coordinates require the matching provider-record, provider-output, " + + "or provider-artifact identity kind." + } + + else -> Unit + } + val preimage = GroveExchangeProtocol.frameFields( + buildList { + add(DOMAIN) + add(identityKind.code) + addAll(components) + }, + ) + val digest = Mac.getInstance(HMAC_SHA_256).run { + init(SecretKeySpec(secretSnapshot, HMAC_SHA_256)) + doFinal(preimage) + } + val encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(digest) + return "v2:$keyId:$epoch:$encoded" + } + + /** A system is immutable for one identity kind, key id, and key epoch. */ + internal fun identifierSystem(identityKind: GroveOpaqueIdentityKind): String = + "$identifierSystemFamily/${identityKind.code}/$keyId/$epoch" + + override fun toString(): String = + "GroveHmacIdentityKey(identifierSystemFamily=$identifierSystemFamily, keyId=$keyId, epoch=$epoch)" + + companion object { + /** The public protocol vector key is reachable only from same-module conformance tests. */ + internal fun forConformanceTesting( + identifierSystemFamily: String, + keyId: String, + epoch: String, + secret: ByteArray, + ): GroveHmacIdentityKey = GroveHmacIdentityKey( + identifierSystemFamily, + keyId, + epoch, + secret, + true, + ) + + private const val DOMAIN = "org.grovealliance.fhir.identity.v2" + private const val HMAC_SHA_256 = "HmacSHA256" + private const val MINIMUM_KEY_BYTES = 32 + private val KEY_ID = Regex("[A-Za-z0-9._-]+") + private val POSITIVE_DECIMAL = Regex("[1-9][0-9]*") + private val PUBLIC_CONFORMANCE_KEY = ByteArray(MINIMUM_KEY_BYTES) { it.toByte() } + } +} + +private const val SOURCE_RECORD_COMPONENT_COUNT = 5 +private const val SOURCE_OUTPUT_COMPONENT_COUNT = 7 +private const val WRITER_RECORD_COMPONENT_COUNT = 3 +private const val PROVIDER_RECORD_COMPONENT_COUNT = 5 +private const val PROVIDER_OUTPUT_COMPONENT_COUNT = 7 +private const val SOURCE_ARTIFACT_COMPONENT_COUNT = 7 +private const val PROVIDER_ARTIFACT_COMPONENT_COUNT = 7 +private const val SOURCE_CONTEXT_COMPONENT_COUNT = 5 +private const val RECORDING_DEVICE_COMPONENT_COUNT = 4 +private const val DEVICE_SNAPSHOT_COMPONENT_COUNT = 4 + +/** Closed HMAC identity shapes from the Grove 0.6.0 exchange-protocol catalog. */ +internal enum class GroveOpaqueIdentityKind( + val code: String, + val identifierRole: GroveIdentifierRole, + val componentCount: Int, +) { + SOURCE_RECORD("source-record", GroveIdentifierRole.SOURCE_RECORD, SOURCE_RECORD_COMPONENT_COUNT), + SOURCE_OUTPUT("source-output", GroveIdentifierRole.SOURCE_OUTPUT, SOURCE_OUTPUT_COMPONENT_COUNT), + WRITER_RECORD("writer-record", GroveIdentifierRole.WRITER_RECORD, WRITER_RECORD_COMPONENT_COUNT), + PROVIDER_RECORD("provider-record", GroveIdentifierRole.SOURCE_RECORD, PROVIDER_RECORD_COMPONENT_COUNT), + PROVIDER_OUTPUT("provider-output", GroveIdentifierRole.SOURCE_OUTPUT, PROVIDER_OUTPUT_COMPONENT_COUNT), + SOURCE_ARTIFACT("source-artifact", GroveIdentifierRole.SOURCE_ARTIFACT, SOURCE_ARTIFACT_COMPONENT_COUNT), + PROVIDER_ARTIFACT("provider-artifact", GroveIdentifierRole.SOURCE_ARTIFACT, PROVIDER_ARTIFACT_COMPONENT_COUNT), + SOURCE_CONTEXT("source-context", GroveIdentifierRole.SOURCE_CONTEXT, SOURCE_CONTEXT_COMPONENT_COUNT), + RECORDING_DEVICE("recording-device", GroveIdentifierRole.RECORDING_DEVICE, RECORDING_DEVICE_COMPONENT_COUNT), + DEVICE_SNAPSHOT("device-snapshot", GroveIdentifierRole.DEVICE_SNAPSHOT, DEVICE_SNAPSHOT_COMPONENT_COUNT), +} + +/** Closed role vocabulary carried in Identifier.type as well as the HMAC domain. */ +enum class GroveIdentifierRole(val code: String, val display: String) { + SOURCE_RECORD("source-record", "Source record"), + SOURCE_OUTPUT("source-output", "Source output"), + WRITER_RECORD("writer-record", "Writer record"), + SOURCE_ARTIFACT("source-artifact", "Source artifact"), + SOURCE_CONTEXT("source-context", "Source context"), + RECORDING_DEVICE("recording-device", "Recording device"), + DEVICE_SNAPSHOT("device-snapshot", "Device snapshot"), + EVENT("event", "Event"), + ENTRY_NODE("entry-node", "Entry node"), +} diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/GroveReferencePolicy.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/GroveReferencePolicy.kt new file mode 100644 index 00000000..1d5cf535 --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/GroveReferencePolicy.kt @@ -0,0 +1,194 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import org.hl7.fhir.r4.model.Base +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.Device +import org.hl7.fhir.r4.model.DocumentReference +import org.hl7.fhir.r4.model.DomainResource +import org.hl7.fhir.r4.model.Extension +import org.hl7.fhir.r4.model.MedicationAdministration +import org.hl7.fhir.r4.model.MedicationStatement +import org.hl7.fhir.r4.model.Observation +import org.hl7.fhir.r4.model.Reference +import org.hl7.fhir.r4.model.ResearchStudy +import org.hl7.fhir.r4.model.ResearchSubject +import org.hl7.fhir.r4.model.Resource +import org.hl7.fhir.r4.model.Specimen +import org.hl7.fhir.r4.model.VisionPrescription +import java.util.Collections +import java.util.IdentityHashMap + +/** Enforces the exchange protocol's literal closure and governed Reference-shape table. */ +internal fun Bundle.requireGroveReferencePolicy() { + val resourcesByFullUrl = entry.associate { bundleEntry -> + bundleEntry.fullUrl to bundleEntry.resource + } + require(resourcesByFullUrl.size == entry.size) { + "Reference validation requires unique Bundle entry fullUrl values." + } + + entry.forEachIndexed { entryIndex, bundleEntry -> + val root = bundleEntry.resource + require((root as? DomainResource)?.contained.isNullOrEmpty()) { + "Bundle.entry[$entryIndex] must not contain hidden resources; every graph node is a Bundle entry." + } + root.groveReferenceNodes().filter(Reference::hasReference).forEach { reference -> + reference.requireResolvedLiteral( + resourcesByFullUrl, + "Bundle.entry[$entryIndex] literal Reference", + ) + } + root.governedReferences().forEach { governed -> + governed.reference.requireGovernedShape( + governed.targetTypes, + resourcesByFullUrl, + "Bundle.entry[$entryIndex].${governed.path}", + ) + } + } +} + +internal fun Resource.groveReferenceNodes(): List { + val references = mutableListOf() + val visited = Collections.newSetFromMap(IdentityHashMap()) + fun visit(element: Base) { + if (!visited.add(element)) return + if (element is Reference) references += element + element.children().flatMap { it.values }.forEach(::visit) + } + visit(this) + return references +} + +private fun Reference.requireResolvedLiteral( + resourcesByFullUrl: Map, + label: String, +): Resource { + val literal = reference + val target = resourcesByFullUrl[literal] + require(target != null) { "$label must resolve inside its exchange graph." } + require(!hasType() || type == target.fhirType()) { + "$label.type must equal the referenced ${target.fhirType()} resource type." + } + return target +} + +private fun Reference.requireGovernedShape( + targetTypes: Set, + resourcesByFullUrl: Map, + label: String, +) { + if (hasReference()) { + require(!hasIdentifier()) { + "$label must not mix a resolving literal with a logical identifier." + } + val target = requireResolvedLiteral(resourcesByFullUrl, label) + require(target.fhirType() in targetTypes) { + "$label must reference ${targetTypes.sorted().joinToString(" or ")}." + } + return + } + + require(hasType() && type in targetTypes && hasIdentifier()) { + "$label identifier-only logical Reference requires an exact admitted type and one complete Identifier." + } + identifier.key() +} + +private fun Resource.governedReferences(): List = + governedElementReferences() + buildList { + allExtensions().forEach { extension -> + val targets = EXTENSION_TARGETS[extension.url] ?: return@forEach + val reference = extension.value as? Reference + require(reference != null) { "${extension.url} must carry a valueReference." } + add(GovernedReference("extension('${extension.url}')", reference, targets)) + } + } + +private fun Resource.governedElementReferences(): List = when (this) { + is Observation -> governedReferences() + is DocumentReference -> optionalGovernedReference( + hasSubject(), + "DocumentReference.subject", + subject, + PATIENT_TARGET, + ) + is Specimen -> governedReferences() + is MedicationAdministration -> + optionalGovernedReference(hasSubject(), "MedicationAdministration.subject", subject, PATIENT_TARGET) + is MedicationStatement -> + optionalGovernedReference(hasSubject(), "MedicationStatement.subject", subject, PATIENT_TARGET) + is VisionPrescription -> + optionalGovernedReference(hasPatient(), "VisionPrescription.patient", patient, PATIENT_TARGET) + is ResearchSubject -> buildList { + if (hasIndividual()) add(GovernedReference("ResearchSubject.individual", individual, PATIENT_TARGET)) + if (hasStudy()) add(GovernedReference("ResearchSubject.study", study, RESEARCH_STUDY_TARGET)) + } + is ResearchStudy -> protocol.map { + GovernedReference("ResearchStudy.protocol", it, PLAN_DEFINITION_TARGET) + } + is Device -> optionalGovernedReference(hasParent(), "Device.parent", parent, DEVICE_TARGET) + else -> emptyList() +} + +private fun Observation.governedReferences(): List = buildList { + require(hasSubject()) { "A Grove Observation requires one Patient subject." } + add(GovernedReference("Observation.subject", subject, PATIENT_TARGET)) + if (hasDevice()) add(GovernedReference("Observation.device", device, DEVICE_TARGET)) + if (hasSpecimen()) add(GovernedReference("Observation.specimen", specimen, SPECIMEN_TARGET)) + hasMember.forEach { add(GovernedReference("Observation.hasMember", it, OBSERVATION_TARGET)) } + derivedFrom.forEach { add(GovernedReference("Observation.derivedFrom", it, DERIVED_FROM_TARGETS)) } +} + +private fun Specimen.governedReferences(): List { + require(hasSubject()) { "A Grove Health Connect Specimen requires one Patient subject." } + return listOf(GovernedReference("Specimen.subject", subject, PATIENT_TARGET)) +} + +private fun optionalGovernedReference( + present: Boolean, + path: String, + reference: Reference, + targetTypes: Set, +): List = if (present) { + listOf(GovernedReference(path, reference, targetTypes)) +} else { + emptyList() +} + +private fun Resource.allExtensions(): List { + val extensions = mutableListOf() + val visited = Collections.newSetFromMap(IdentityHashMap()) + fun visit(element: Base) { + if (!visited.add(element)) return + if (element is Extension) extensions += element + element.children().flatMap { it.values }.forEach(::visit) + } + visit(this) + return extensions +} + +private data class GovernedReference( + val path: String, + val reference: Reference, + val targetTypes: Set, +) + +private val PATIENT_TARGET = setOf("Patient") +private val DEVICE_TARGET = setOf("Device") +private val SPECIMEN_TARGET = setOf("Specimen") +private val OBSERVATION_TARGET = setOf("Observation") +private val RESEARCH_STUDY_TARGET = setOf("ResearchStudy") +private val PLAN_DEFINITION_TARGET = setOf("PlanDefinition") +private val DERIVED_FROM_TARGETS = setOf("DocumentReference", "QuestionnaireResponse") +private val EXTENSION_TARGETS = mapOf( + "http://hl7.org/fhir/StructureDefinition/observation-gatewayDevice" to DEVICE_TARGET, + "http://hl7.org/fhir/StructureDefinition/workflow-researchStudy" to RESEARCH_STUDY_TARGET, +) diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/GroveUnicode.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/GroveUnicode.kt new file mode 100644 index 00000000..3a79ab7f --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/GroveUnicode.kt @@ -0,0 +1,41 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +/** UTF-16 validation shared by every byte-sensitive Grove identity algorithm. */ +internal object GroveUnicode { + /** + * Returns [value] when it is a sequence of Unicode scalar values. + * + * Kotlin strings are UTF-16. A supplementary scalar therefore occupies a *paired* high and + * low surrogate and is valid input. Only an unpaired code unit is rejected. + */ + fun requireScalarText(value: String, field: String): String { + var index = 0 + while (index < value.length) { + val current = value[index] + when { + Character.isHighSurrogate(current) -> { + require(index + 1 < value.length && Character.isLowSurrogate(value[index + 1])) { + "$field contains an unpaired high surrogate at UTF-16 index $index." + } + index += 2 + } + + Character.isLowSurrogate(current) -> { + throw IllegalArgumentException( + "$field contains an unpaired low surrogate at UTF-16 index $index.", + ) + } + + else -> index += 1 + } + } + return value + } +} diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectActiveExportBuilder.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectActiveExportBuilder.kt new file mode 100644 index 00000000..94e0711d --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectActiveExportBuilder.kt @@ -0,0 +1,51 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import androidx.health.connect.client.records.Record + +/** Builds immutable active outbox state without owning journal or sink side effects. */ +internal class HealthConnectActiveExportBuilder( + private val synchronizationScope: HealthConnectSynchronizationScope, +) { + fun draft( + record: Record, + conversion: HealthConnectConversion, + prior: HealthConnectExportJournalEntry?, + ): HealthConnectPendingExportDraft = HealthConnectPendingExportDraft( + repositoryScopeKey = synchronizationScope.repositoryScopeKey, + projectionScopeKey = synchronizationScope.projectionScopeKey, + operation = HealthConnectExportOperation.ACTIVE, + recordType = conversion.sourceRecordType, + healthConnectId = record.metadata.id, + sourceRecordIdentifier = conversion.sourceRecordIdentifier.copy(), + sourceVersion = conversion.sourceLastModified, + bundle = conversion.bundle, + retractedTargets = emptySet(), + nextEntry = entry(record, conversion, prior), + ) + + fun entry( + record: Record, + conversion: HealthConnectConversion, + prior: HealthConnectExportJournalEntry?, + ): HealthConnectExportJournalEntry = HealthConnectExportJournalEntry( + repositoryScopeKey = synchronizationScope.repositoryScopeKey, + projectionScopeKey = synchronizationScope.projectionScopeKey, + recordType = conversion.sourceRecordType, + healthConnectId = record.metadata.id, + dataOriginPackage = record.metadata.dataOrigin.packageName, + sourceLastModified = conversion.sourceLastModified, + conversionContractVersion = conversion.conversionContractVersion, + sourceRecordIdentifier = conversion.sourceRecordIdentifier.copy(), + observations = conversion.observations.map { it.copy() }, + bundle = conversion.bundle.copy(), + destinationReferences = emptyMap(), + lastEventSequence = prior?.lastEventSequence, + ) +} diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectBodyQuantityConversions.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectBodyQuantityConversions.kt new file mode 100644 index 00000000..68f71a11 --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectBodyQuantityConversions.kt @@ -0,0 +1,393 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord +import androidx.health.connect.client.records.BasalMetabolicRateRecord +import androidx.health.connect.client.records.BodyFatRecord +import androidx.health.connect.client.records.BodyWaterMassRecord +import androidx.health.connect.client.records.BoneMassRecord +import androidx.health.connect.client.records.ElevationGainedRecord +import androidx.health.connect.client.records.FloorsClimbedRecord +import androidx.health.connect.client.records.HeartRateVariabilityRmssdRecord +import androidx.health.connect.client.records.HydrationRecord +import androidx.health.connect.client.records.LeanBodyMassRecord +import androidx.health.connect.client.records.RestingHeartRateRecord +import androidx.health.connect.client.records.TotalCaloriesBurnedRecord +import androidx.health.connect.client.records.Vo2MaxRecord +import androidx.health.connect.client.records.WheelchairPushesRecord +import org.hl7.fhir.r4.model.CodeableConcept +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.DateTimeType +import java.time.Instant + +internal fun HealthConnectConverter.convertActiveEnergy( + record: ActiveCaloriesBurnedRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion = convertIntervalQuantity( + metadata = record.metadata, + recordType = HealthConnectConverter.ACTIVE_CALORIES_BURNED_RECORD, + start = record.startTime, + startOffset = record.startZoneOffset, + end = record.endTime, + endOffset = record.endZoneOffset, + value = record.energy.inKilocalories, + spec = MobileQuantitySpec( + profile = HealthConnectContract.MOBILE_ACTIVE_ENERGY_PROFILE, + category = HealthConnectConverter.ACTIVITY_CATEGORY, + codeSystem = HealthConnectContract.GROVE_MOBILE_MEASUREMENT, + code = "active-energy-burned", + display = "Active energy burned", + unitCode = "kcal", + unitDisplay = "kcal", + ), + convertedAt = convertedAt, + eventSequence = eventSequence, +) + +internal fun HealthConnectConverter.convertBasalMetabolicRate( + record: BasalMetabolicRateRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion = convertInstantQuantity( + metadata = record.metadata, + recordType = HealthConnectConverter.BASAL_METABOLIC_RATE_RECORD, + time = record.time, + offset = record.zoneOffset, + value = record.basalMetabolicRate.inKilocaloriesPerDay, + spec = MobileQuantitySpec( + profile = HealthConnectContract.HEALTH_CONNECT_BASAL_METABOLIC_RATE_PROFILE, + category = HealthConnectConverter.ACTIVITY_CATEGORY, + codeSystem = HealthConnectContract.HEALTH_CONNECT_MEASUREMENT, + code = "basal-metabolic-rate", + display = "Basal metabolic rate", + unitCode = "kcal/d", + unitDisplay = "kcal/day", + adapterSpecific = true, + ), + convertedAt = convertedAt, + eventSequence = eventSequence, +) + +internal fun HealthConnectConverter.convertBodyFat( + record: BodyFatRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion = convertInstantQuantity( + metadata = record.metadata, + recordType = HealthConnectConverter.BODY_FAT_RECORD, + time = record.time, + offset = record.zoneOffset, + value = record.percentage.value, + spec = MobileQuantitySpec( + profile = HealthConnectContract.MOBILE_BODY_FAT_PERCENTAGE_PROFILE, + category = HealthConnectConverter.VITAL_SIGNS_CATEGORY, + codeSystem = HealthConnectContract.LOINC, + code = "41982-0", + display = "Percentage of body fat Measured", + unitCode = "%", + unitDisplay = "%", + valueDomain = HealthConnectContract.quantityValueDomains.getValue("body-fat-percentage"), + ), + convertedAt = convertedAt, + eventSequence = eventSequence, +) + +internal fun HealthConnectConverter.convertBodyWaterMass( + record: BodyWaterMassRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion = convertInstantQuantity( + metadata = record.metadata, + recordType = HealthConnectConverter.BODY_WATER_MASS_RECORD, + time = record.time, + offset = record.zoneOffset, + value = record.mass.inKilograms, + spec = MobileQuantitySpec( + profile = HealthConnectContract.MOBILE_BODY_WATER_MASS_PROFILE, + category = HealthConnectConverter.VITAL_SIGNS_CATEGORY, + codeSystem = HealthConnectContract.LOINC, + code = "101683-1", + display = "Body water mass", + unitCode = "kg", + unitDisplay = "kg", + ), + convertedAt = convertedAt, + eventSequence = eventSequence, +) + +internal fun HealthConnectConverter.convertBoneMass( + record: BoneMassRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion = convertInstantQuantity( + metadata = record.metadata, + recordType = HealthConnectConverter.BONE_MASS_RECORD, + time = record.time, + offset = record.zoneOffset, + value = record.mass.inKilograms, + spec = MobileQuantitySpec( + profile = HealthConnectContract.MOBILE_BONE_MASS_PROFILE, + category = HealthConnectConverter.VITAL_SIGNS_CATEGORY, + codeSystem = HealthConnectContract.LOINC, + code = "101685-6", + display = "Body bone mass", + unitCode = "kg", + unitDisplay = "kg", + ), + convertedAt = convertedAt, + eventSequence = eventSequence, +) + +internal fun HealthConnectConverter.convertLeanBodyMass( + record: LeanBodyMassRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion = convertInstantQuantity( + metadata = record.metadata, + recordType = HealthConnectConverter.LEAN_BODY_MASS_RECORD, + time = record.time, + offset = record.zoneOffset, + value = record.mass.inKilograms, + spec = MobileQuantitySpec( + profile = HealthConnectContract.MOBILE_LEAN_BODY_MASS_PROFILE, + category = HealthConnectConverter.VITAL_SIGNS_CATEGORY, + codeSystem = HealthConnectContract.LOINC, + code = "91557-9", + display = "Lean body weight", + unitCode = "kg", + unitDisplay = "kg", + ), + convertedAt = convertedAt, + eventSequence = eventSequence, +) + +internal fun HealthConnectConverter.convertHeartRateVariabilityRmssd( + record: HeartRateVariabilityRmssdRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion = convertInstantQuantity( + metadata = record.metadata, + recordType = HealthConnectConverter.HEART_RATE_VARIABILITY_RMSSD_RECORD, + time = record.time, + offset = record.zoneOffset, + value = record.heartRateVariabilityMillis, + spec = MobileQuantitySpec( + profile = HealthConnectContract.MOBILE_HEART_RATE_VARIABILITY_RMSSD_PROFILE, + category = HealthConnectConverter.VITAL_SIGNS_CATEGORY, + codeSystem = HealthConnectContract.GROVE_MOBILE_MEASUREMENT, + code = "heart-rate-variability-rmssd", + display = "Heart rate variability RMSSD", + unitCode = "ms", + unitDisplay = "ms", + ), + convertedAt = convertedAt, + eventSequence = eventSequence, +) + +internal fun HealthConnectConverter.convertVo2Max( + record: Vo2MaxRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion = convertInstantQuantity( + metadata = record.metadata, + recordType = HealthConnectConverter.VO2_MAX_RECORD, + time = record.time, + offset = record.zoneOffset, + value = record.vo2MillilitersPerMinuteKilogram, + spec = MobileQuantitySpec( + profile = HealthConnectContract.MOBILE_VO2_MAX_PROFILE, + category = HealthConnectConverter.ACTIVITY_CATEGORY, + codeSystem = HealthConnectContract.GROVE_MOBILE_MEASUREMENT, + code = "vo2-max", + display = "VO2 max", + unitCode = "mL/kg/min", + unitDisplay = "mL/kg/min", + ), + method = CodeableConcept(vo2MaxMeasurementMethodCoding(record.measurementMethod)), + convertedAt = convertedAt, + eventSequence = eventSequence, +) + +internal fun HealthConnectConverter.convertHydration( + record: HydrationRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion = convertIntervalQuantity( + metadata = record.metadata, + recordType = HealthConnectConverter.HYDRATION_RECORD, + start = record.startTime, + startOffset = record.startZoneOffset, + end = record.endTime, + endOffset = record.endZoneOffset, + value = record.volume.inMilliliters, + spec = MobileQuantitySpec( + profile = HealthConnectContract.MOBILE_FLUID_INTAKE_PROFILE, + category = null, + codeSystem = HealthConnectContract.LOINC, + code = "8985-4", + display = "Fluid intake Measured", + unitCode = "mL", + unitDisplay = "mL", + ), + convertedAt = convertedAt, + eventSequence = eventSequence, +) + +internal fun HealthConnectConverter.convertElevationGained( + record: ElevationGainedRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion = convertIntervalQuantity( + metadata = record.metadata, + recordType = HealthConnectConverter.ELEVATION_GAINED_RECORD, + start = record.startTime, + startOffset = record.startZoneOffset, + end = record.endTime, + endOffset = record.endZoneOffset, + value = record.elevation.inMeters, + spec = MobileQuantitySpec( + profile = HealthConnectContract.HEALTH_CONNECT_ELEVATION_GAINED_PROFILE, + category = HealthConnectConverter.ACTIVITY_CATEGORY, + codeSystem = HealthConnectContract.HEALTH_CONNECT_MEASUREMENT, + code = "elevation-gained", + display = "Elevation gained", + unitCode = "m", + unitDisplay = "m", + // Health Connect admits negative elevation change over an interval. + valueDomain = QuantityValueDomain.UNBOUNDED, + adapterSpecific = true, + ), + convertedAt = convertedAt, + eventSequence = eventSequence, +) + +internal fun HealthConnectConverter.convertFloorsClimbed( + record: FloorsClimbedRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion = convertIntervalQuantity( + metadata = record.metadata, + recordType = HealthConnectConverter.FLOORS_CLIMBED_RECORD, + start = record.startTime, + startOffset = record.startZoneOffset, + end = record.endTime, + endOffset = record.endZoneOffset, + value = record.floors, + spec = MobileQuantitySpec( + profile = HealthConnectContract.MOBILE_FLIGHTS_CLIMBED_PROFILE, + category = HealthConnectConverter.ACTIVITY_CATEGORY, + codeSystem = HealthConnectContract.LOINC, + code = "100304-5", + display = "Flights climbed [#] Reporting Period", + unitCode = "{flights}", + unitDisplay = "flights", + valueDomain = HealthConnectContract.quantityValueDomains.getValue("flights-climbed"), + ), + convertedAt = convertedAt, + eventSequence = eventSequence, +) + +internal fun HealthConnectConverter.convertTotalEnergy( + record: TotalCaloriesBurnedRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion = convertIntervalQuantity( + metadata = record.metadata, + recordType = HealthConnectConverter.TOTAL_CALORIES_BURNED_RECORD, + start = record.startTime, + startOffset = record.startZoneOffset, + end = record.endTime, + endOffset = record.endZoneOffset, + value = record.energy.inKilocalories, + spec = MobileQuantitySpec( + profile = HealthConnectContract.HEALTH_CONNECT_TOTAL_ENERGY_PROFILE, + category = HealthConnectConverter.ACTIVITY_CATEGORY, + codeSystem = HealthConnectContract.HEALTH_CONNECT_MEASUREMENT, + code = "total-energy-burned", + display = "Total energy burned", + unitCode = "kcal", + unitDisplay = "kcal", + adapterSpecific = true, + ), + convertedAt = convertedAt, + eventSequence = eventSequence, +) + +internal fun HealthConnectConverter.convertWheelchairPushes( + record: WheelchairPushesRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion = convertIntervalQuantity( + metadata = record.metadata, + recordType = HealthConnectConverter.WHEELCHAIR_PUSHES_RECORD, + start = record.startTime, + startOffset = record.startZoneOffset, + end = record.endTime, + endOffset = record.endZoneOffset, + value = record.count.toDouble(), + exactValue = record.count.toBigDecimal(), + spec = MobileQuantitySpec( + profile = HealthConnectContract.MOBILE_WHEELCHAIR_PUSH_COUNT_PROFILE, + category = HealthConnectConverter.ACTIVITY_CATEGORY, + codeSystem = HealthConnectContract.LOINC, + code = "96502-0", + display = "Number of wheelchair pushes per time period", + unitCode = "{pushes}", + unitDisplay = "pushes", + valueDomain = HealthConnectContract.quantityValueDomains.getValue("wheelchair-push-count"), + ), + convertedAt = convertedAt, + eventSequence = eventSequence, +) + +internal fun HealthConnectConverter.convertRestingHeartRate( + record: RestingHeartRateRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion { + if (record.beatsPerMinute < 0L) { + throw InvalidHealthConnectRecord("RestingHeartRateRecord beats per minute must be unsigned.") + } + val source = sourceIdentity(record.metadata, HealthConnectConverter.RESTING_HEART_RATE_RECORD) + val resolvedContext = context.resolve( + record.metadata, + synchronizationScope.identityKey, + bundleIdentifier(eventSequence), + ) + val observation = baseObservation( + record.metadata, + source, + null, + resolvedContext, + "resting-heart-rate", + ).apply { + claimMeasurementProfile(HealthConnectContract.MOBILE_RESTING_HEART_RATE_PROFILE) + code = concept( + HealthConnectContract.LOINC, + "40443-4", + "Resting heart rate", + ).apply { + addCoding(Coding(HealthConnectContract.LOINC, "8867-4", "Heart rate")) + } + addCategory(category(HealthConnectConverter.VITAL_SIGNS_CATEGORY)) + // Health Connect 1.1 exposes one instantaneous estimate and no aggregation window. + effective = DateTimeType(record.time.fhirDateTime(record.zoneOffset, "Resting heart rate time")) + value = quantity(record.beatsPerMinute.toBigDecimal(), "/min", "beats/minute") + } + return conversion( + record.metadata, + HealthConnectConverter.RESTING_HEART_RATE_RECORD, + source, + listOf(observation), + convertedAt, + eventSequence, + resolvedContext, + ) +} diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectCatalog.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectCatalog.kt new file mode 100644 index 00000000..92f3b1b2 --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectCatalog.kt @@ -0,0 +1,87 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import org.grovealliance.health.RecordType + +/** Closed source-type inventory admitted by the current Health Connect FHIR producer. */ +internal object HealthConnectCatalog { + /** Exact AndroidX 1.1.0 inventory surfaced by this version of Grove Health. */ + val allRecordTypeIdentifiers: Set = RecordType.all.mapTo(mutableSetOf()) { it.identifier } + + val supportedRecordTypeIdentifiers: Set = setOf( + "ActiveCaloriesBurnedRecord", + "BasalBodyTemperatureRecord", + "BasalMetabolicRateRecord", + "BloodGlucoseRecord", + "BloodPressureRecord", + "BodyFatRecord", + "BodyTemperatureRecord", + "BodyWaterMassRecord", + "BoneMassRecord", + "CervicalMucusRecord", + "CyclingPedalingCadenceRecord", + "DistanceRecord", + "ElevationGainedRecord", + "ExerciseSessionRecord", + "FloorsClimbedRecord", + "HeartRateRecord", + "HeartRateVariabilityRmssdRecord", + "HeightRecord", + "HydrationRecord", + "IntermenstrualBleedingRecord", + "LeanBodyMassRecord", + "MenstruationFlowRecord", + "MenstruationPeriodRecord", + "MindfulnessSessionRecord", + "NutritionRecord", + "OvulationTestRecord", + "OxygenSaturationRecord", + "PowerRecord", + "RespiratoryRateRecord", + "RestingHeartRateRecord", + "SexualActivityRecord", + "SkinTemperatureRecord", + "SleepSessionRecord", + "SpeedRecord", + "StepsCadenceRecord", + "StepsRecord", + "TotalCaloriesBurnedRecord", + "Vo2MaxRecord", + "WeightRecord", + "WheelchairPushesRecord", + ) + + /** Exact source inventory that is deliberately not emitted yet. */ + val deferredRecordTypeIdentifiers: Set = setOf( + "PlannedExerciseSessionRecord", + ) + + /** Supported source types whose successful conversion may legitimately produce no outputs. */ + val zeroOutputRecordTypeIdentifiers: Set = setOf( + "CyclingPedalingCadenceRecord", + "HeartRateRecord", + "NutritionRecord", + "PowerRecord", + "SkinTemperatureRecord", + "SpeedRecord", + "StepsCadenceRecord", + ) + + init { + check(supportedRecordTypeIdentifiers.intersect(deferredRecordTypeIdentifiers).isEmpty()) { + "A Health Connect source type cannot be both supported and deferred." + } + check(supportedRecordTypeIdentifiers + deferredRecordTypeIdentifiers == allRecordTypeIdentifiers) { + "Every AndroidX Health Connect 1.1 source type must have one explicit adapter status." + } + check(zeroOutputRecordTypeIdentifiers.all { it in supportedRecordTypeIdentifiers }) { + "Only a supported Health Connect source type may claim zero-output conversions." + } + } +} diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectClinicalConversions.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectClinicalConversions.kt new file mode 100644 index 00000000..c91e4cc8 --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectClinicalConversions.kt @@ -0,0 +1,272 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import androidx.health.connect.client.records.BasalBodyTemperatureRecord +import androidx.health.connect.client.records.BloodGlucoseRecord +import androidx.health.connect.client.records.BloodPressureRecord +import androidx.health.connect.client.records.BodyTemperatureRecord +import androidx.health.connect.client.records.DistanceRecord +import androidx.health.connect.client.records.HeightRecord +import androidx.health.connect.client.records.OxygenSaturationRecord +import androidx.health.connect.client.records.RespiratoryRateRecord +import org.hl7.fhir.r4.model.CodeableConcept +import org.hl7.fhir.r4.model.DateTimeType +import org.hl7.fhir.r4.model.Extension +import org.hl7.fhir.r4.model.Specimen +import java.time.Instant + +internal fun HealthConnectConverter.convertBloodGlucose( + record: BloodGlucoseRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion { + val source = sourceIdentity(record.metadata, HealthConnectConverter.BLOOD_GLUCOSE_RECORD) + val resolvedContext = context.resolve( + record.metadata, + synchronizationScope.identityKey, + bundleIdentifier(eventSequence), + ) + val definition = bloodGlucoseDefinition(record.specimenSource) + val specimenIdentity = specimenIdentifier(source, definition.specimenSourceCode) + val specimen = Specimen().apply { + meta.addProfile(HealthConnectContract.HEALTH_CONNECT_SPECIMEN_PROFILE) + addIdentifier(source.identifier.copy()) + addIdentifier(specimenIdentity.copy()) + status = Specimen.SpecimenStatus.AVAILABLE + type = CodeableConcept(definition.specimenType.copy()) + subject = resolvedContext.subject.copy() + } + val specimenResource = HealthConnectBundleResource(specimenIdentity, specimen) + val observation = baseObservation( + record.metadata, + source, + null, + resolvedContext, + definition.measurement, + ).apply { + claimAdapterSpecificProfile(definition.profile) + addCategory(category(HealthConnectConverter.LABORATORY_CATEGORY)) + code = concept(HealthConnectContract.LOINC, definition.loinc, definition.loincDisplay) + effective = DateTimeType(record.time.fhirDateTime(record.zoneOffset, "Blood-glucose time")) + value = quantity( + record.level.inMilligramsPerDeciliter.fhirDecimal("Blood-glucose value"), + "mg/dL", + "mg/dL", + ) + this.specimen = specimenResource.reference() + glucoseMealContext(record)?.let(::addExtension) + } + return conversion( + record.metadata, + HealthConnectConverter.BLOOD_GLUCOSE_RECORD, + source, + listOf(observation), + convertedAt, + eventSequence, + resolvedContext, + listOf(specimenResource), + ) +} + +internal fun HealthConnectConverter.convertBloodPressure( + record: BloodPressureRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion { + val source = sourceIdentity(record.metadata, HealthConnectConverter.BLOOD_PRESSURE_RECORD) + val resolvedContext = context.resolve( + record.metadata, + synchronizationScope.identityKey, + bundleIdentifier(eventSequence), + ) + val systolic = record.systolic.inMillimetersOfMercury.fhirDecimal("Blood-pressure systolic value") + val diastolic = record.diastolic.inMillimetersOfMercury.fhirDecimal("Blood-pressure diastolic value") + val observation = baseObservation( + record.metadata, + source, + null, + resolvedContext, + "blood-pressure", + ).apply { + claimMeasurementProfile(HealthConnectContract.MOBILE_BLOOD_PRESSURE_PROFILE) + addCategory(concept(HealthConnectContract.OBSERVATION_CATEGORY, HealthConnectConverter.VITAL_SIGNS_CATEGORY, "Vital Signs")) + code = concept(HealthConnectContract.LOINC, "85354-9", "Blood pressure panel") + effective = DateTimeType(record.time.fhirDateTime(record.zoneOffset, "Blood-pressure time")) + addComponent().apply { + code = concept(HealthConnectContract.LOINC, "8480-6", "Systolic blood pressure") + value = quantity(systolic, "mm[Hg]", "mmHg") + } + bloodPressureBodyPosition(record.bodyPosition)?.let { position -> + addExtension( + Extension( + HealthConnectContract.OBSERVATION_BODY_POSITION, + CodeableConcept(position), + ), + ) + } + bodySite = bloodPressureMeasurementLocation(record.measurementLocation) + addComponent().apply { + code = concept(HealthConnectContract.LOINC, "8462-4", "Diastolic blood pressure") + value = quantity(diastolic, "mm[Hg]", "mmHg") + } + } + return conversion( + record.metadata, + HealthConnectConverter.BLOOD_PRESSURE_RECORD, + source, + listOf(observation), + convertedAt, + eventSequence, + resolvedContext, + ) +} + +internal fun HealthConnectConverter.convertBasalBodyTemperature( + record: BasalBodyTemperatureRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion = convertInstantQuantity( + metadata = record.metadata, + recordType = HealthConnectConverter.BASAL_BODY_TEMPERATURE_RECORD, + time = record.time, + offset = record.zoneOffset, + value = record.temperature.inCelsius, + spec = MobileQuantitySpec( + profile = HealthConnectContract.MOBILE_BASAL_BODY_TEMPERATURE_PROFILE, + category = HealthConnectConverter.VITAL_SIGNS_CATEGORY, + codeSystem = HealthConnectContract.GROVE_MOBILE_MEASUREMENT, + code = "basal-body-temperature", + display = "Basal body temperature", + unitCode = "Cel", + unitDisplay = "Cel", + valueDomain = QuantityValueDomain.UNBOUNDED, + ), + bodySite = temperatureMeasurementLocation(record.measurementLocation), + convertedAt = convertedAt, + eventSequence = eventSequence, +) +internal fun HealthConnectConverter.convertBodyTemperature( + record: BodyTemperatureRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion = convertInstantQuantity( + metadata = record.metadata, + recordType = HealthConnectConverter.BODY_TEMPERATURE_RECORD, + time = record.time, + offset = record.zoneOffset, + value = record.temperature.inCelsius, + spec = MobileQuantitySpec( + profile = HealthConnectContract.MOBILE_BODY_TEMPERATURE_PROFILE, + category = HealthConnectConverter.VITAL_SIGNS_CATEGORY, + codeSystem = HealthConnectContract.LOINC, + code = "8310-5", + display = "Body temperature", + unitCode = "Cel", + unitDisplay = "Cel", + valueDomain = QuantityValueDomain.UNBOUNDED, + ), + bodySite = temperatureMeasurementLocation(record.measurementLocation), + convertedAt = convertedAt, + eventSequence = eventSequence, +) + +internal fun HealthConnectConverter.convertDistance( + record: DistanceRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion = convertIntervalQuantity( + metadata = record.metadata, + recordType = HealthConnectConverter.DISTANCE_RECORD, + start = record.startTime, + startOffset = record.startZoneOffset, + end = record.endTime, + endOffset = record.endZoneOffset, + value = record.distance.inMeters, + spec = MobileQuantitySpec( + profile = HealthConnectContract.MOBILE_DISTANCE_PROFILE, + category = HealthConnectConverter.ACTIVITY_CATEGORY, + codeSystem = HealthConnectContract.LOINC, + code = "103208-5", + display = "Distance traveled", + unitCode = "m", + unitDisplay = "m", + ), + convertedAt = convertedAt, + eventSequence = eventSequence, +) + +internal fun HealthConnectConverter.convertHeight( + record: HeightRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion = convertInstantQuantity( + metadata = record.metadata, + recordType = HealthConnectConverter.HEIGHT_RECORD, + time = record.time, + offset = record.zoneOffset, + value = record.height.inMeters * HealthConnectConverter.CENTIMETERS_PER_METER, + spec = MobileQuantitySpec( + profile = HealthConnectContract.MOBILE_BODY_HEIGHT_PROFILE, + category = HealthConnectConverter.VITAL_SIGNS_CATEGORY, + codeSystem = HealthConnectContract.LOINC, + code = "8302-2", + display = "Body height", + unitCode = "cm", + unitDisplay = "cm", + ), + convertedAt = convertedAt, + eventSequence = eventSequence, +) + +internal fun HealthConnectConverter.convertOxygenSaturation( + record: OxygenSaturationRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion = convertInstantQuantity( + metadata = record.metadata, + recordType = HealthConnectConverter.OXYGEN_SATURATION_RECORD, + time = record.time, + offset = record.zoneOffset, + value = record.percentage.value, + spec = MobileQuantitySpec( + profile = HealthConnectContract.MOBILE_OXYGEN_SATURATION_PROFILE, + category = HealthConnectConverter.VITAL_SIGNS_CATEGORY, + codeSystem = HealthConnectContract.LOINC, + code = "2708-6", + display = "Oxygen saturation in Arterial blood", + unitCode = "%", + unitDisplay = "%", + valueDomain = HealthConnectContract.quantityValueDomains.getValue("oxygen-saturation"), + ), + convertedAt = convertedAt, + eventSequence = eventSequence, +) + +internal fun HealthConnectConverter.convertRespiratoryRate( + record: RespiratoryRateRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion = convertInstantQuantity( + metadata = record.metadata, + recordType = HealthConnectConverter.RESPIRATORY_RATE_RECORD, + time = record.time, + offset = record.zoneOffset, + value = record.rate, + spec = MobileQuantitySpec( + profile = HealthConnectContract.MOBILE_RESPIRATORY_RATE_PROFILE, + category = HealthConnectConverter.VITAL_SIGNS_CATEGORY, + codeSystem = HealthConnectContract.LOINC, + code = "9279-1", + display = "Respiratory rate", + unitCode = "/min", + unitDisplay = "breaths/minute", + ), + convertedAt = convertedAt, + eventSequence = eventSequence, +) diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectContextCodings.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectContextCodings.kt new file mode 100644 index 00000000..7c105fe8 --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectContextCodings.kt @@ -0,0 +1,243 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import androidx.health.connect.client.feature.ExperimentalMindfulnessSessionApi +import androidx.health.connect.client.records.BloodGlucoseRecord +import androidx.health.connect.client.records.BloodPressureRecord +import androidx.health.connect.client.records.BodyTemperatureMeasurementLocation +import androidx.health.connect.client.records.MealType +import androidx.health.connect.client.records.MindfulnessSessionRecord +import androidx.health.connect.client.records.SkinTemperatureRecord +import androidx.health.connect.client.records.SleepSessionRecord +import androidx.health.connect.client.records.Vo2MaxRecord +import org.hl7.fhir.r4.model.CodeableConcept +import org.hl7.fhir.r4.model.Coding + +/** Maps each Health Connect measurement-context constant onto its standard coding. */ +internal fun sleepStageCoding(stage: Int): SourceCodedValue = when (stage) { + SleepSessionRecord.STAGE_TYPE_UNKNOWN -> + SourceCodedValue( + "unknown", + "Unknown sleep stage", + "STAGE_TYPE_UNKNOWN", + "Unknown", + ) + SleepSessionRecord.STAGE_TYPE_AWAKE -> + SourceCodedValue("awake", "Awake", "STAGE_TYPE_AWAKE", "Awake") + SleepSessionRecord.STAGE_TYPE_SLEEPING -> + SourceCodedValue( + "asleep-unspecified", + "Asleep, unspecified stage", + "STAGE_TYPE_SLEEPING", + "Sleeping", + ) + SleepSessionRecord.STAGE_TYPE_OUT_OF_BED -> + SourceCodedValue("out-of-bed", "Out of bed", "STAGE_TYPE_OUT_OF_BED", "Out of bed") + SleepSessionRecord.STAGE_TYPE_LIGHT -> + SourceCodedValue("light", "Light sleep", "STAGE_TYPE_LIGHT", "Light sleep") + SleepSessionRecord.STAGE_TYPE_DEEP -> + SourceCodedValue("deep", "Deep sleep", "STAGE_TYPE_DEEP", "Deep sleep") + SleepSessionRecord.STAGE_TYPE_REM -> + SourceCodedValue("rem", "REM sleep", "STAGE_TYPE_REM", "REM sleep") + SleepSessionRecord.STAGE_TYPE_AWAKE_IN_BED -> + SourceCodedValue("awake", "Awake", "STAGE_TYPE_AWAKE_IN_BED", "Awake in bed") + else -> throw InvalidHealthConnectRecord("Unsupported Health Connect sleep stage: $stage") +} + +@OptIn(ExperimentalMindfulnessSessionApi::class) +internal fun mindfulnessSessionTypeCoding(value: Int): Coding = when (value) { + MindfulnessSessionRecord.MINDFULNESS_SESSION_TYPE_UNKNOWN -> + Coding( + HealthConnectContract.HEALTH_CONNECT_MINDFULNESS_SESSION_TYPE, + "MINDFULNESS_SESSION_TYPE_UNKNOWN", + "Unknown", + ) + MindfulnessSessionRecord.MINDFULNESS_SESSION_TYPE_MEDITATION -> + Coding( + HealthConnectContract.HEALTH_CONNECT_MINDFULNESS_SESSION_TYPE, + "MINDFULNESS_SESSION_TYPE_MEDITATION", + "Meditation", + ) + MindfulnessSessionRecord.MINDFULNESS_SESSION_TYPE_BREATHING -> + Coding( + HealthConnectContract.HEALTH_CONNECT_MINDFULNESS_SESSION_TYPE, + "MINDFULNESS_SESSION_TYPE_BREATHING", + "Breathing", + ) + MindfulnessSessionRecord.MINDFULNESS_SESSION_TYPE_MUSIC -> + Coding( + HealthConnectContract.HEALTH_CONNECT_MINDFULNESS_SESSION_TYPE, + "MINDFULNESS_SESSION_TYPE_MUSIC", + "Music", + ) + MindfulnessSessionRecord.MINDFULNESS_SESSION_TYPE_MOVEMENT -> + Coding( + HealthConnectContract.HEALTH_CONNECT_MINDFULNESS_SESSION_TYPE, + "MINDFULNESS_SESSION_TYPE_MOVEMENT", + "Movement", + ) + MindfulnessSessionRecord.MINDFULNESS_SESSION_TYPE_UNGUIDED -> + Coding( + HealthConnectContract.HEALTH_CONNECT_MINDFULNESS_SESSION_TYPE, + "MINDFULNESS_SESSION_TYPE_UNGUIDED", + "Unguided", + ) + else -> throw InvalidHealthConnectRecord("Unsupported Health Connect mindfulness-session type: $value") +} + +internal fun vo2MaxMeasurementMethodCoding(value: Int): Coding = when (value) { + Vo2MaxRecord.MEASUREMENT_METHOD_OTHER -> + Coding(HealthConnectContract.HEALTH_CONNECT_VO2_MAX_MEASUREMENT_METHOD, "MEASUREMENT_METHOD_OTHER", "Other") + Vo2MaxRecord.MEASUREMENT_METHOD_METABOLIC_CART -> + Coding( + HealthConnectContract.HEALTH_CONNECT_VO2_MAX_MEASUREMENT_METHOD, + "MEASUREMENT_METHOD_METABOLIC_CART", + "Metabolic cart", + ) + Vo2MaxRecord.MEASUREMENT_METHOD_HEART_RATE_RATIO -> + Coding( + HealthConnectContract.HEALTH_CONNECT_VO2_MAX_MEASUREMENT_METHOD, + "MEASUREMENT_METHOD_HEART_RATE_RATIO", + "Heart-rate ratio", + ) + Vo2MaxRecord.MEASUREMENT_METHOD_COOPER_TEST -> + Coding( + HealthConnectContract.HEALTH_CONNECT_VO2_MAX_MEASUREMENT_METHOD, + "MEASUREMENT_METHOD_COOPER_TEST", + "Cooper test", + ) + Vo2MaxRecord.MEASUREMENT_METHOD_MULTISTAGE_FITNESS_TEST -> + Coding( + HealthConnectContract.HEALTH_CONNECT_VO2_MAX_MEASUREMENT_METHOD, + "MEASUREMENT_METHOD_MULTISTAGE_FITNESS_TEST", + "Multistage fitness test", + ) + Vo2MaxRecord.MEASUREMENT_METHOD_ROCKPORT_FITNESS_TEST -> + Coding( + HealthConnectContract.HEALTH_CONNECT_VO2_MAX_MEASUREMENT_METHOD, + "MEASUREMENT_METHOD_ROCKPORT_FITNESS_TEST", + "Rockport fitness test", + ) + else -> throw InvalidHealthConnectRecord("Unsupported Health Connect VO2 max measurement method: $value") +} + +internal fun bloodGlucoseRelationToMeal(value: Int): Coding? = when (value) { + BloodGlucoseRecord.RELATION_TO_MEAL_UNKNOWN -> null + BloodGlucoseRecord.RELATION_TO_MEAL_GENERAL -> healthConnectMealCoding( + HealthConnectContract.HEALTH_CONNECT_RELATION_TO_MEAL, + "RELATION_TO_MEAL_GENERAL", + "General", + ) + BloodGlucoseRecord.RELATION_TO_MEAL_FASTING -> healthConnectMealCoding( + HealthConnectContract.HEALTH_CONNECT_RELATION_TO_MEAL, + "RELATION_TO_MEAL_FASTING", + "Fasting", + ) + BloodGlucoseRecord.RELATION_TO_MEAL_BEFORE_MEAL -> healthConnectMealCoding( + HealthConnectContract.HEALTH_CONNECT_RELATION_TO_MEAL, + "RELATION_TO_MEAL_BEFORE_MEAL", + "Before meal", + ) + BloodGlucoseRecord.RELATION_TO_MEAL_AFTER_MEAL -> healthConnectMealCoding( + HealthConnectContract.HEALTH_CONNECT_RELATION_TO_MEAL, + "RELATION_TO_MEAL_AFTER_MEAL", + "After meal", + ) + else -> throw InvalidHealthConnectRecord("Unsupported Health Connect relation to meal: $value") +} + +internal fun bloodGlucoseMealType(value: Int): Coding? = when (value) { + MealType.MEAL_TYPE_UNKNOWN -> null + MealType.MEAL_TYPE_BREAKFAST -> healthConnectMealCoding( + HealthConnectContract.HEALTH_CONNECT_MEAL_TYPE, + "MEAL_TYPE_BREAKFAST", + "Breakfast", + ) + MealType.MEAL_TYPE_LUNCH -> healthConnectMealCoding( + HealthConnectContract.HEALTH_CONNECT_MEAL_TYPE, + "MEAL_TYPE_LUNCH", + "Lunch", + ) + MealType.MEAL_TYPE_DINNER -> healthConnectMealCoding( + HealthConnectContract.HEALTH_CONNECT_MEAL_TYPE, + "MEAL_TYPE_DINNER", + "Dinner", + ) + MealType.MEAL_TYPE_SNACK -> healthConnectMealCoding( + HealthConnectContract.HEALTH_CONNECT_MEAL_TYPE, + "MEAL_TYPE_SNACK", + "Snack", + ) + else -> throw InvalidHealthConnectRecord("Unsupported Health Connect meal type: $value") +} + +internal fun healthConnectMealCoding(system: String, code: String, display: String): Coding = + Coding(system, code, display) + +internal fun bloodPressureBodyPosition(value: Int): Coding? = when (value) { + BloodPressureRecord.BODY_POSITION_UNKNOWN -> null + BloodPressureRecord.BODY_POSITION_STANDING_UP -> + Coding(HealthConnectContract.SNOMED_CT, "10904000", "Orthostatic body position") + BloodPressureRecord.BODY_POSITION_SITTING_DOWN -> + Coding(HealthConnectContract.SNOMED_CT, "33586001", "Sitting position") + BloodPressureRecord.BODY_POSITION_LYING_DOWN -> + Coding(HealthConnectContract.SNOMED_CT, "102538003", "Recumbent body position") + BloodPressureRecord.BODY_POSITION_RECLINING -> + Coding(HealthConnectContract.SNOMED_CT, "272580008", "Semi-recumbent position") + else -> throw InvalidHealthConnectRecord("Unsupported Health Connect blood-pressure body position: $value") +} + +internal fun bloodPressureMeasurementLocation(value: Int): CodeableConcept? = when (value) { + BloodPressureRecord.MEASUREMENT_LOCATION_UNKNOWN -> null + BloodPressureRecord.MEASUREMENT_LOCATION_LEFT_WRIST -> + concept(HealthConnectContract.SNOMED_CT, "5951000", "Structure of left wrist region") + BloodPressureRecord.MEASUREMENT_LOCATION_RIGHT_WRIST -> + concept(HealthConnectContract.SNOMED_CT, "9736006", "Structure of right wrist") + BloodPressureRecord.MEASUREMENT_LOCATION_LEFT_UPPER_ARM -> + concept(HealthConnectContract.SNOMED_CT, "368208006", "Left upper arm structure") + BloodPressureRecord.MEASUREMENT_LOCATION_RIGHT_UPPER_ARM -> + concept(HealthConnectContract.SNOMED_CT, "368209003", "Right upper arm structure") + else -> throw InvalidHealthConnectRecord("Unsupported Health Connect blood-pressure location: $value") +} + +internal fun temperatureMeasurementLocation(value: Int): CodeableConcept? = when (value) { + BodyTemperatureMeasurementLocation.MEASUREMENT_LOCATION_UNKNOWN -> null + BodyTemperatureMeasurementLocation.MEASUREMENT_LOCATION_ARMPIT -> + concept(HealthConnectContract.SNOMED_CT, "422543003", "Structure of axillary fossa") + BodyTemperatureMeasurementLocation.MEASUREMENT_LOCATION_FINGER -> + concept(HealthConnectContract.SNOMED_CT, "7569003", "Finger structure") + BodyTemperatureMeasurementLocation.MEASUREMENT_LOCATION_FOREHEAD -> + concept(HealthConnectContract.SNOMED_CT, "52795006", "Forehead structure") + BodyTemperatureMeasurementLocation.MEASUREMENT_LOCATION_MOUTH -> + concept(HealthConnectContract.SNOMED_CT, "74262004", "Oral cavity structure") + BodyTemperatureMeasurementLocation.MEASUREMENT_LOCATION_RECTUM -> + concept(HealthConnectContract.SNOMED_CT, "34402009", "Rectum structure") + BodyTemperatureMeasurementLocation.MEASUREMENT_LOCATION_TEMPORAL_ARTERY -> + concept(HealthConnectContract.SNOMED_CT, "15672000", "Structure of superficial temporal artery") + BodyTemperatureMeasurementLocation.MEASUREMENT_LOCATION_TOE -> + concept(HealthConnectContract.SNOMED_CT, "29707007", "Toe structure") + BodyTemperatureMeasurementLocation.MEASUREMENT_LOCATION_EAR -> + concept(HealthConnectContract.SNOMED_CT, "117590005", "Ear structure") + BodyTemperatureMeasurementLocation.MEASUREMENT_LOCATION_WRIST -> + concept(HealthConnectContract.SNOMED_CT, "8205005", "Wrist region structure") + BodyTemperatureMeasurementLocation.MEASUREMENT_LOCATION_VAGINA -> + concept(HealthConnectContract.SNOMED_CT, "76784001", "Vaginal structure") + else -> throw InvalidHealthConnectRecord("Unsupported Health Connect temperature location: $value") +} + +internal fun skinTemperatureMeasurementLocation(value: Int): CodeableConcept? = when (value) { + SkinTemperatureRecord.MEASUREMENT_LOCATION_UNKNOWN -> null + SkinTemperatureRecord.MEASUREMENT_LOCATION_FINGER -> + concept(HealthConnectContract.SNOMED_CT, "7569003", "Finger structure") + SkinTemperatureRecord.MEASUREMENT_LOCATION_TOE -> + concept(HealthConnectContract.SNOMED_CT, "29707007", "Toe structure") + SkinTemperatureRecord.MEASUREMENT_LOCATION_WRIST -> + concept(HealthConnectContract.SNOMED_CT, "8205005", "Wrist region structure") + else -> throw InvalidHealthConnectRecord("Unsupported Health Connect skin-temperature location: $value") +} diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectContract.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectContract.kt new file mode 100644 index 00000000..7efe6f3e --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectContract.kt @@ -0,0 +1,617 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +// Generated by Scripts/generate-grove-fhir-kotlin-contract.py. Do not edit by hand. + +package org.grovealliance.health.fhir + +/** Canonicals shared with the Grove FHIR Mobile and Health Connect packages. */ +object HealthConnectContract { + const val FHIR_VERSION = "4.0.1" + const val PACKAGE_VERSION = "0.6.0" + const val CONVERSION_CONTRACT_VERSION = "health-connect-r4-0.6.0" + const val CANONICAL_ROOT = "https://grovealliance.org/fhir" + const val MOBILE_BASE = "$CANONICAL_ROOT/mobile" + const val HEALTH_CONNECT_BASE = "$CANONICAL_ROOT/health-connect" + + /** Closed provider codes admitted by the provider-specific HMAC identity domains. */ + internal val providerCodes: Set = setOf( + "google-health-api", + "oura", + "withings", + ) + + const val MOBILE_OBSERVATION_PROFILE = "$MOBILE_BASE/StructureDefinition/grove-mobile-observation" + const val MOBILE_EXCHANGE_BUNDLE_PROFILE = "$MOBILE_BASE/StructureDefinition/grove-mobile-exchange-bundle" + const val MOBILE_RETRACTION_BUNDLE_PROFILE = "$MOBILE_BASE/StructureDefinition/grove-mobile-retraction-bundle" + const val MOBILE_RETRACTION_PROVENANCE_PROFILE = + "$MOBILE_BASE/StructureDefinition/grove-mobile-retraction-provenance" + const val MOBILE_APPLICATION_DEVICE_PROFILE = "$MOBILE_BASE/StructureDefinition/grove-application-device" + const val MOBILE_HOST_DEVICE_PROFILE = "$MOBILE_BASE/StructureDefinition/grove-host-device" + const val MOBILE_RECORDING_DEVICE_PROFILE = "$MOBILE_BASE/StructureDefinition/grove-recording-device" + const val HEALTH_CONNECT_OBSERVATION_PROFILE = "$HEALTH_CONNECT_BASE/StructureDefinition/health-connect-observation" + const val HEALTH_CONNECT_SPECIMEN_PROFILE = "$HEALTH_CONNECT_BASE/StructureDefinition/health-connect-specimen" + const val HEALTH_CONNECT_PROVENANCE_PROFILE = + "$HEALTH_CONNECT_BASE/StructureDefinition/health-connect-conversion-provenance" + + const val MOBILE_ACTIVE_ENERGY_PROFILE = "$MOBILE_BASE/StructureDefinition/grove-mobile-active-energy" + const val MOBILE_BASAL_BODY_TEMPERATURE_PROFILE = + "$MOBILE_BASE/StructureDefinition/grove-mobile-basal-body-temperature" + const val MOBILE_BLOOD_PRESSURE_PROFILE = "$MOBILE_BASE/StructureDefinition/grove-mobile-blood-pressure" + const val MOBILE_BODY_FAT_PERCENTAGE_PROFILE = "$MOBILE_BASE/StructureDefinition/grove-mobile-body-fat-percentage" + const val MOBILE_BODY_HEIGHT_PROFILE = "$MOBILE_BASE/StructureDefinition/grove-mobile-body-height" + const val MOBILE_BODY_TEMPERATURE_PROFILE = "$MOBILE_BASE/StructureDefinition/grove-mobile-body-temperature" + const val MOBILE_BODY_WATER_MASS_PROFILE = "$MOBILE_BASE/StructureDefinition/grove-mobile-body-water-mass" + const val MOBILE_BODY_WEIGHT_PROFILE = "$MOBILE_BASE/StructureDefinition/grove-mobile-body-weight" + const val MOBILE_BONE_MASS_PROFILE = "$MOBILE_BASE/StructureDefinition/grove-mobile-bone-mass" + const val MOBILE_CERVICAL_MUCUS_QUALITY_PROFILE = + "$MOBILE_BASE/StructureDefinition/grove-mobile-cervical-mucus-quality" + const val MOBILE_CYCLING_CADENCE_PROFILE = "$MOBILE_BASE/StructureDefinition/grove-mobile-cycling-cadence" + const val MOBILE_DISTANCE_PROFILE = "$MOBILE_BASE/StructureDefinition/grove-mobile-distance" + const val MOBILE_FLIGHTS_CLIMBED_PROFILE = "$MOBILE_BASE/StructureDefinition/grove-mobile-flights-climbed" + const val MOBILE_FLUID_INTAKE_PROFILE = "$MOBILE_BASE/StructureDefinition/grove-mobile-fluid-intake" + const val MOBILE_HEART_RATE_PROFILE = "$MOBILE_BASE/StructureDefinition/grove-mobile-heart-rate" + const val MOBILE_HEART_RATE_VARIABILITY_RMSSD_PROFILE = + "$MOBILE_BASE/StructureDefinition/grove-mobile-heart-rate-variability-rmssd" + const val MOBILE_INTERMENSTRUAL_BLEEDING_PROFILE = + "$MOBILE_BASE/StructureDefinition/grove-mobile-intermenstrual-bleeding" + const val MOBILE_LEAN_BODY_MASS_PROFILE = "$MOBILE_BASE/StructureDefinition/grove-mobile-lean-body-mass" + const val MOBILE_MENSTRUATION_FLOW_PROFILE = "$MOBILE_BASE/StructureDefinition/grove-mobile-menstruation-flow" + const val MOBILE_MINDFULNESS_SESSION_PROFILE = "$MOBILE_BASE/StructureDefinition/grove-mobile-mindfulness-session" + const val MOBILE_OVULATION_TEST_RESULT_PROFILE = + "$MOBILE_BASE/StructureDefinition/grove-mobile-ovulation-test-result" + const val MOBILE_OXYGEN_SATURATION_PROFILE = "$MOBILE_BASE/StructureDefinition/grove-mobile-oxygen-saturation" + const val MOBILE_POWER_PROFILE = "$MOBILE_BASE/StructureDefinition/grove-mobile-power" + const val MOBILE_RESPIRATORY_RATE_PROFILE = "$MOBILE_BASE/StructureDefinition/grove-mobile-respiratory-rate" + const val MOBILE_RESTING_HEART_RATE_PROFILE = "$MOBILE_BASE/StructureDefinition/grove-mobile-resting-heart-rate" + const val MOBILE_SEXUAL_ACTIVITY_PROFILE = "$MOBILE_BASE/StructureDefinition/grove-mobile-sexual-activity" + const val MOBILE_SKIN_TEMPERATURE_PROFILE = "$MOBILE_BASE/StructureDefinition/grove-mobile-skin-temperature" + const val MOBILE_SLEEP_DURATION_PROFILE = "$MOBILE_BASE/StructureDefinition/grove-mobile-sleep-duration" + const val MOBILE_SLEEP_STAGE_PROFILE = "$MOBILE_BASE/StructureDefinition/grove-mobile-sleep-stage" + const val MOBILE_SPEED_PROFILE = "$MOBILE_BASE/StructureDefinition/grove-mobile-speed" + const val MOBILE_STEP_COUNT_PROFILE = "$MOBILE_BASE/StructureDefinition/grove-mobile-step-count" + const val MOBILE_VO2_MAX_PROFILE = "$MOBILE_BASE/StructureDefinition/grove-mobile-vo2-max" + const val MOBILE_WHEELCHAIR_PUSH_COUNT_PROFILE = + "$MOBILE_BASE/StructureDefinition/grove-mobile-wheelchair-push-count" + const val MOBILE_WORKOUT_PROFILE = "$MOBILE_BASE/StructureDefinition/grove-mobile-workout" + const val MOBILE_WORKOUT_SEGMENT_PROFILE = "$MOBILE_BASE/StructureDefinition/grove-mobile-workout-segment" + + const val HEALTH_CONNECT_BASAL_METABOLIC_RATE_PROFILE = + "$HEALTH_CONNECT_BASE/StructureDefinition/health-connect-basal-metabolic-rate" + const val HEALTH_CONNECT_WHOLE_BLOOD_GLUCOSE_PROFILE = + "$HEALTH_CONNECT_BASE/StructureDefinition/health-connect-whole-blood-glucose" + const val HEALTH_CONNECT_CAPILLARY_BLOOD_GLUCOSE_PROFILE = + "$HEALTH_CONNECT_BASE/StructureDefinition/health-connect-capillary-blood-glucose" + const val HEALTH_CONNECT_DIETARY_ENERGY_FROM_FAT_PROFILE = + "$HEALTH_CONNECT_BASE/StructureDefinition/health-connect-dietary-energy-from-fat" + const val HEALTH_CONNECT_DIETARY_FAT_TRANS_PROFILE = + "$HEALTH_CONNECT_BASE/StructureDefinition/health-connect-dietary-fat-trans" + const val HEALTH_CONNECT_DIETARY_FAT_UNSATURATED_PROFILE = + "$HEALTH_CONNECT_BASE/StructureDefinition/health-connect-dietary-fat-unsaturated" + const val HEALTH_CONNECT_DIETARY_FOLIC_ACID_PROFILE = + "$HEALTH_CONNECT_BASE/StructureDefinition/health-connect-dietary-folic-acid" + const val HEALTH_CONNECT_ELEVATION_GAINED_PROFILE = + "$HEALTH_CONNECT_BASE/StructureDefinition/health-connect-elevation-gained" + const val HEALTH_CONNECT_INTERSTITIAL_GLUCOSE_PROFILE = + "$HEALTH_CONNECT_BASE/StructureDefinition/health-connect-interstitial-glucose" + const val HEALTH_CONNECT_MENSTRUATION_PERIOD_PROFILE = + "$HEALTH_CONNECT_BASE/StructureDefinition/health-connect-menstruation-period" + const val HEALTH_CONNECT_SERUM_PLASMA_GLUCOSE_PROFILE = + "$HEALTH_CONNECT_BASE/StructureDefinition/health-connect-serum-plasma-glucose" + const val HEALTH_CONNECT_STEP_CADENCE_PROFILE = + "$HEALTH_CONNECT_BASE/StructureDefinition/health-connect-step-cadence" + const val HEALTH_CONNECT_TOTAL_ENERGY_PROFILE = + "$HEALTH_CONNECT_BASE/StructureDefinition/health-connect-total-energy" + + /** Every dietary nutrient the adapter emits, keyed by its catalog measurement id. */ + val mobileDietaryProfiles: Map = mapOf( + "dietary-biotin" to "$MOBILE_BASE/StructureDefinition/grove-mobile-dietary-biotin", + "dietary-caffeine" to "$MOBILE_BASE/StructureDefinition/grove-mobile-dietary-caffeine", + "dietary-calcium" to "$MOBILE_BASE/StructureDefinition/grove-mobile-dietary-calcium", + "dietary-carbohydrates" to "$MOBILE_BASE/StructureDefinition/grove-mobile-dietary-carbohydrates", + "dietary-chloride" to "$MOBILE_BASE/StructureDefinition/grove-mobile-dietary-chloride", + "dietary-cholesterol" to "$MOBILE_BASE/StructureDefinition/grove-mobile-dietary-cholesterol", + "dietary-chromium" to "$MOBILE_BASE/StructureDefinition/grove-mobile-dietary-chromium", + "dietary-copper" to "$MOBILE_BASE/StructureDefinition/grove-mobile-dietary-copper", + "dietary-energy" to "$MOBILE_BASE/StructureDefinition/grove-mobile-dietary-energy", + "dietary-fat-monounsaturated" to "$MOBILE_BASE/StructureDefinition/grove-mobile-dietary-fat-monounsaturated", + "dietary-fat-polyunsaturated" to "$MOBILE_BASE/StructureDefinition/grove-mobile-dietary-fat-polyunsaturated", + "dietary-fat-saturated" to "$MOBILE_BASE/StructureDefinition/grove-mobile-dietary-fat-saturated", + "dietary-fat-total" to "$MOBILE_BASE/StructureDefinition/grove-mobile-dietary-fat-total", + "dietary-fiber" to "$MOBILE_BASE/StructureDefinition/grove-mobile-dietary-fiber", + "dietary-folate" to "$MOBILE_BASE/StructureDefinition/grove-mobile-dietary-folate", + "dietary-iodine" to "$MOBILE_BASE/StructureDefinition/grove-mobile-dietary-iodine", + "dietary-iron" to "$MOBILE_BASE/StructureDefinition/grove-mobile-dietary-iron", + "dietary-magnesium" to "$MOBILE_BASE/StructureDefinition/grove-mobile-dietary-magnesium", + "dietary-manganese" to "$MOBILE_BASE/StructureDefinition/grove-mobile-dietary-manganese", + "dietary-molybdenum" to "$MOBILE_BASE/StructureDefinition/grove-mobile-dietary-molybdenum", + "dietary-niacin" to "$MOBILE_BASE/StructureDefinition/grove-mobile-dietary-niacin", + "dietary-pantothenic-acid" to "$MOBILE_BASE/StructureDefinition/grove-mobile-dietary-pantothenic-acid", + "dietary-phosphorus" to "$MOBILE_BASE/StructureDefinition/grove-mobile-dietary-phosphorus", + "dietary-potassium" to "$MOBILE_BASE/StructureDefinition/grove-mobile-dietary-potassium", + "dietary-protein" to "$MOBILE_BASE/StructureDefinition/grove-mobile-dietary-protein", + "dietary-riboflavin" to "$MOBILE_BASE/StructureDefinition/grove-mobile-dietary-riboflavin", + "dietary-selenium" to "$MOBILE_BASE/StructureDefinition/grove-mobile-dietary-selenium", + "dietary-sodium" to "$MOBILE_BASE/StructureDefinition/grove-mobile-dietary-sodium", + "dietary-sugar" to "$MOBILE_BASE/StructureDefinition/grove-mobile-dietary-sugar", + "dietary-thiamin" to "$MOBILE_BASE/StructureDefinition/grove-mobile-dietary-thiamin", + "dietary-vitamin-a" to "$MOBILE_BASE/StructureDefinition/grove-mobile-dietary-vitamin-a", + "dietary-vitamin-b12" to "$MOBILE_BASE/StructureDefinition/grove-mobile-dietary-vitamin-b12", + "dietary-vitamin-b6" to "$MOBILE_BASE/StructureDefinition/grove-mobile-dietary-vitamin-b6", + "dietary-vitamin-c" to "$MOBILE_BASE/StructureDefinition/grove-mobile-dietary-vitamin-c", + "dietary-vitamin-d" to "$MOBILE_BASE/StructureDefinition/grove-mobile-dietary-vitamin-d", + "dietary-vitamin-e" to "$MOBILE_BASE/StructureDefinition/grove-mobile-dietary-vitamin-e", + "dietary-vitamin-k" to "$MOBILE_BASE/StructureDefinition/grove-mobile-dietary-vitamin-k", + "dietary-zinc" to "$MOBILE_BASE/StructureDefinition/grove-mobile-dietary-zinc", + ) + + /** Profiles the shared Mobile package owns, which this adapter also claims. */ + val sharedMeasurementProfiles: Set = setOf( + MOBILE_ACTIVE_ENERGY_PROFILE, + MOBILE_BASAL_BODY_TEMPERATURE_PROFILE, + MOBILE_BLOOD_PRESSURE_PROFILE, + MOBILE_BODY_FAT_PERCENTAGE_PROFILE, + MOBILE_BODY_HEIGHT_PROFILE, + MOBILE_BODY_TEMPERATURE_PROFILE, + MOBILE_BODY_WATER_MASS_PROFILE, + MOBILE_BODY_WEIGHT_PROFILE, + MOBILE_BONE_MASS_PROFILE, + MOBILE_CERVICAL_MUCUS_QUALITY_PROFILE, + MOBILE_CYCLING_CADENCE_PROFILE, + MOBILE_DISTANCE_PROFILE, + MOBILE_FLIGHTS_CLIMBED_PROFILE, + MOBILE_FLUID_INTAKE_PROFILE, + MOBILE_HEART_RATE_PROFILE, + MOBILE_HEART_RATE_VARIABILITY_RMSSD_PROFILE, + MOBILE_INTERMENSTRUAL_BLEEDING_PROFILE, + MOBILE_LEAN_BODY_MASS_PROFILE, + MOBILE_MENSTRUATION_FLOW_PROFILE, + MOBILE_MINDFULNESS_SESSION_PROFILE, + MOBILE_OVULATION_TEST_RESULT_PROFILE, + MOBILE_OXYGEN_SATURATION_PROFILE, + MOBILE_POWER_PROFILE, + MOBILE_RESPIRATORY_RATE_PROFILE, + MOBILE_RESTING_HEART_RATE_PROFILE, + MOBILE_SEXUAL_ACTIVITY_PROFILE, + MOBILE_SKIN_TEMPERATURE_PROFILE, + MOBILE_SLEEP_DURATION_PROFILE, + MOBILE_SLEEP_STAGE_PROFILE, + MOBILE_SPEED_PROFILE, + MOBILE_STEP_COUNT_PROFILE, + MOBILE_VO2_MAX_PROFILE, + MOBILE_WHEELCHAIR_PUSH_COUNT_PROFILE, + MOBILE_WORKOUT_PROFILE, + MOBILE_WORKOUT_SEGMENT_PROFILE, + ) + mobileDietaryProfiles.values + + /** Profiles only this adapter publishes. */ + val adapterSpecificObservationProfiles: Set = setOf( + HEALTH_CONNECT_BASAL_METABOLIC_RATE_PROFILE, + HEALTH_CONNECT_WHOLE_BLOOD_GLUCOSE_PROFILE, + HEALTH_CONNECT_CAPILLARY_BLOOD_GLUCOSE_PROFILE, + HEALTH_CONNECT_DIETARY_ENERGY_FROM_FAT_PROFILE, + HEALTH_CONNECT_DIETARY_FAT_TRANS_PROFILE, + HEALTH_CONNECT_DIETARY_FAT_UNSATURATED_PROFILE, + HEALTH_CONNECT_DIETARY_FOLIC_ACID_PROFILE, + HEALTH_CONNECT_ELEVATION_GAINED_PROFILE, + HEALTH_CONNECT_INTERSTITIAL_GLUCOSE_PROFILE, + HEALTH_CONNECT_MENSTRUATION_PERIOD_PROFILE, + HEALTH_CONNECT_SERUM_PLASMA_GLUCOSE_PROFILE, + HEALTH_CONNECT_STEP_CADENCE_PROFILE, + HEALTH_CONNECT_TOTAL_ENERGY_PROFILE, + ) + + /** Exact direct profile for every protocol-declared adapter-only active output type. */ + internal val adapterOnlyOutputProfiles: Map = mapOf( + "MedicationAdministration" to + "https://grovealliance.org/fhir/healthkit/StructureDefinition/healthkit-medication-dose-event", + "MedicationStatement" to + "https://grovealliance.org/fhir/healthkit/StructureDefinition/healthkit-user-annotated-medication", + "Specimen" to + "https://grovealliance.org/fhir/health-connect/StructureDefinition/health-connect-specimen", + "VisionPrescription" to + "https://grovealliance.org/fhir/healthkit/StructureDefinition/healthkit-vision-prescription", + ) + + /** Closed resource types admitted in a Mobile active event graph. */ + internal val activeOutputResourceTypes: Set = setOf( + "Observation", + "DocumentReference", + "Specimen", + "VisionPrescription", + "MedicationAdministration", + "MedicationStatement", + ) + internal val activeSupportingResourceTypes: Set = setOf( + "Patient", + "Device", + "ResearchStudy", + "ResearchSubject", + "PlanDefinition", + "QuestionnaireResponse", + ) + const val ACTIVE_LIFECYCLE_RESOURCE_TYPE = "Provenance" + + /** Catalog-priority business Identifier used as each exchange entry key. */ + internal val entryIdentifierPriority: List = listOf( + GroveIdentifierRole.SOURCE_OUTPUT, + GroveIdentifierRole.SOURCE_ARTIFACT, + GroveIdentifierRole.SOURCE_RECORD, + GroveIdentifierRole.WRITER_RECORD, + GroveIdentifierRole.DEVICE_SNAPSHOT, + GroveIdentifierRole.RECORDING_DEVICE, + ) + + /** Exact Device profile mode to its exact typed Grove identifier roles. */ + internal val activeDeviceProfileClaims: Map> = mapOf( + "https://grovealliance.org/fhir/healthkit/StructureDefinition/healthkit-application-device" to + setOf( + GroveIdentifierRole.DEVICE_SNAPSHOT, + ), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-application-device" to + setOf( + GroveIdentifierRole.DEVICE_SNAPSHOT, + ), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-host-device" to + setOf( + GroveIdentifierRole.DEVICE_SNAPSHOT, + ), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-recording-device" to + setOf( + GroveIdentifierRole.RECORDING_DEVICE, + GroveIdentifierRole.DEVICE_SNAPSHOT, + ), + ) + + /** Exact DocumentReference profile mode to its required Grove identifier roles. */ + internal val activeDocumentProfileClaims: Map, Set> = mapOf( + setOf( + "https://grovealliance.org/fhir/healthkit/StructureDefinition/healthkit-clinical-record-document", + ) to setOf( + GroveIdentifierRole.SOURCE_RECORD, + GroveIdentifierRole.SOURCE_OUTPUT, + GroveIdentifierRole.SOURCE_ARTIFACT, + ), + setOf( + "https://grovealliance.org/fhir/healthkit/StructureDefinition/healthkit-recording-document", + "https://grovealliance.org/fhir/sensor/StructureDefinition/grove-sensor-recording-document", + ) to setOf( + GroveIdentifierRole.SOURCE_RECORD, + GroveIdentifierRole.SOURCE_OUTPUT, + GroveIdentifierRole.SOURCE_ARTIFACT, + ), + setOf( + "https://grovealliance.org/fhir/providers/StructureDefinition/providers-recording-document", + "https://grovealliance.org/fhir/sensor/StructureDefinition/grove-sensor-recording-document", + ) to setOf( + GroveIdentifierRole.SOURCE_RECORD, + GroveIdentifierRole.SOURCE_OUTPUT, + GroveIdentifierRole.SOURCE_ARTIFACT, + ), + setOf( + "https://grovealliance.org/fhir/sensor/StructureDefinition/grove-sensor-recording-document", + ) to setOf( + GroveIdentifierRole.SOURCE_RECORD, + GroveIdentifierRole.SOURCE_OUTPUT, + GroveIdentifierRole.SOURCE_ARTIFACT, + ), + setOf( + "https://grovealliance.org/fhir/sensor/StructureDefinition/grove-sensor-recording-document", + "https://grovealliance.org/fhir/sensorkit/StructureDefinition/sensorkit-recording-document", + ) to setOf( + GroveIdentifierRole.SOURCE_RECORD, + GroveIdentifierRole.SOURCE_OUTPUT, + GroveIdentifierRole.SOURCE_ARTIFACT, + ), + ) + const val ACTIVE_QUESTIONNAIRE_RESPONSE_PROFILE = + "https://grovealliance.org/fhir/questionnaire/StructureDefinition/grove-questionnaire-response" + + /** Exact retraction target role, Identifier role, and resource-type closure. */ + internal val retractionTargetClaims: Map = mapOf( + HealthConnectRetractionTargetRole.CHILD_OUTPUT to GroveRetractionTargetClaim( + identifierRole = GroveIdentifierRole.SOURCE_OUTPUT, + resourceTypes = setOf( + "Observation", + ), + ), + HealthConnectRetractionTargetRole.DEVICE_SNAPSHOT to GroveRetractionTargetClaim( + identifierRole = GroveIdentifierRole.DEVICE_SNAPSHOT, + resourceTypes = setOf( + "Device", + ), + ), + HealthConnectRetractionTargetRole.PRIMARY_OUTPUT to GroveRetractionTargetClaim( + identifierRole = GroveIdentifierRole.SOURCE_OUTPUT, + resourceTypes = setOf( + "Observation", + "VisionPrescription", + "MedicationAdministration", + "MedicationStatement", + ), + ), + HealthConnectRetractionTargetRole.SOURCE_ARTIFACT to GroveRetractionTargetClaim( + identifierRole = GroveIdentifierRole.SOURCE_OUTPUT, + resourceTypes = setOf( + "DocumentReference", + ), + ), + HealthConnectRetractionTargetRole.SPECIMEN to GroveRetractionTargetClaim( + identifierRole = GroveIdentifierRole.SOURCE_OUTPUT, + resourceTypes = setOf( + "Specimen", + ), + ), + ) + + /** Exact Mobile or Health Connect profiles admitted on the active lifecycle Provenance. */ + internal val activeConversionProvenanceProfiles: Set = setOf( + "https://grovealliance.org/fhir/health-connect/StructureDefinition/health-connect-conversion-provenance", + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-conversion-provenance", + ) + + /** Health Connect Observation profiles whose complete claim is one direct profile. */ + internal val activeHealthConnectExclusiveObservationProfiles: Set = setOf( + "https://grovealliance.org/fhir/health-connect/StructureDefinition/health-connect-capillary-blood-glucose", + "https://grovealliance.org/fhir/health-connect/StructureDefinition/health-connect-interstitial-glucose", + "https://grovealliance.org/fhir/health-connect/StructureDefinition/health-connect-serum-plasma-glucose", + "https://grovealliance.org/fhir/health-connect/StructureDefinition/health-connect-whole-blood-glucose", + ) + + /** Fixed Quantity system/code pairs keyed by a produced semantic profile. */ + internal val quantitySemanticsByProfile: Map = mapOf( + "https://grovealliance.org/fhir/health-connect/StructureDefinition/health-connect-basal-metabolic-rate" to + QuantitySemantics("http://unitsofmeasure.org", "kcal/d"), + "https://grovealliance.org/fhir/health-connect/StructureDefinition/health-connect-capillary-blood-glucose" to + QuantitySemantics("http://unitsofmeasure.org", "mg/dL"), + "https://grovealliance.org/fhir/health-connect/StructureDefinition/health-connect-dietary-energy-from-fat" to + QuantitySemantics("http://unitsofmeasure.org", "kcal"), + "https://grovealliance.org/fhir/health-connect/StructureDefinition/health-connect-dietary-fat-trans" to + QuantitySemantics("http://unitsofmeasure.org", "g"), + "https://grovealliance.org/fhir/health-connect/StructureDefinition/health-connect-dietary-fat-unsaturated" to + QuantitySemantics("http://unitsofmeasure.org", "g"), + "https://grovealliance.org/fhir/health-connect/StructureDefinition/health-connect-dietary-folic-acid" to + QuantitySemantics("http://unitsofmeasure.org", "ug"), + "https://grovealliance.org/fhir/health-connect/StructureDefinition/health-connect-elevation-gained" to + QuantitySemantics("http://unitsofmeasure.org", "m"), + "https://grovealliance.org/fhir/health-connect/StructureDefinition/health-connect-interstitial-glucose" to + QuantitySemantics("http://unitsofmeasure.org", "mg/dL"), + "https://grovealliance.org/fhir/health-connect/StructureDefinition/health-connect-serum-plasma-glucose" to + QuantitySemantics("http://unitsofmeasure.org", "mg/dL"), + "https://grovealliance.org/fhir/health-connect/StructureDefinition/health-connect-step-cadence" to + QuantitySemantics("http://unitsofmeasure.org", "{steps}/min"), + "https://grovealliance.org/fhir/health-connect/StructureDefinition/health-connect-total-energy" to + QuantitySemantics("http://unitsofmeasure.org", "kcal"), + "https://grovealliance.org/fhir/health-connect/StructureDefinition/health-connect-whole-blood-glucose" to + QuantitySemantics("http://unitsofmeasure.org", "mg/dL"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-active-energy" to + QuantitySemantics("http://unitsofmeasure.org", "kcal"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-basal-body-temperature" to + QuantitySemantics("http://unitsofmeasure.org", "Cel"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-body-fat-percentage" to + QuantitySemantics("http://unitsofmeasure.org", "%"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-body-height" to + QuantitySemantics("http://unitsofmeasure.org", "cm"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-body-temperature" to + QuantitySemantics("http://unitsofmeasure.org", "Cel"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-body-water-mass" to + QuantitySemantics("http://unitsofmeasure.org", "kg"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-body-weight" to + QuantitySemantics("http://unitsofmeasure.org", "kg"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-bone-mass" to + QuantitySemantics("http://unitsofmeasure.org", "kg"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-cycling-cadence" to + QuantitySemantics("http://unitsofmeasure.org", "/min"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-dietary-biotin" to + QuantitySemantics("http://unitsofmeasure.org", "ug"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-dietary-caffeine" to + QuantitySemantics("http://unitsofmeasure.org", "mg"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-dietary-calcium" to + QuantitySemantics("http://unitsofmeasure.org", "mg"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-dietary-carbohydrates" to + QuantitySemantics("http://unitsofmeasure.org", "g"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-dietary-chloride" to + QuantitySemantics("http://unitsofmeasure.org", "mg"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-dietary-cholesterol" to + QuantitySemantics("http://unitsofmeasure.org", "mg"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-dietary-chromium" to + QuantitySemantics("http://unitsofmeasure.org", "ug"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-dietary-copper" to + QuantitySemantics("http://unitsofmeasure.org", "ug"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-dietary-energy" to + QuantitySemantics("http://unitsofmeasure.org", "kcal"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-dietary-fat-monounsaturated" to + QuantitySemantics("http://unitsofmeasure.org", "g"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-dietary-fat-polyunsaturated" to + QuantitySemantics("http://unitsofmeasure.org", "g"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-dietary-fat-saturated" to + QuantitySemantics("http://unitsofmeasure.org", "g"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-dietary-fat-total" to + QuantitySemantics("http://unitsofmeasure.org", "g"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-dietary-fiber" to + QuantitySemantics("http://unitsofmeasure.org", "g"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-dietary-folate" to + QuantitySemantics("http://unitsofmeasure.org", "ug"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-dietary-iodine" to + QuantitySemantics("http://unitsofmeasure.org", "ug"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-dietary-iron" to + QuantitySemantics("http://unitsofmeasure.org", "mg"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-dietary-magnesium" to + QuantitySemantics("http://unitsofmeasure.org", "mg"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-dietary-manganese" to + QuantitySemantics("http://unitsofmeasure.org", "mg"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-dietary-molybdenum" to + QuantitySemantics("http://unitsofmeasure.org", "ug"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-dietary-niacin" to + QuantitySemantics("http://unitsofmeasure.org", "mg"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-dietary-pantothenic-acid" to + QuantitySemantics("http://unitsofmeasure.org", "mg"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-dietary-phosphorus" to + QuantitySemantics("http://unitsofmeasure.org", "mg"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-dietary-potassium" to + QuantitySemantics("http://unitsofmeasure.org", "mg"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-dietary-protein" to + QuantitySemantics("http://unitsofmeasure.org", "g"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-dietary-riboflavin" to + QuantitySemantics("http://unitsofmeasure.org", "mg"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-dietary-selenium" to + QuantitySemantics("http://unitsofmeasure.org", "ug"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-dietary-sodium" to + QuantitySemantics("http://unitsofmeasure.org", "mg"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-dietary-sugar" to + QuantitySemantics("http://unitsofmeasure.org", "g"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-dietary-thiamin" to + QuantitySemantics("http://unitsofmeasure.org", "mg"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-dietary-vitamin-a" to + QuantitySemantics("http://unitsofmeasure.org", "ug"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-dietary-vitamin-b12" to + QuantitySemantics("http://unitsofmeasure.org", "ug"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-dietary-vitamin-b6" to + QuantitySemantics("http://unitsofmeasure.org", "mg"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-dietary-vitamin-c" to + QuantitySemantics("http://unitsofmeasure.org", "mg"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-dietary-vitamin-d" to + QuantitySemantics("http://unitsofmeasure.org", "ug"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-dietary-vitamin-e" to + QuantitySemantics("http://unitsofmeasure.org", "mg"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-dietary-vitamin-k" to + QuantitySemantics("http://unitsofmeasure.org", "ug"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-dietary-zinc" to + QuantitySemantics("http://unitsofmeasure.org", "mg"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-distance" to + QuantitySemantics("http://unitsofmeasure.org", "m"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-flights-climbed" to + QuantitySemantics("http://unitsofmeasure.org", "{flights}"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-fluid-intake" to + QuantitySemantics("http://unitsofmeasure.org", "mL"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-heart-rate" to + QuantitySemantics("http://unitsofmeasure.org", "/min"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-heart-rate-variability-rmssd" to + QuantitySemantics("http://unitsofmeasure.org", "ms"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-lean-body-mass" to + QuantitySemantics("http://unitsofmeasure.org", "kg"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-mindfulness-session" to + QuantitySemantics("http://unitsofmeasure.org", "min"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-oxygen-saturation" to + QuantitySemantics("http://unitsofmeasure.org", "%"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-power" to + QuantitySemantics("http://unitsofmeasure.org", "W"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-respiratory-rate" to + QuantitySemantics("http://unitsofmeasure.org", "/min"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-resting-heart-rate" to + QuantitySemantics("http://unitsofmeasure.org", "/min"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-skin-temperature" to + QuantitySemantics("http://unitsofmeasure.org", "Cel"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-sleep-duration" to + QuantitySemantics("http://unitsofmeasure.org", "h"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-speed" to + QuantitySemantics("http://unitsofmeasure.org", "m/s"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-step-count" to + QuantitySemantics("http://unitsofmeasure.org", "{steps}"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-vo2-max" to + QuantitySemantics("http://unitsofmeasure.org", "mL/kg/min"), + "https://grovealliance.org/fhir/mobile/StructureDefinition/grove-mobile-wheelchair-push-count" to + QuantitySemantics("http://unitsofmeasure.org", "{pushes}"), + ) + + /** Catalog-defined representational Quantity domains used by this adapter. */ + internal val quantityValueDomains: Map = mapOf( + "body-fat-percentage" to QuantityValueDomain( + minimum = java.math.BigDecimal("0"), + maximum = java.math.BigDecimal("100"), + integerOnly = false, + ), + "flights-climbed" to QuantityValueDomain( + minimum = java.math.BigDecimal("0"), + maximum = null, + integerOnly = true, + ), + "oxygen-saturation" to QuantityValueDomain( + minimum = java.math.BigDecimal("0"), + maximum = java.math.BigDecimal("100"), + integerOnly = false, + ), + "step-count" to QuantityValueDomain( + minimum = java.math.BigDecimal("0"), + maximum = null, + integerOnly = true, + ), + "wheelchair-push-count" to QuantityValueDomain( + minimum = java.math.BigDecimal("0"), + maximum = null, + integerOnly = true, + ), + ) + + const val ANDROID_PACKAGE_IDENTIFIER = + "https://grovealliance.org/fhir/health-connect/NamingSystem/android-package-name" + + const val WRITER_RECORD_VERSION = "$MOBILE_BASE/StructureDefinition/grove-writer-record-version" + const val RECORDING_METHOD_EXTENSION = "$MOBILE_BASE/StructureDefinition/grove-recording-method" + const val HEALTH_CONNECT_SESSION_TITLE = "$HEALTH_CONNECT_BASE/StructureDefinition/health-connect-session-title" + const val HEALTH_CONNECT_GLUCOSE_MEAL_CONTEXT = + "$HEALTH_CONNECT_BASE/StructureDefinition/health-connect-glucose-meal-context" + const val HEALTH_CONNECT_RECORD_TYPE_EXTENSION = + "$HEALTH_CONNECT_BASE/StructureDefinition/health-connect-record-type" + const val GROVE_EXCHANGE_ENTRY_NODE_KEY = "$MOBILE_BASE/StructureDefinition/grove-exchange-entry-node-key" + const val GROVE_RETRACTION_TARGET_ROLE = "$MOBILE_BASE/StructureDefinition/grove-retraction-target-role" + + const val GROVE_AGGREGATION_METHOD = "$MOBILE_BASE/CodeSystem/grove-aggregation-method" + const val GROVE_APPLICATION_VERSION_TYPE = "$MOBILE_BASE/CodeSystem/grove-application-version-type" + const val GROVE_CERVICAL_MUCUS_QUALITY = "$MOBILE_BASE/CodeSystem/grove-cervical-mucus-quality" + const val GROVE_CERVICAL_MUCUS_SENSATION = "$MOBILE_BASE/CodeSystem/grove-cervical-mucus-sensation" + const val GROVE_IDENTIFIER_ROLE = "$MOBILE_BASE/CodeSystem/grove-identifier-role" + const val GROVE_INTERMENSTRUAL_BLEEDING = "$MOBILE_BASE/CodeSystem/grove-intermenstrual-bleeding" + const val GROVE_LIFECYCLE_EVENT = "$MOBILE_BASE/CodeSystem/grove-lifecycle-event" + const val GROVE_MENSTRUAL_CYCLE_START = "$MOBILE_BASE/CodeSystem/grove-menstrual-cycle-start" + const val GROVE_MENSTRUATION_FLOW = "$MOBILE_BASE/CodeSystem/grove-menstruation-flow" + const val GROVE_MOBILE_MEASUREMENT = "$MOBILE_BASE/CodeSystem/grove-mobile-measurement" + const val GROVE_OVULATION_TEST_RESULT = "$MOBILE_BASE/CodeSystem/grove-ovulation-test-result" + const val GROVE_RECORDING_METHOD = "$MOBILE_BASE/CodeSystem/grove-recording-method" + const val GROVE_RETRACTION_TARGET_ROLE_CS = "$MOBILE_BASE/CodeSystem/grove-retraction-target-role" + const val GROVE_SEXUAL_ACTIVITY = "$MOBILE_BASE/CodeSystem/grove-sexual-activity" + const val GROVE_SLEEP_STAGE = "$MOBILE_BASE/CodeSystem/grove-sleep-stage" + const val GROVE_WORKOUT_ACTIVITY = "$MOBILE_BASE/CodeSystem/grove-workout-activity" + const val GROVE_WORKOUT_SEGMENT_TYPE = "$MOBILE_BASE/CodeSystem/grove-workout-segment-type" + const val GROVE_WORKOUT_STATISTIC = "$MOBILE_BASE/CodeSystem/grove-workout-statistic" + const val HEALTH_CONNECT_CERVICAL_MUCUS_APPEARANCE = + "$HEALTH_CONNECT_BASE/CodeSystem/health-connect-cervical-mucus-appearance" + const val HEALTH_CONNECT_CERVICAL_MUCUS_SENSATION = + "$HEALTH_CONNECT_BASE/CodeSystem/health-connect-cervical-mucus-sensation" + const val HEALTH_CONNECT_CONCEPT_PROPERTY = "$HEALTH_CONNECT_BASE/CodeSystem/health-connect-concept-property" + const val HEALTH_CONNECT_EXERCISE_SEGMENT_TYPE = + "$HEALTH_CONNECT_BASE/CodeSystem/health-connect-exercise-segment-type" + const val HEALTH_CONNECT_EXERCISE_TYPE = "$HEALTH_CONNECT_BASE/CodeSystem/health-connect-exercise-type" + const val HEALTH_CONNECT_MEAL_TYPE = "$HEALTH_CONNECT_BASE/CodeSystem/health-connect-meal-type" + const val HEALTH_CONNECT_MEASUREMENT = "$HEALTH_CONNECT_BASE/CodeSystem/health-connect-measurement" + const val HEALTH_CONNECT_MENSTRUATION_FLOW = "$HEALTH_CONNECT_BASE/CodeSystem/health-connect-menstruation-flow" + const val HEALTH_CONNECT_MENSTRUATION_PERIOD = "$HEALTH_CONNECT_BASE/CodeSystem/health-connect-menstruation-period" + const val HEALTH_CONNECT_MINDFULNESS_SESSION_TYPE = + "$HEALTH_CONNECT_BASE/CodeSystem/health-connect-mindfulness-session-type" + const val HEALTH_CONNECT_OVULATION_TEST_RESULT = + "$HEALTH_CONNECT_BASE/CodeSystem/health-connect-ovulation-test-result" + const val HEALTH_CONNECT_RECORD_TYPE = "$HEALTH_CONNECT_BASE/CodeSystem/health-connect-record-type" + const val HEALTH_CONNECT_RELATION_TO_MEAL = "$HEALTH_CONNECT_BASE/CodeSystem/health-connect-relation-to-meal" + const val HEALTH_CONNECT_SEXUAL_ACTIVITY_PROTECTION = + "$HEALTH_CONNECT_BASE/CodeSystem/health-connect-sexual-activity-protection" + const val HEALTH_CONNECT_SLEEP_STAGE = "$HEALTH_CONNECT_BASE/CodeSystem/health-connect-sleep-stage" + const val HEALTH_CONNECT_VO2_MAX_MEASUREMENT_METHOD = + "$HEALTH_CONNECT_BASE/CodeSystem/health-connect-vo2-max-measurement-method" + + const val OBSERVATION_BODY_POSITION = "http://hl7.org/fhir/StructureDefinition/observation-bodyPosition" + const val LOINC = "http://loinc.org" + const val SNOMED_CT = "http://snomed.info/sct" + const val UCUM = "http://unitsofmeasure.org" + const val MDC = "urn:iso:std:iso:11073:10101" + + /** The ISO 11073 MDC software-revision code. */ + const val APPLICATION_SOFTWARE_VERSION = "531975" + const val OBSERVATION_CATEGORY = "http://terminology.hl7.org/CodeSystem/observation-category" + const val RECORD_LIFECYCLE = "http://terminology.hl7.org/CodeSystem/iso-21089-lifecycle" + const val PROVENANCE_PARTICIPANT = "http://terminology.hl7.org/CodeSystem/provenance-participant-type" + const val RESEARCH_STUDY_EXTENSION = "http://hl7.org/fhir/StructureDefinition/workflow-researchStudy" +} diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectConversion.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectConversion.kt new file mode 100644 index 00000000..2a2dd56d --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectConversion.kt @@ -0,0 +1,342 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.Identifier +import org.hl7.fhir.r4.model.Observation +import org.hl7.fhir.r4.model.Provenance +import org.hl7.fhir.r4.model.Specimen +import java.time.Instant + +/** + * The resources and synchronization facts derived from one Health Connect record. + * + * HAPI FHIR model objects are mutable. This result owns defensive snapshots and returns fresh + * copies from its public accessors, so a caller cannot invalidate a graph after its identities, + * profile claims, and references have been checked. + */ +@Suppress("LongParameterList") +class HealthConnectConversion internal constructor( + val conversionContractVersion: String, + sourceRecordIdentifier: Identifier, + val sourceRecordType: String, + val sourceLastModified: Instant, + observations: List, + provenance: Provenance?, + bundle: Bundle, +) { + private val sourceRecordIdentifierSnapshot = sourceRecordIdentifier.copy() + private val observationSnapshots = observations.map(Observation::copy) + private val provenanceSnapshot = provenance?.copy() + private val bundleSnapshot = bundle.copy() + + val sourceRecordIdentifier: Identifier + get() = sourceRecordIdentifierSnapshot.copy() + + val observations: List + get() = observationSnapshots.map(Observation::copy) + + val provenance: Provenance? + get() = provenanceSnapshot?.copy() + + val bundle: Bundle + get() = bundleSnapshot.copy() + + init { + require(conversionContractVersion.isNotBlank()) { + "The conversion-contract version must not be blank." + } + require( + sourceRecordIdentifierSnapshot.hasSystem() && + sourceRecordIdentifierSnapshot.hasValue() && + sourceRecordIdentifierSnapshot.hasGroveRole(GroveIdentifierRole.SOURCE_RECORD), + ) { "The source record identifier must contain a complete typed source-record pair." } + require(sourceRecordType.isNotBlank()) { "The source record type must not be blank." } + require( + observationSnapshots.isNotEmpty() || + sourceRecordType in HealthConnectCatalog.zeroOutputRecordTypeIdentifiers, + ) { + "Only an admitted zero-output source type may have a successful zero-output conversion." + } + require((observationSnapshots.isEmpty()) == (provenanceSnapshot == null)) { + "Zero-output conversions omit Provenance; conversions with outputs require it." + } + require( + observationSnapshots.all { observation -> + observation.identifier.count { + it.hasGroveRole(GroveIdentifierRole.SOURCE_RECORD) && it.hasSystem() && it.hasValue() + } == 1 && + observation.identifier.count { + it.hasGroveRole(GroveIdentifierRole.SOURCE_OUTPUT) && it.hasSystem() && it.hasValue() + } == 1 + }, + ) { "Every converted Observation must contain one exact source and one exact output identifier." } + require( + observationSnapshots.all { observation -> + observation.identifier.single { + it.hasGroveRole(GroveIdentifierRole.SOURCE_RECORD) + }.let { + it.system == sourceRecordIdentifierSnapshot.system && + it.value == sourceRecordIdentifierSnapshot.value + } + }, + ) { "Every converted Observation must identify its exact source Record." } + require( + observationSnapshots.all { observation -> + !observation.hasIdElement() && + observation.meta.profile.map { it.value }.let { profiles -> + val sharedAndAdapter = profiles.size == 2 && + profiles[0] in HealthConnectContract.sharedMeasurementProfiles && + profiles[1] == HealthConnectContract.HEALTH_CONNECT_OBSERVATION_PROFILE + val adapterSpecific = profiles.size == 1 && + profiles.single() in HealthConnectContract.adapterSpecificObservationProfiles + sharedAndAdapter || adapterSpecific + } + }, + ) { + "Every output must omit producer-derived Resource.id and use its exact admitted profile-claim mode." + } + require( + observationSnapshots.all { observation -> + observation.getExtensionsByUrl( + HealthConnectContract.HEALTH_CONNECT_RECORD_TYPE_EXTENSION, + ).singleOrNull()?.value?.primitiveValue() == sourceRecordType + }, + ) { + "Every output must preserve its exact AndroidX Health Connect Record class as typed lineage." + } + require( + observationIdentifiers.map { "${it.system}|${it.value}" }.distinct().size == + observationSnapshots.size, + ) { + "Every converted Observation must have a distinct output identifier." + } + require(bundleSnapshot.type == Bundle.BundleType.COLLECTION && bundleSnapshot.entry.isNotEmpty()) { + "A conversion must contain a non-empty collection Bundle." + } + require(!bundleSnapshot.hasIdElement()) { "The producer must not assign a Bundle Resource.id." } + // The exchange Bundle is created by the export, so it is named in the deployment's own + // namespace rather than one this guide owns; only its role-suffixed shape is fixed here. + require( + bundleSnapshot.identifier.let { + it.hasSystem() && it.hasValue() && it.hasGroveRole(GroveIdentifierRole.EVENT) && + EVENT_IDENTITY_VALUE.matches(it.value) + }, + ) { "The Bundle must carry one deployment-namespaced exchange-bundle business identifier." } + require( + bundleSnapshot.meta.profile.map { it.value } == + listOf(HealthConnectContract.MOBILE_EXCHANGE_BUNDLE_PROFILE), + ) { "The collection Bundle must claim only the Grove Mobile exchange profile directly." } + require(bundleSnapshot.entry.all { it.hasFullUrl() && it.hasResource() }) { + "Every collection Bundle entry must contain a fullUrl and resource." + } + require( + bundleSnapshot.entry.map { it.fullUrl }.distinct().size == bundleSnapshot.entry.size, + ) { + "Bundle fullUrl values must be unique." + } + bundleSnapshot.requireGroveEntryIdentitySelection() + bundleSnapshot.requireGroveReferencePolicy() + require( + bundleSnapshot.entry.all { entry -> + val device = entry.resource as? org.hl7.fhir.r4.model.Device ?: return@all true + val entryIdentifier = ( + entry.extension.single { + it.url == GroveExchangeIdentity.ENTRY_IDENTIFIER_EXTENSION + }.value as Identifier + ) + when { + device.meta.profile.any { + it.value == HealthConnectContract.MOBILE_APPLICATION_DEVICE_PROFILE + } -> device.identifier.count { + it.hasGroveRole(GroveIdentifierRole.DEVICE_SNAPSHOT) + } == 1 && device.identifier.single { + it.hasGroveRole(GroveIdentifierRole.DEVICE_SNAPSHOT) + }.samePair(entryIdentifier) + device.meta.profile.any { + it.value == HealthConnectContract.MOBILE_HOST_DEVICE_PROFILE + } -> device.identifier.size == 1 && device.identifier.singleOrNull { + it.hasGroveRole(GroveIdentifierRole.DEVICE_SNAPSHOT) + }?.samePair(entryIdentifier) == true + device.meta.profile.any { + it.value == HealthConnectContract.MOBILE_RECORDING_DEVICE_PROFILE + } -> device.identifier.size == 2 && + device.identifier.count { + it.hasGroveRole(GroveIdentifierRole.RECORDING_DEVICE) + } == 1 && device.identifier.singleOrNull { + it.hasGroveRole(GroveIdentifierRole.DEVICE_SNAPSHOT) + }?.samePair(entryIdentifier) == true + else -> true + } + }, + ) { + "Grove Devices must expose their exact closed typed identities and select the event snapshot as entry key." + } + val bundledObservations = bundleSnapshot.entry.mapNotNull { it.resource as? Observation } + require( + bundledObservations.size == observationSnapshots.size && + bundledObservations.sortedBy(::completeOutputIdentifierKey) + .zip(observationSnapshots.sortedBy(::completeOutputIdentifierKey)) + .all { (bundled, converted) -> bundled.equalsDeep(converted) }, + ) { "The Bundle Observation set must exactly match the converted output set." } + val specimenEntries = bundleSnapshot.entry.filter { it.resource is Specimen } + require( + specimenEntries.all { entry -> + val specimen = entry.resource as Specimen + val sourceIdentifier = specimen.identifier.singleOrNull { + it.hasGroveRole(GroveIdentifierRole.SOURCE_RECORD) + } + val outputIdentifier = specimen.identifier.singleOrNull { + it.hasGroveRole(GroveIdentifierRole.SOURCE_OUTPUT) + } + val entryIdentifier = ( + entry.extension.single { + it.url == GroveExchangeIdentity.ENTRY_IDENTIFIER_EXTENSION + }.value as Identifier + ) + specimen.hasIdElement().not() && + specimen.meta.profile.map { it.value } == + listOf(HealthConnectContract.HEALTH_CONNECT_SPECIMEN_PROFILE) && + specimen.identifier.size == 2 && + sourceIdentifier?.samePair(sourceRecordIdentifierSnapshot) == true && + outputIdentifier?.samePair(entryIdentifier) == true + }, + ) { + "Every Health Connect Specimen must contain exactly its typed source and specimen-output identities." + } + val specimenReferences = bundledObservations + .filter(Observation::hasSpecimen) + .map { it.specimen.reference } + require( + specimenReferences.size == specimenEntries.size && + specimenReferences.toSet() == specimenEntries.map { it.fullUrl }.toSet() && + (sourceRecordType == "BloodGlucoseRecord") == (specimenEntries.size == 1), + ) { + "A supported BloodGlucoseRecord must have one referenced Specimen and no other conversion may emit one." + } + val bundledProvenances = bundleSnapshot.entry.mapNotNull { it.resource as? Provenance } + require( + if (provenanceSnapshot == null) { + bundledProvenances.isEmpty() + } else { + bundledProvenances.size == 1 && bundledProvenances.single().equalsDeep(provenanceSnapshot) + }, + ) { "The Bundle must contain exactly the conversion result's Provenance, when present." } + val outputEntries = bundleSnapshot.entry.mapNotNull { entry -> + val identifier = entry.extension.single { + it.url == GroveExchangeIdentity.ENTRY_IDENTIFIER_EXTENSION + }.value as Identifier + identifier.takeIf { it.hasGroveRole(GroveIdentifierRole.SOURCE_OUTPUT) } + ?.key() + ?.let { it to entry } + }.toMap() + val provenanceTargets = provenanceSnapshot?.target.orEmpty() + require( + outputEntries.size == outputIdentifiers.size && + provenanceTargets.size == outputEntries.size && + provenanceTargets.mapNotNull { target -> + target.identifier.takeIf { it.hasSystem() && it.hasValue() }?.key() + }.distinct().size == provenanceTargets.size && + provenanceTargets.all { target -> + val key = target.identifier.takeIf { it.hasSystem() && it.hasValue() }?.key() + val outputEntry = key?.let(outputEntries::get) + outputEntry != null && target.reference == outputEntry.fullUrl && + target.type == outputEntry.resource.fhirType() + }, + ) { + "Conversion Provenance must literally target every exact output entry with its typed identifier and resource type." + } + provenanceSnapshot?.let { conversionProvenance -> + require(!conversionProvenance.hasIdElement()) { + "The producer must not assign a Provenance Resource.id." + } + require( + conversionProvenance.meta.profile.map { it.value } == listOf( + HealthConnectContract.HEALTH_CONNECT_PROVENANCE_PROFILE, + ), + ) { "Conversion Provenance must directly claim exactly the Health Connect child profile." } + val provenanceEntry = bundleSnapshot.entry.single { it.resource is Provenance } + val entryIdentifier = provenanceEntry.extension.single { + it.url == GroveExchangeIdentity.ENTRY_IDENTIFIER_EXTENSION + }.value as Identifier + require( + !entryIdentifier.system.isNullOrEmpty() && + entryIdentifier.hasGroveRole(GroveIdentifierRole.ENTRY_NODE) && + ENTRY_NODE_IDENTITY_VALUE.matches(entryIdentifier.value.orEmpty()), + ) { "The Provenance entry must use a deployment-namespaced conversion-provenance identifier." } + } + } + + val observationIdentifiers: List + get() = observationSnapshots.map { observation -> observationIdentity(observation).copy() } + + /** Every addressable active output node, including synthesized Specimens and source artifacts. */ + val outputIdentifiers: List + get() = bundleSnapshot.groveOutputIdentifiers().map(Identifier::copy) +} + +/** Non-throwing public conversion boundary; exceptions remain reserved for producer/configuration bugs. */ +sealed interface HealthConnectConversionOutcome { + data class Converted(val conversion: HealthConnectConversion) : HealthConnectConversionOutcome + + data class Unsupported(val sourceType: String, val reason: String) : HealthConnectConversionOutcome + + data class Rejected(val reason: String) : HealthConnectConversionOutcome +} + +/** + * The identity of one emitted Observation. + * + * Every Observation has one source-output identity. This remains true for one-to-one mappings so a + * later retraction can target the exact output independently of the source-record identity. + */ +internal fun observationIdentity(observation: Observation): Identifier = + observation.identifier.single { it.hasGroveRole(GroveIdentifierRole.SOURCE_OUTPUT) } + +/** Selected entry identities for every active semantic or source-preservation output node. */ +internal fun Bundle.groveOutputIdentifiers(): List = entry.mapNotNull { bundleEntry -> + bundleEntry.resource + .takeIf { it.fhirType() in HealthConnectContract.activeOutputResourceTypes } + ?.typedGroveIdentifiers("${bundleEntry.resource.fhirType()} output") + ?.get(GroveIdentifierRole.SOURCE_OUTPUT) +} + +private val EVENT_IDENTITY_VALUE = + Regex("""e2:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}:[1-9][0-9]*""") +private val ENTRY_NODE_IDENTITY_VALUE = + Regex("""n2:[a-z][a-z0-9-]*:(0|[1-9][0-9]*):[A-Za-z0-9_-]{43}""") + +/** + * Why one Health Connect record cannot be converted. + * + * Every rejection is one of these, so the export path catches the base and cannot let a new + * refusal reach the caller as an unhandled crash. + * + * Producer invariants deliberately stay outside this hierarchy and crash the export: the + * `require` calls in [HealthConnectConversion]'s initializer, the `check` calls guarding graph + * assembly, and a malformed event sequence all indicate a bug here rather than a bad record. A + * condition that depends on record data belongs in this hierarchy instead, so it reaches the + * journal as a rejection. + */ +sealed class HealthConnectRecordRejected(message: String, cause: Throwable? = null) : + IllegalArgumentException(message, cause) + +/** A record type outside the published inventory; the producer emits nothing for it. */ +class UnsupportedHealthConnectRecord(val recordType: String) : + HealthConnectRecordRejected("Unsupported Health Connect record type: $recordType") + +open class InvalidHealthConnectRecord(message: String, cause: Throwable? = null) : + HealthConnectRecordRejected(message, cause) + +private fun completeOutputIdentifierKey(observation: Observation): String = + observationIdentity(observation) + .let { "${it.system.length}:${it.system}\u0000${it.value.length}:${it.value}" } + +private fun Identifier.samePair(other: Identifier): Boolean = + system == other.system && value == other.value diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectConversionContext.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectConversionContext.kt new file mode 100644 index 00000000..35eab09a --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectConversionContext.kt @@ -0,0 +1,540 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import androidx.health.connect.client.records.metadata.Device +import org.hl7.fhir.r4.model.Base +import org.hl7.fhir.r4.model.Identifier +import org.hl7.fhir.r4.model.Observation +import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.Provenance +import org.hl7.fhir.r4.model.Reference +import org.hl7.fhir.r4.model.ResearchStudy +import org.hl7.fhir.r4.model.Resource +import java.util.Collections +import java.util.IdentityHashMap +import java.util.Locale + +/** A resource and the complete business identity used to derive its exchange Bundle fullUrl. */ +data class HealthConnectBundleResource( + val entryIdentifier: Identifier, + val resource: T, +) { + val fullUrl: String = GroveExchangeIdentity.fullUrl(entryIdentifier) + + init { + require(entryIdentifier.hasSystem() && entryIdentifier.hasValue()) { + "A bundle resource must have a complete entry Identifier." + } + } + + fun reference(): Reference = Reference(fullUrl) + + internal fun requireStableEntryIdentity() { + require(fullUrl == GroveExchangeIdentity.fullUrl(entryIdentifier)) { + "A bundle resource Identifier must not change after its fullUrl is derived." + } + } +} + +/** Explicit deployment policy for user-authored Health Connect title and notes fields. */ +enum class HealthConnectUserAuthoredTextPolicy { + /** Preserve nonblank title strings in typed extensions and notes in Observation.note.text. */ + RETAIN, + + /** Deliberately omit the strings as a data-minimization decision. */ + OMIT, +} + +/** + * A physical recording Device plus the governed per-unit token used only as HMAC input. + * + * The token is never serialized. Grove mints both the stable typed `recording-device` Identifier + * and the event-scoped `device-snapshot` Identifier selected as the Bundle entry key. Callers + * cannot accidentally use a model, manufacturer, or unhashed platform value as identity. + */ +data class HealthConnectRecordingDeviceResource( + val stablePerUnitToken: String, + val resource: org.hl7.fhir.r4.model.Device, +) { + init { + requireAdmittedIdentityShape() + } + + internal fun admittedBundleResource( + identityKey: GroveHmacIdentityKey, + subjectKey: FhirIdentifierKey, + eventIdentifier: Identifier, + ): HealthConnectBundleResource { + requireAdmittedIdentityShape() + val recordingDeviceIdentifier = HealthConnectIdentity.recordingDevice( + identityKey, + subjectKey, + stablePerUnitToken, + ) + val eventSnapshotIdentifier = HealthConnectIdentity.deviceSnapshot( + identityKey, + eventIdentifier, + RECORDING_DEVICE_ROLE, + stablePerUnitToken, + ) + val snapshot = resource.copy().apply { + addIdentifier(recordingDeviceIdentifier.copy()) + addIdentifier(eventSnapshotIdentifier.copy()) + } + return HealthConnectBundleResource(eventSnapshotIdentifier, snapshot) + } + + private fun requireAdmittedIdentityShape() { + GroveUnicode.requireScalarText(stablePerUnitToken, "Recording Device stable per-unit token") + require(stablePerUnitToken.isNotBlank()) { + "A physical recording Device requires a governed stable per-unit token." + } + require(!resource.hasSerialNumber()) { + "Recording Device.serialNumber is not an exchange field." + } + require(resource.identifier.isEmpty()) { + "A recording Device template cannot disclose identifiers; Grove mints its two closed opaque identities." + } + require( + resource.meta.profile.map { it.value } == + listOf(HealthConnectContract.MOBILE_RECORDING_DEVICE_PROFILE), + ) { "A recording Device template must directly claim exactly the Grove Recording Device profile." } + } +} + +/** + * Immutable host facts for the device on which application snapshots ran. + * + * The source token is HMAC input only and is never serialized. The converter creates the one + * event-scoped `device-snapshot` Identifier allowed by the closed Grove Host Device profile. + */ +data class HealthConnectHostDeviceResource( + val sourceDeviceToken: String, + val resource: org.hl7.fhir.r4.model.Device, +) { + init { + requireTemplateShape() + } + + internal fun admittedBundleResource( + identityKey: GroveHmacIdentityKey, + eventIdentifier: Identifier, + ): HealthConnectBundleResource { + requireTemplateShape() + val snapshotIdentifier = HealthConnectIdentity.deviceSnapshot( + identityKey, + eventIdentifier, + HOST_DEVICE_ROLE, + sourceDeviceToken, + ) + return HealthConnectBundleResource( + snapshotIdentifier, + resource.copy().apply { addIdentifier(snapshotIdentifier.copy()) }, + ) + } + + internal fun requireTemplateShape() { + GroveUnicode.requireScalarText(sourceDeviceToken, "Host Device source token") + require(sourceDeviceToken.isNotBlank()) { "A host Device snapshot requires a nonblank source token." } + require( + resource.meta.profile.map { it.value } == listOf(HealthConnectContract.MOBILE_HOST_DEVICE_PROFILE), + ) { "A host Device template must directly claim exactly the Grove Host Device profile." } + require(resource.identifier.isEmpty()) { + "A host Device template cannot disclose identifiers; Grove mints its event snapshot identity." + } + require(!resource.hasSerialNumber()) { + "Host Device.serialNumber is not an exchange field." + } + require(!resource.hasParent()) { "A Grove host Device cannot itself carry an application host parent." } + require( + resource.version.count { version -> + version.value.isNotBlank() && version.type.coding.any { + it.system == HealthConnectContract.GROVE_APPLICATION_VERSION_TYPE && + it.code == OPERATING_SYSTEM_VERSION_CODE + } + } == 1, + ) { "A host Device must carry one exact typed operating-system version." } + } +} + +/** The two admitted Patient subject shapes; a logical pseudonym never fabricates a Bundle node. */ +sealed class HealthConnectPatientSubject { + /** A concrete Patient whose event-scoped snapshot is included and referenced by fullUrl. */ + data class Bundled(val patient: HealthConnectBundleResource) : HealthConnectPatientSubject() + + /** An identifier-only logical Patient pseudonym with no literal Reference.reference. */ + data class Logical(val identifier: Identifier) : HealthConnectPatientSubject() { + init { + requireValid() + } + } + + internal fun requireValid() { + when (this) { + is Bundled -> patient.requireStableEntryIdentity() + is Logical -> identifier.key() + } + } + + internal fun identityKey(): FhirIdentifierKey = when (this) { + is Bundled -> patient.entryIdentifier.key() + is Logical -> identifier.key() + } + + internal fun logicalReference(): Reference = when (this) { + is Bundled -> error("A bundled Patient is referenced through its event-scoped entry.") + is Logical -> Reference().setType(PATIENT_RESOURCE_TYPE).setIdentifier(identifier.copy()) + } + + internal fun bundledResource(): HealthConnectBundleResource? = + (this as? Bundled)?.patient +} + +/** + * Complete FHIR context supplied around a source-record conversion. + * + * The caller supplies one literal-bundled or identifier-only logical Patient subject, study + * identities, converter application and host facts, and any stable physical-unit token. The + * adapter derives every protocol-defined opaque Device identity. Every literal reference resolves + * inside the Bundle. Health Connect DataOrigin is carried separately as an identifier-only logical + * Device Reference; it is an application product, not an event Device snapshot. + */ +data class HealthConnectConversionContext( + val subject: HealthConnectPatientSubject, + val assembler: HealthConnectBundleResource, + val assemblerHost: HealthConnectHostDeviceResource? = null, + val researchStudies: List> = emptyList(), + val supportingResources: List> = emptyList(), + /** Deployment-owned system for clear `e2::` event identifiers. */ + val eventIdentifierSystem: String, + /** Deployment-owned system for deterministic `n2:` event-scoped entry-node identifiers. */ + val entryNodeIdentifierSystem: String, + val userAuthoredTextPolicy: HealthConnectUserAuthoredTextPolicy, + /** Optional, explicit wire disclosure of Metadata.id on a one-to-one primary Observation. */ + val nativeIdentifierDisclosure: HealthConnectNativeIdentifierDisclosure? = null, + val recordingDevice: (device: Device) -> HealthConnectRecordingDeviceResource? = { null }, +) { + init { + validateStaticContext() + } + + internal fun resolve( + metadata: androidx.health.connect.client.records.metadata.Metadata, + identityKey: GroveHmacIdentityKey, + eventIdentifier: Identifier, + ): ResolvedFhirContext { + validateStaticContext() + val dataOriginPackageName = metadata.dataOrigin.packageName + GroveUnicode.requireScalarText(dataOriginPackageName, "Metadata.dataOrigin.packageName") + require(dataOriginPackageName.isNotBlank()) { + "Metadata.dataOrigin.packageName must identify a nonblank application product." + } + val admittedRecorder = metadata.device?.let(recordingDevice) + + // The recording-device callback runs arbitrary code and HAPI resources are mutable. + // Revalidate the static graph after it returns so it cannot invalidate an already-checked + // assembler, subject, study, or supporting resource. + validateStaticContext() + val hostSnapshot = assemblerHost?.admittedBundleResource(identityKey, eventIdentifier) + val assemblerPackage = assembler.resource.identifier.single { + it.system == HealthConnectContract.ANDROID_PACKAGE_IDENTIFIER + }.value + val assemblerSnapshot = applicationSnapshot( + assembler, + assemblerPackage, + identityKey, + eventIdentifier, + hostSnapshot?.reference(), + ) + val recorder = admittedRecorder?.admittedBundleResource( + identityKey, + subject.identityKey(), + eventIdentifier, + ) + val contextNodes = eventContextNodes(eventIdentifier, assemblerSnapshot) + recorder?.let { + requireProfile(it.resource, HealthConnectContract.MOBILE_RECORDING_DEVICE_PROFILE) + requireCompleteIdentifiers(it.resource, required = false) + require(it.fullUrl != assemblerSnapshot.fullUrl) { + "A physical recorder must not reuse a software-application Bundle identity." + } + } + + val resources = listOfNotNull(contextNodes.subject, hostSnapshot) + listOf(assemblerSnapshot) + + contextNodes.studies + contextNodes.supporting + + listOfNotNull(recorder) + val snapshots = distinctResources(resources).map(HealthConnectBundleResource::snapshot) + return ResolvedFhirContext( + subject = contextNodes.subject?.reference() ?: subject.logicalReference(), + assembler = assemblerSnapshot.reference(), + researchStudies = contextNodes.studies.map(HealthConnectBundleResource::reference), + dataOriginApplication = dataOriginApplicationReference(dataOriginPackageName), + recordingDevice = recorder?.reference(), + resources = snapshots, + ) + } + + private fun eventContextNodes( + eventIdentifier: Identifier, + assemblerSnapshot: HealthConnectBundleResource, + ): EventContextNodes { + val subjectTemplate = subject.bundledResource() + val subjectSnapshot = subjectTemplate?.let { + eventScopedContextResource(it, eventIdentifier, PATIENT_NODE_ROLE, 0) + } + val studyNodes = researchStudies + .sortedBy { it.fullUrl } + .mapIndexed { ordinal, study -> + study to eventScopedContextResource(study, eventIdentifier, RESEARCH_STUDY_NODE_ROLE, ordinal) + } + val supportingNodes = supportingResources + .groupBy { it.resource.fhirType().contextNodeRole() } + .toSortedMap() + .flatMap { (nodeRole, resources) -> + resources.sortedBy { it.fullUrl }.mapIndexed { ordinal, resource -> + resource to eventScopedContextResource(resource, eventIdentifier, nodeRole, ordinal) + } + } + val studySnapshots = studyNodes.map { it.second } + val supportingSnapshots = supportingNodes.map { it.second } + val referenceReplacements = buildMap { + if (subjectTemplate != null && subjectSnapshot != null) { + put(subjectTemplate.fullUrl, subjectSnapshot.fullUrl) + } + put(assembler.fullUrl, assemblerSnapshot.fullUrl) + studyNodes.forEach { (original, snapshot) -> + put(original.fullUrl, snapshot.fullUrl) + } + supportingNodes.forEach { (original, snapshot) -> + put(original.fullUrl, snapshot.fullUrl) + } + } + val remappedSubject = subjectSnapshot?.remapLiteralReferences(referenceReplacements) + val remappedStudies = studySnapshots.map { it.remapLiteralReferences(referenceReplacements) } + val remappedSupporting = supportingSnapshots.map { it.remapLiteralReferences(referenceReplacements) } + return EventContextNodes( + subject = remappedSubject, + studies = remappedStudies, + supporting = remappedSupporting, + ) + } + + /** Current event-time assembler identity for an identifier-only lifecycle assertion. */ + internal fun assemblerSnapshotIdentifier( + identityKey: GroveHmacIdentityKey, + eventIdentifier: Identifier, + ): Identifier { + validateStaticContext() + val packageName = assembler.resource.identifier.single { + it.system == HealthConnectContract.ANDROID_PACKAGE_IDENTIFIER + }.value + return HealthConnectIdentity.deviceSnapshot( + identityKey, + eventIdentifier, + APPLICATION_DEVICE_ROLE, + packageName, + ) + } + + private fun validateStaticContext() { + subject.requireValid() + require( + listOf(eventIdentifierSystem, entryNodeIdentifierSystem).all { + it.isAbsoluteAsciiUri() + } && eventIdentifierSystem != entryNodeIdentifierSystem, + ) { + "Event and entry-node systems must be distinct deployment-owned absolute ASCII RFC 3986 URIs." + } + require(supportingResources.none { + it.resource is Observation || it.resource is Provenance + }) { + "Supporting resources cannot inject adapter-owned Observations or Provenance." + } + require(supportingResources.all { + it.resource.fhirType() in HealthConnectContract.activeSupportingResourceTypes + }) { + "Supporting resources must belong to the protocol's closed active supporting-resource type set." + } + require(supportingResources.none { supporting -> + val profiles = supporting.resource.meta.profile.map { it.value }.toSet() + profiles.any { + it == HealthConnectContract.MOBILE_APPLICATION_DEVICE_PROFILE || + it == HealthConnectContract.MOBILE_HOST_DEVICE_PROFILE || + it == HealthConnectContract.MOBILE_RECORDING_DEVICE_PROFILE + } + }) { + "Supporting resources cannot bypass the governed Grove Device snapshot builders." + } + assemblerHost?.requireTemplateShape() + distinctResources( + listOfNotNull(subject.bundledResource(), assembler) + researchStudies + supportingResources, + ) + requireProfile(assembler.resource, HealthConnectContract.MOBILE_APPLICATION_DEVICE_PROFILE) + val assemblerPackage = assembler.resource.identifier.singleOrNull { + it.system == HealthConnectContract.ANDROID_PACKAGE_IDENTIFIER && it.hasValue() + }?.value ?: throw IllegalArgumentException( + "The converter application must carry one exact Android package-name identifier.", + ) + validateApplicationTemplate(assembler.resource, assemblerPackage, requireVersion = true) + require( + assembler.resource.version.count { version -> + version.value.isNotBlank() && version.type.coding.any { + it.system == HealthConnectContract.MDC && + it.code == HealthConnectContract.APPLICATION_SOFTWARE_VERSION + } + } == 1, + ) { "The converter application must carry one exact application software version." } + } + + private fun applicationSnapshot( + template: HealthConnectBundleResource, + packageName: String, + identityKey: GroveHmacIdentityKey, + eventIdentifier: Identifier, + hostReference: Reference?, + ): HealthConnectBundleResource { + val snapshotIdentifier = HealthConnectIdentity.deviceSnapshot( + identityKey, + eventIdentifier, + APPLICATION_DEVICE_ROLE, + packageName, + ) + val snapshot = template.resource.copy().apply { + addIdentifier(snapshotIdentifier.copy()) + hostReference?.let { parent = it.copy() } + } + return HealthConnectBundleResource(snapshotIdentifier, snapshot) + } + + private fun dataOriginApplicationReference(packageName: String): Reference = Reference().apply { + type = "Device" + identifier = Identifier() + .setSystem(HealthConnectContract.ANDROID_PACKAGE_IDENTIFIER) + .setValue(packageName) + } + + private fun eventScopedContextResource( + template: HealthConnectBundleResource, + eventIdentifier: Identifier, + nodeRole: String, + ordinal: Int, + ): HealthConnectBundleResource = HealthConnectBundleResource( + HealthConnectIdentity.contextNode( + entryNodeIdentifierSystem, + eventIdentifier, + nodeRole, + ordinal, + ), + template.resource.copy(), + ) + + private fun validateApplicationTemplate( + resource: org.hl7.fhir.r4.model.Device, + expectedPackageName: String, + requireVersion: Boolean, + ) { + requireProfile(resource, HealthConnectContract.MOBILE_APPLICATION_DEVICE_PROFILE) + requireCompleteIdentifiers(resource, required = true) + require( + resource.identifier.filter { it.system == HealthConnectContract.ANDROID_PACKAGE_IDENTIFIER } + .singleOrNull()?.value == expectedPackageName, + ) { + "An application Device must carry its exact Android package-name identifier." + } + require(resource.identifier.none { it.hasGroveRole(GroveIdentifierRole.DEVICE_SNAPSHOT) }) { + "The converter, not the caller, mints each event-bound application Device snapshot identity." + } + require(!resource.hasParent()) { + "The converter owns event-bound application-to-host linkage; application templates cannot set parent." + } + require(resource.deviceName.count { + it.type == org.hl7.fhir.r4.model.Device.DeviceNameType.USERFRIENDLYNAME && it.name.isNotBlank() + } == 1) { + "An application Device must carry one exact user-friendly application name." + } + require(!requireVersion || resource.version.isNotEmpty()) { + "The converter application template must state its software version." + } + } + + private fun distinctResources( + resources: List>, + ): List> { + resources.forEach(HealthConnectBundleResource::requireStableEntryIdentity) + resources.groupBy { it.fullUrl }.forEach { (fullUrl, matches) -> + require(matches.drop(1).all { it.resource.equalsDeep(matches.first().resource) }) { + "Bundle fullUrl $fullUrl identifies conflicting context resources." + } + } + return resources.distinctBy { it.fullUrl } + } + + private fun requireProfile(resource: Resource, canonical: String) { + require(resource.meta.profile.map { it.value } == listOf(canonical)) { + "${resource.fhirType()} must directly declare only required profile $canonical." + } + } + + private fun requireCompleteIdentifiers(resource: org.hl7.fhir.r4.model.Device, required: Boolean) { + require(!required || resource.identifier.isNotEmpty()) { + "${resource.fhirType()} must carry a stable business identifier." + } + require(resource.identifier.all { it.hasSystem() && it.hasValue() }) { + "${resource.fhirType()} identifiers must contain both system and value." + } + require(resource.identifier.map { it.system to it.value }.distinct().size == resource.identifier.size) { + "${resource.fhirType()} must not repeat an identifier system and value pair." + } + } +} + +internal data class ResolvedFhirContext( + val subject: Reference, + val assembler: Reference, + val researchStudies: List, + val dataOriginApplication: Reference, + val recordingDevice: Reference?, + val resources: List>, +) + +private data class EventContextNodes( + val subject: HealthConnectBundleResource?, + val studies: List>, + val supporting: List>, +) + +private fun HealthConnectBundleResource.snapshot(): HealthConnectBundleResource = + HealthConnectBundleResource(entryIdentifier.copy(), resource.copy()) + +private fun HealthConnectBundleResource.remapLiteralReferences( + replacements: Map, +): HealthConnectBundleResource { + val remapped = resource.copy() + val visited = Collections.newSetFromMap(IdentityHashMap()) + fun visit(element: Base) { + if (!visited.add(element)) return + if (element is Reference && element.hasReference()) { + replacements[element.reference]?.let { element.reference = it } + } + element.children().flatMap { it.values }.forEach(::visit) + } + visit(remapped) + return HealthConnectBundleResource(entryIdentifier.copy(), remapped) +} + +private fun String.contextNodeRole(): String = + "context-" + replace(Regex("([a-z0-9])([A-Z])"), "$1-$2").lowercase(Locale.ROOT) + +private const val APPLICATION_DEVICE_ROLE = "application" +private const val HOST_DEVICE_ROLE = "host" +private const val RECORDING_DEVICE_ROLE = "recording-device" +private const val OPERATING_SYSTEM_VERSION_CODE = "os-version" +private const val PATIENT_NODE_ROLE = "patient" +private const val RESEARCH_STUDY_NODE_ROLE = "research-study" +private const val PATIENT_RESOURCE_TYPE = "Patient" diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectConversionSpecs.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectConversionSpecs.kt new file mode 100644 index 00000000..1dccff9e --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectConversionSpecs.kt @@ -0,0 +1,117 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import androidx.health.connect.client.records.NutritionRecord +import org.hl7.fhir.r4.model.Coding +import java.math.BigDecimal +import java.time.Instant + +/** One catalog-fixed UCUM system/code pair for an Observation Quantity. */ +internal data class QuantitySemantics( + val system: String, + val code: String, +) + +/** Representational limits stated by one catalog Quantity, independent of plausibility policy. */ +internal data class QuantityValueDomain( + val minimum: BigDecimal? = BigDecimal.ZERO, + val maximum: BigDecimal? = null, + val integerOnly: Boolean = false, +) { + init { + require(minimum == null || maximum == null || minimum <= maximum) { + "A Quantity value domain cannot have a minimum above its maximum." + } + } + + fun requireValue(value: BigDecimal, field: String): BigDecimal { + val violation = when { + minimum != null && value < minimum -> "must be greater than or equal to $minimum" + maximum != null && value > maximum -> "must be less than or equal to $maximum" + integerOnly && value.stripTrailingZeros().scale() > 0 -> "must be an integer" + else -> null + } + if (violation != null) throw InvalidHealthConnectRecord("$field $violation.") + return value + } + + companion object { + val UNBOUNDED = QuantityValueDomain(minimum = null) + } +} + +/** The shapes the converter states one output in: a quantity, a series sample, a coded value. */ +internal data class MobileQuantitySpec( + val profile: String, + val category: String?, + val codeSystem: String, + val code: String, + val display: String, + val unitCode: String, + val unitDisplay: String, + val valueDomain: QuantityValueDomain = QuantityValueDomain(), + val adapterSpecific: Boolean = false, +) { + val measurement: String = profile.measurementId() +} + +internal data class SeriesSample( + val time: Instant, + val value: Double, +) + +/** Binds duplicate coordinates to their exact source-list slot before any output sorting. */ +internal fun assignSourceListOccurrences( + values: List, + coordinate: (T) -> K, +): List> { + val occurrences = mutableMapOf() + return values.map { value -> + val key = coordinate(value) + val occurrence = occurrences.getOrDefault(key, 0) + occurrences[key] = occurrence + 1 + value to occurrence + } +} + +internal data class NutrientSpec( + val measurement: String, + val spec: MobileQuantitySpec, + val extract: (NutritionRecord) -> Double?, +) + +internal data class SourceCodedValue( + val sharedCode: String, + val sharedDisplay: String, + val sourceCode: String, + val sourceDisplay: String, +) + +internal data class MobileCodedSpec( + val profile: String, + val category: String?, + val code: String, + val display: String, + val codeSystem: String = HealthConnectContract.GROVE_MOBILE_MEASUREMENT, + val adapterSpecific: Boolean = false, +) { + val measurement: String = profile.measurementId() +} + +private fun String.measurementId(): String = + substringAfterLast('/').removePrefix("grove-mobile-").removePrefix("health-connect-") + +internal data class BloodGlucoseDefinition( + val measurement: String, + val profile: String, + val loinc: String, + val loincDisplay: String, + val specimenSourceCode: String, + val specimenType: Coding, +) diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectConverter.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectConverter.kt new file mode 100644 index 00000000..e673e6a9 --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectConverter.kt @@ -0,0 +1,290 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import androidx.health.connect.client.feature.ExperimentalMindfulnessSessionApi +import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord +import androidx.health.connect.client.records.BasalBodyTemperatureRecord +import androidx.health.connect.client.records.BasalMetabolicRateRecord +import androidx.health.connect.client.records.BloodGlucoseRecord +import androidx.health.connect.client.records.BloodPressureRecord +import androidx.health.connect.client.records.BodyFatRecord +import androidx.health.connect.client.records.BodyTemperatureRecord +import androidx.health.connect.client.records.BodyWaterMassRecord +import androidx.health.connect.client.records.BoneMassRecord +import androidx.health.connect.client.records.CervicalMucusRecord +import androidx.health.connect.client.records.CyclingPedalingCadenceRecord +import androidx.health.connect.client.records.DistanceRecord +import androidx.health.connect.client.records.ElevationGainedRecord +import androidx.health.connect.client.records.ExerciseSessionRecord +import androidx.health.connect.client.records.FloorsClimbedRecord +import androidx.health.connect.client.records.HeartRateRecord +import androidx.health.connect.client.records.HeartRateVariabilityRmssdRecord +import androidx.health.connect.client.records.HeightRecord +import androidx.health.connect.client.records.HydrationRecord +import androidx.health.connect.client.records.IntermenstrualBleedingRecord +import androidx.health.connect.client.records.LeanBodyMassRecord +import androidx.health.connect.client.records.MenstruationFlowRecord +import androidx.health.connect.client.records.MenstruationPeriodRecord +import androidx.health.connect.client.records.MindfulnessSessionRecord +import androidx.health.connect.client.records.NutritionRecord +import androidx.health.connect.client.records.OvulationTestRecord +import androidx.health.connect.client.records.OxygenSaturationRecord +import androidx.health.connect.client.records.PowerRecord +import androidx.health.connect.client.records.Record +import androidx.health.connect.client.records.RespiratoryRateRecord +import androidx.health.connect.client.records.RestingHeartRateRecord +import androidx.health.connect.client.records.SexualActivityRecord +import androidx.health.connect.client.records.SkinTemperatureRecord +import androidx.health.connect.client.records.SleepSessionRecord +import androidx.health.connect.client.records.SpeedRecord +import androidx.health.connect.client.records.StepsCadenceRecord +import androidx.health.connect.client.records.StepsRecord +import androidx.health.connect.client.records.TotalCaloriesBurnedRecord +import androidx.health.connect.client.records.Vo2MaxRecord +import androidx.health.connect.client.records.WeightRecord +import androidx.health.connect.client.records.WheelchairPushesRecord +import androidx.health.connect.client.records.metadata.Metadata +import org.grovealliance.health.RecordType +import org.hl7.fhir.r4.model.CodeableConcept +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.DateTimeType +import org.hl7.fhir.r4.model.Extension +import org.hl7.fhir.r4.model.Identifier +import org.hl7.fhir.r4.model.Observation +import org.hl7.fhir.r4.model.StringType +import java.math.BigDecimal +import java.time.Instant +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeFormatterBuilder +import java.time.temporal.ChronoField + +/** Converts the explicitly supported Health Connect records into the Grove Mobile R4 contract. */ +@OptIn(ExperimentalMindfulnessSessionApi::class) +@Suppress("CyclomaticComplexMethod") +class HealthConnectConverter( + internal val context: HealthConnectConversionContext, + internal val synchronizationScope: HealthConnectSynchronizationScope, +) { + internal val entryNodeIdentifierSystem: String + get() = context.entryNodeIdentifierSystem + + internal fun convert(record: Record, convertedAt: Instant, eventSequence: EventSequence): HealthConnectConversion { + return convertWithEventToken(record, convertedAt, eventSequence) + } + + /** Converts record-data failures into a stable typed result for collector telemetry and flow control. */ + fun convertOutcome( + record: Record, + convertedAt: Instant, + eventSequence: EventSequence, + ): HealthConnectConversionOutcome = try { + HealthConnectConversionOutcome.Converted(convertWithEventToken(record, convertedAt, eventSequence)) + } catch (error: UnsupportedHealthConnectRecord) { + HealthConnectConversionOutcome.Unsupported(error.recordType, requireNotNull(error.message)) + } catch (error: InvalidHealthConnectRecord) { + HealthConnectConversionOutcome.Rejected(requireNotNull(error.message)) + } + + internal fun preview( + record: Record, + convertedAt: Instant, + priorEventSequence: EventSequence?, + ): HealthConnectConversion = + // Event-time Device snapshots do depend on the event. Reusing the prior sequence makes the + // comparison graph address the prior snapshots, while a new record uses a valid placeholder. + convertWithEventToken(record, convertedAt, priorEventSequence ?: PREVIEW_EVENT_SEQUENCE) + + internal fun bundleIdentifier(eventSequence: EventSequence): Identifier = + HealthConnectIdentity.exchange( + context.eventIdentifierSystem, + synchronizationScope.producerInstance, + eventSequence, + ) + + internal fun assemblerSnapshotIdentifier(eventSequence: EventSequence): Identifier = + context.assemblerSnapshotIdentifier( + synchronizationScope.identityKey, + bundleIdentifier(eventSequence), + ) + + internal fun convertWithEventToken( + record: Record, + convertedAt: Instant, + eventSequence: EventSequence, + ): HealthConnectConversion { + HealthConnectWireFormat.requireFhirInstant(convertedAt, "Conversion event time") + return when (record) { + is ActiveCaloriesBurnedRecord -> convertActiveEnergy(record, convertedAt, eventSequence) + is BasalBodyTemperatureRecord -> convertBasalBodyTemperature(record, convertedAt, eventSequence) + is BasalMetabolicRateRecord -> convertBasalMetabolicRate(record, convertedAt, eventSequence) + is BloodGlucoseRecord -> convertBloodGlucose(record, convertedAt, eventSequence) + is BloodPressureRecord -> convertBloodPressure(record, convertedAt, eventSequence) + is BodyFatRecord -> convertBodyFat(record, convertedAt, eventSequence) + is BodyTemperatureRecord -> convertBodyTemperature(record, convertedAt, eventSequence) + is BodyWaterMassRecord -> convertBodyWaterMass(record, convertedAt, eventSequence) + is BoneMassRecord -> convertBoneMass(record, convertedAt, eventSequence) + is CervicalMucusRecord -> convertCervicalMucus(record, convertedAt, eventSequence) + is CyclingPedalingCadenceRecord -> convertCyclingCadence(record, convertedAt, eventSequence) + is DistanceRecord -> convertDistance(record, convertedAt, eventSequence) + is ElevationGainedRecord -> convertElevationGained(record, convertedAt, eventSequence) + is ExerciseSessionRecord -> convertExerciseSession(record, convertedAt, eventSequence) + is FloorsClimbedRecord -> convertFloorsClimbed(record, convertedAt, eventSequence) + is HeartRateRecord -> convertHeartRate(record, convertedAt, eventSequence) + is HeartRateVariabilityRmssdRecord -> + convertHeartRateVariabilityRmssd(record, convertedAt, eventSequence) + is HeightRecord -> convertHeight(record, convertedAt, eventSequence) + is HydrationRecord -> convertHydration(record, convertedAt, eventSequence) + is IntermenstrualBleedingRecord -> convertIntermenstrualBleeding(record, convertedAt, eventSequence) + is LeanBodyMassRecord -> convertLeanBodyMass(record, convertedAt, eventSequence) + is MenstruationFlowRecord -> convertMenstruationFlow(record, convertedAt, eventSequence) + is MenstruationPeriodRecord -> convertMenstruationPeriod(record, convertedAt, eventSequence) + is MindfulnessSessionRecord -> convertMindfulnessSession(record, convertedAt, eventSequence) + is NutritionRecord -> convertNutrition(record, convertedAt, eventSequence) + is OvulationTestRecord -> convertOvulationTest(record, convertedAt, eventSequence) + is OxygenSaturationRecord -> convertOxygenSaturation(record, convertedAt, eventSequence) + is PowerRecord -> convertPower(record, convertedAt, eventSequence) + is RespiratoryRateRecord -> convertRespiratoryRate(record, convertedAt, eventSequence) + is RestingHeartRateRecord -> convertRestingHeartRate(record, convertedAt, eventSequence) + is SexualActivityRecord -> convertSexualActivity(record, convertedAt, eventSequence) + is SkinTemperatureRecord -> convertSkinTemperature(record, convertedAt, eventSequence) + is SleepSessionRecord -> convertSleepDuration(record, convertedAt, eventSequence) + is SpeedRecord -> convertSpeed(record, convertedAt, eventSequence) + is StepsCadenceRecord -> convertStepCadence(record, convertedAt, eventSequence) + is StepsRecord -> convertSteps(record, convertedAt, eventSequence) + is TotalCaloriesBurnedRecord -> convertTotalEnergy(record, convertedAt, eventSequence) + is Vo2MaxRecord -> convertVo2Max(record, convertedAt, eventSequence) + is WeightRecord -> convertWeight(record, convertedAt, eventSequence) + is WheelchairPushesRecord -> convertWheelchairPushes(record, convertedAt, eventSequence) + else -> throw UnsupportedHealthConnectRecord(RecordType.from(record).identifier) + } + } + + internal fun Observation.retainSessionText( + title: String?, + notes: String?, + sourceType: String, + ) { + if (context.userAuthoredTextPolicy != HealthConnectUserAuthoredTextPolicy.RETAIN) return + title?.takeIf(String::isNotBlank)?.let { + addExtension( + Extension( + HealthConnectContract.HEALTH_CONNECT_SESSION_TITLE, + StringType(requireSourceScalarText(it, "$sourceType.title")), + ), + ) + } + notes?.takeIf(String::isNotBlank)?.let { + addNote().text = requireSourceScalarText(it, "$sourceType.notes") + } + } + + /** Carries the writer's own identity for the record, when it assigns one. */ + internal fun Observation.clientRecordIdentity(metadata: Metadata) { + // A writer that re-imports a measurement reuses its clientRecordId and raises the version, + // and the stored Record then carries a new metadata.id. Without this the same measurement + // is counted twice; with it a receiver supersedes the lower version. + val clientRecordId = metadata.validatedClientRecordId() ?: return + // A clientRecordId is unique only within the app that wrote it, so the writer is part of + // the identity. Without it two apps that both chose "weighin-2026-08-19" would look like + // one measurement, and a receiver applying the supersession rule would drop one of them. + val writer = metadata.dataOrigin.packageName + if (writer.isBlank()) { + throw InvalidHealthConnectRecord("A client record identity requires its writer package name.") + } + addIdentifier( + HealthConnectIdentity.writerRecord( + synchronizationScope.identityKey, + FhirIdentifierKey(HealthConnectContract.ANDROID_PACKAGE_IDENTIFIER, writer), + clientRecordId, + ), + ) + addExtension( + Extension( + HealthConnectContract.WRITER_RECORD_VERSION, + // The source version is a Long; narrowing it to a FHIR integer would wrap a + // millisecond-based version into a negative number and invert the ordering. + StringType(metadata.clientRecordVersion.toString()), + ), + ) + } + + internal companion object { + val FHIR_OFFSET_DATE_TIME: DateTimeFormatter = DateTimeFormatterBuilder() + .appendPattern("uuuu-MM-dd'T'HH:mm:ss") + .appendFraction(ChronoField.NANO_OF_SECOND, 0, 9, true) + .appendOffsetId() + .toFormatter() + val EFFECTIVE_DATE_TIME_ORDER = compareBy({ it.value.time }, { it.valueAsString }) + + const val ACTIVE_CALORIES_BURNED_RECORD = "ActiveCaloriesBurnedRecord" + const val BASAL_BODY_TEMPERATURE_RECORD = "BasalBodyTemperatureRecord" + const val BASAL_METABOLIC_RATE_RECORD = "BasalMetabolicRateRecord" + const val BLOOD_GLUCOSE_RECORD = "BloodGlucoseRecord" + const val BLOOD_PRESSURE_RECORD = "BloodPressureRecord" + const val BODY_FAT_RECORD = "BodyFatRecord" + const val BODY_TEMPERATURE_RECORD = "BodyTemperatureRecord" + const val BODY_WATER_MASS_RECORD = "BodyWaterMassRecord" + const val BONE_MASS_RECORD = "BoneMassRecord" + const val CERVICAL_MUCUS_RECORD = "CervicalMucusRecord" + const val CYCLING_PEDALING_CADENCE_RECORD = "CyclingPedalingCadenceRecord" + const val DISTANCE_RECORD = "DistanceRecord" + const val ELEVATION_GAINED_RECORD = "ElevationGainedRecord" + const val EXERCISE_SESSION_RECORD = "ExerciseSessionRecord" + const val FLOORS_CLIMBED_RECORD = "FloorsClimbedRecord" + const val HEART_RATE_RECORD = "HeartRateRecord" + const val HEART_RATE_VARIABILITY_RMSSD_RECORD = "HeartRateVariabilityRmssdRecord" + const val HEIGHT_RECORD = "HeightRecord" + const val HYDRATION_RECORD = "HydrationRecord" + const val INTERMENSTRUAL_BLEEDING_RECORD = "IntermenstrualBleedingRecord" + const val LEAN_BODY_MASS_RECORD = "LeanBodyMassRecord" + const val MENSTRUATION_FLOW_RECORD = "MenstruationFlowRecord" + const val MENSTRUATION_PERIOD_RECORD = "MenstruationPeriodRecord" + const val MINDFULNESS_SESSION_RECORD = "MindfulnessSessionRecord" + const val NUTRITION_RECORD = "NutritionRecord" + const val OVULATION_TEST_RECORD = "OvulationTestRecord" + const val OXYGEN_SATURATION_RECORD = "OxygenSaturationRecord" + const val POWER_RECORD = "PowerRecord" + const val RESPIRATORY_RATE_RECORD = "RespiratoryRateRecord" + const val RESTING_HEART_RATE_RECORD = "RestingHeartRateRecord" + const val SEXUAL_ACTIVITY_RECORD = "SexualActivityRecord" + const val SKIN_TEMPERATURE_RECORD = "SkinTemperatureRecord" + const val SLEEP_SESSION_RECORD = "SleepSessionRecord" + const val SPEED_RECORD = "SpeedRecord" + const val STEPS_CADENCE_RECORD = "StepsCadenceRecord" + const val MAX_WEIGHT_KILOGRAMS = 1_000.0 + const val STEPS_RECORD = "StepsRecord" + const val TOTAL_CALORIES_BURNED_RECORD = "TotalCaloriesBurnedRecord" + const val VO2_MAX_RECORD = "Vo2MaxRecord" + const val WEIGHT_RECORD = "WeightRecord" + const val WHEELCHAIR_PUSHES_RECORD = "WheelchairPushesRecord" + + const val ACTIVITY_CATEGORY = "activity" + const val LABORATORY_CATEGORY = "laboratory" + const val VITAL_SIGNS_CATEGORY = "vital-signs" + + const val CENTIMETERS_PER_METER = 100.0 + val NANOSECONDS_PER_HOUR: BigDecimal = BigDecimal("3600000000000") + val NANOSECONDS_PER_MINUTE: BigDecimal = BigDecimal("60000000000") + val MILLISECONDS_PER_SECOND: BigDecimal = BigDecimal("1000") + const val SESSION_DURATION_SCALE = 12 + const val NANOSECONDS_TO_MILLISECONDS_SCALE = 6 + + val PREVIEW_EVENT_SEQUENCE = EventSequence("1") + + const val SECONDS_PER_MINUTE = 60 + const val MAX_FHIR_OFFSET_SECONDS = 14 * 60 * SECONDS_PER_MINUTE + const val MIN_FHIR_YEAR = 1 + const val MAX_FHIR_YEAR = 9999 + } +} + +internal fun outputIdentifier(observation: Observation): Identifier = observationIdentity(observation) + +internal fun concept(system: String, code: String, display: String): CodeableConcept = + CodeableConcept(Coding(system, code, display)) diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectCycleCodings.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectCycleCodings.kt new file mode 100644 index 00000000..7e77813b --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectCycleCodings.kt @@ -0,0 +1,87 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import androidx.health.connect.client.records.CervicalMucusRecord +import androidx.health.connect.client.records.MenstruationFlowRecord +import androidx.health.connect.client.records.OvulationTestRecord +import androidx.health.connect.client.records.SexualActivityRecord + +/** Maps each Health Connect cycle-tracking constant onto its shared Grove coding. */ +internal fun menstruationFlowCoding(flow: Int): SourceCodedValue = when (flow) { + MenstruationFlowRecord.FLOW_UNKNOWN -> + SourceCodedValue("unspecified", "Unspecified", "FLOW_UNKNOWN", "Unknown") + MenstruationFlowRecord.FLOW_LIGHT -> + SourceCodedValue("light", "Light", "FLOW_LIGHT", "Light") + MenstruationFlowRecord.FLOW_MEDIUM -> + SourceCodedValue("medium", "Medium", "FLOW_MEDIUM", "Medium") + MenstruationFlowRecord.FLOW_HEAVY -> + SourceCodedValue("heavy", "Heavy", "FLOW_HEAVY", "Heavy") + else -> throw InvalidHealthConnectRecord("Unsupported Health Connect menstruation flow: $flow") +} + +internal fun ovulationTestCoding(result: Int): SourceCodedValue = when (result) { + OvulationTestRecord.RESULT_NEGATIVE -> + SourceCodedValue("negative", "Negative", "RESULT_NEGATIVE", "Negative") + OvulationTestRecord.RESULT_HIGH -> + SourceCodedValue("high-fertility", "High fertility", "RESULT_HIGH", "High") + OvulationTestRecord.RESULT_POSITIVE -> + SourceCodedValue( + "luteinizing-hormone-surge", + "Luteinizing hormone surge", + "RESULT_POSITIVE", + "Positive", + ) + OvulationTestRecord.RESULT_INCONCLUSIVE -> + SourceCodedValue("indeterminate", "Indeterminate", "RESULT_INCONCLUSIVE", "Inconclusive") + else -> throw InvalidHealthConnectRecord("Unsupported Health Connect ovulation-test result: $result") +} + +internal fun sexualActivityCoding(protectionUsed: Int): SourceCodedValue = when (protectionUsed) { + SexualActivityRecord.PROTECTION_USED_UNKNOWN -> + SourceCodedValue("unknown", "Unknown", "PROTECTION_USED_UNKNOWN", "Unknown") + SexualActivityRecord.PROTECTION_USED_PROTECTED -> + SourceCodedValue("protected", "Protection used", "PROTECTION_USED_PROTECTED", "Protected") + SexualActivityRecord.PROTECTION_USED_UNPROTECTED -> + SourceCodedValue( + "unprotected", + "Protection not used", + "PROTECTION_USED_UNPROTECTED", + "Unprotected", + ) + else -> throw InvalidHealthConnectRecord("Unsupported Health Connect protection use: $protectionUsed") +} + +internal fun cervicalMucusAppearanceCoding(appearance: Int): SourceCodedValue = when (appearance) { + CervicalMucusRecord.APPEARANCE_UNKNOWN -> + SourceCodedValue("unknown", "Unknown", "APPEARANCE_UNKNOWN", "Unknown") + CervicalMucusRecord.APPEARANCE_DRY -> + SourceCodedValue("dry", "Dry", "APPEARANCE_DRY", "Dry") + CervicalMucusRecord.APPEARANCE_STICKY -> + SourceCodedValue("sticky", "Sticky", "APPEARANCE_STICKY", "Sticky") + CervicalMucusRecord.APPEARANCE_CREAMY -> + SourceCodedValue("creamy", "Creamy", "APPEARANCE_CREAMY", "Creamy") + CervicalMucusRecord.APPEARANCE_WATERY -> + SourceCodedValue("watery", "Watery", "APPEARANCE_WATERY", "Watery") + CervicalMucusRecord.APPEARANCE_EGG_WHITE -> + SourceCodedValue("egg-white", "Egg white", "APPEARANCE_EGG_WHITE", "Egg white") + CervicalMucusRecord.APPEARANCE_UNUSUAL -> + SourceCodedValue("unusual", "Unusual", "APPEARANCE_UNUSUAL", "Unusual") + else -> throw InvalidHealthConnectRecord("Unsupported Health Connect cervical-mucus appearance: $appearance") +} + +internal fun cervicalMucusSensationCoding(sensation: Int): SourceCodedValue? = when (sensation) { + CervicalMucusRecord.SENSATION_UNKNOWN -> null + CervicalMucusRecord.SENSATION_LIGHT -> + SourceCodedValue("light", "Light", "SENSATION_LIGHT", "Light") + CervicalMucusRecord.SENSATION_MEDIUM -> + SourceCodedValue("medium", "Medium", "SENSATION_MEDIUM", "Medium") + CervicalMucusRecord.SENSATION_HEAVY -> + SourceCodedValue("heavy", "Heavy", "SENSATION_HEAVY", "Heavy") + else -> throw InvalidHealthConnectRecord("Unsupported Health Connect cervical-mucus sensation: $sensation") +} diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectExportCoordinator.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectExportCoordinator.kt new file mode 100644 index 00000000..6d7f3c53 --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectExportCoordinator.kt @@ -0,0 +1,599 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import androidx.health.connect.client.records.Record +import org.grovealliance.health.RecordType +import org.hl7.fhir.r4.formats.JsonParser +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.CodeType +import org.hl7.fhir.r4.model.Identifier +import org.hl7.fhir.r4.model.Observation +import org.hl7.fhir.r4.model.Provenance +import java.time.Instant + +/** One prior logical output named by a retraction assertion. */ +data class HealthConnectRetractionTarget( + val identifier: FhirIdentifierKey, + val identifierRole: GroveIdentifierRole, + val resourceType: String, + val role: HealthConnectRetractionTargetRole, +) { + init { + require(resourceType.matches(Regex("[A-Z][A-Za-z0-9]+"))) { + "A retraction target requires its exact FHIR resource type." + } + val claim = HealthConnectContract.retractionTargetClaims.getValue(role) + require(identifierRole == claim.identifierRole && resourceType in claim.resourceTypes) { + "${role.code} requires Identifier role ${claim.identifierRole.code} and one of " + + "${claim.resourceTypes.sorted().joinToString()}; received ${identifierRole.code}/$resourceType." + } + } +} + +/** One closed target-role row projected from the normative exchange-protocol catalog. */ +internal data class GroveRetractionTargetClaim( + val identifierRole: GroveIdentifierRole, + val resourceTypes: Set, +) + +enum class HealthConnectRetractionTargetRole(val code: String) { + PRIMARY_OUTPUT("primary-output"), + SOURCE_ARTIFACT("source-artifact"), + CHILD_OUTPUT("child-output"), + SPECIMEN("specimen"), + DEVICE_SNAPSHOT("device-snapshot"), +} + +/** One sink-acknowledged unit of FHIR changes for a Health Connect source record. */ +@Suppress("LongParameterList") +class HealthConnectExportBatch( + val eventSequence: EventSequence, + val operation: HealthConnectExportOperation, + sourceRecordIdentifier: org.hl7.fhir.r4.model.Identifier, + val sourceVersion: Instant, + bundle: Bundle, + /** Exact compact FHIR JSON persisted before delivery and reused byte-for-byte on retry. */ + val bundleJson: String, + /** Lowercase SHA-256 of the exact UTF-8 [bundleJson] string. */ + val payloadSha256: String, + retractedTargets: Set = emptySet(), +) { + private val sourceRecordIdentifierSnapshot = sourceRecordIdentifier.copy() + private val bundleSnapshot = bundle.copy() + private val retractedTargetSnapshot = retractedTargets.toSet() + + /** Defensive copy; mutating it cannot change this batch or a later retry. */ + val sourceRecordIdentifier: Identifier + get() = sourceRecordIdentifierSnapshot.copy() + + /** Diagnostic object view only; [bundleJson] remains the authoritative wire payload. */ + val bundle: Bundle + get() = bundleSnapshot.copy() + + val retractedTargets: Set + get() = retractedTargetSnapshot.toSet() + + init { + require( + sourceRecordIdentifierSnapshot.hasSystem() && sourceRecordIdentifierSnapshot.hasValue() && + sourceRecordIdentifierSnapshot.hasGroveRole(GroveIdentifierRole.SOURCE_RECORD), + ) { + "An export batch requires the exact typed source-record identifier." + } + require(bundleSnapshot.type == Bundle.BundleType.COLLECTION && bundleSnapshot.entry.isNotEmpty()) { + "An export batch must contain a non-empty collection Bundle." + } + require( + bundleSnapshot.identifier.hasSystem() && bundleSnapshot.identifier.hasValue() && + bundleSnapshot.identifier.hasGroveRole(GroveIdentifierRole.EVENT) && + EVENT_IDENTIFIER_VALUE.matches(bundleSnapshot.identifier.value) && + bundleSnapshot.identifier.value.substringAfterLast(':') == eventSequence.value, + ) { "An export batch event Identifier must contain its exact allocated sequence." } + require(bundleSnapshot.hasTimestampElement()) { "An export batch requires its immutable assembly timestamp." } + require(payloadSha256 == HealthConnectWireFormat.sha256(bundleJson)) { + "The export batch payload checksum must match its exact UTF-8 Bundle JSON." + } + val parsedBundle = runCatching { JsonParser().parse(bundleJson) as? Bundle }.getOrNull() + require(parsedBundle != null && parsedBundle.equalsDeep(bundleSnapshot)) { + "The export batch Bundle must exactly match its authoritative JSON payload." + } + require(bundleSnapshot.entry.none { it.hasRequest() || it.hasResponse() }) { + "A collection event cannot contain transaction request or response elements." + } + when (operation) { + HealthConnectExportOperation.ACTIVE -> + bundleSnapshot.requireGroveActiveExchangeContract(sourceRecordIdentifierSnapshot) + HealthConnectExportOperation.RETRACTION -> bundleSnapshot.requireGroveEntryIdentitySelection() + } + bundleSnapshot.requireGroveReferencePolicy() + when (operation) { + HealthConnectExportOperation.ACTIVE -> { + require(retractedTargetSnapshot.isEmpty()) { "An active event cannot carry retraction targets." } + require(bundleSnapshot.meta.profile.map { it.value } == listOf( + HealthConnectContract.MOBILE_EXCHANGE_BUNDLE_PROFILE, + )) { "An active export must claim exactly the Grove Mobile exchange Bundle profile." } + } + HealthConnectExportOperation.RETRACTION -> validateRetractionShape() + } + } + + private fun validateRetractionShape() { + require(retractedTargetSnapshot.isNotEmpty()) { "A retraction event must name at least one prior graph node." } + require(bundleSnapshot.meta.profile.map { it.value } == listOf( + HealthConnectContract.MOBILE_RETRACTION_BUNDLE_PROFILE, + )) { "A retraction export must claim exactly the Grove Mobile retraction Bundle profile." } + require(bundleSnapshot.entry.size == 1 && bundleSnapshot.entry.single().resource is Provenance) { + "A retraction Bundle contains only its lifecycle Provenance and no copied active resource." + } + val provenance = bundleSnapshot.entry.single().resource as Provenance + validateRetractionAssertion(provenance) + validateRetractionAgent(provenance) + validateRetractionSource(provenance) + validateRetractionTargets(provenance) + } + + private fun validateRetractionAssertion(provenance: Provenance) { + require( + provenance.meta.profile.map { it.value } == listOf( + HealthConnectContract.MOBILE_RETRACTION_PROVENANCE_PROFILE, + ) && + provenance.activity.coding.singleOrNull()?.let { + it.system == HealthConnectContract.GROVE_LIFECYCLE_EVENT && + it.code == "source-record-retracted" + } == true, + ) { "A retraction requires the exact Grove lifecycle assertion and profile." } + } + + private fun validateRetractionAgent(provenance: Provenance) { + require( + provenance.agent.size == 1 && + provenance.agent.single().type.coding.singleOrNull()?.let { + it.system == HealthConnectContract.PROVENANCE_PARTICIPANT && it.code == "assembler" + } == true && + provenance.agent.single().who.let { + !it.hasReference() && it.type == "Device" && + it.identifier.hasGroveRole(GroveIdentifierRole.DEVICE_SNAPSHOT) + }, + ) { "A retraction requires one current identifier-only assembler Device snapshot." } + } + + private fun validateRetractionSource(provenance: Provenance) { + require( + provenance.entity.size == 1 && + provenance.entity.single().role == Provenance.ProvenanceEntityRole.SOURCE && + provenance.entity.single().what.let { source -> + !source.hasReference() && source.hasIdentifier() && + source.identifier.hasGroveRole(GroveIdentifierRole.SOURCE_RECORD) && + source.identifier.system == sourceRecordIdentifierSnapshot.system && + source.identifier.value == sourceRecordIdentifierSnapshot.value + }, + ) { "A retraction must identify the exact typed source Record as its sole source entity." } + } + + private fun validateRetractionTargets(provenance: Provenance) { + val actualTargets = provenance.target.map { target -> + require(!target.hasReference() && target.hasType() && target.hasIdentifier()) { + "A retraction target must be an identifier-only typed logical Reference." + } + val identifierRole = target.identifier.type.coding.singleOrNull { + it.system == HealthConnectContract.GROVE_IDENTIFIER_ROLE + }?.code + val role = target.extension.singleOrNull { + it.url == HealthConnectContract.GROVE_RETRACTION_TARGET_ROLE + }?.value as? CodeType + RetractionTargetShape( + identifier = target.identifier.key(), + identifierRole = identifierRole, + resourceType = target.type, + targetRole = role?.value, + ) + }.toSet() + val expectedTargets = retractedTargetSnapshot.map { target -> + RetractionTargetShape( + target.identifier, + target.identifierRole.code, + target.resourceType, + target.role.code, + ) + }.toSet() + require(actualTargets == expectedTargets && actualTargets.size == provenance.target.size) { + "The retraction Provenance must name every exact typed target and role once." + } + } + + val wireOperation: String + get() = operation.wireValue + + val wireSourceVersion: String + get() = HealthConnectWireFormat.sourceVersion(sourceVersion) + + private data class RetractionTargetShape( + val identifier: FhirIdentifierKey, + val identifierRole: String?, + val resourceType: String, + val targetRole: String?, + ) + + private companion object { + val EVENT_IDENTIFIER_VALUE = + Regex("""e2:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}:[1-9][0-9]*""") + } +} + +/** A sink's confirmation that it durably stored the exact serialized event it was given. */ +class HealthConnectExportAcknowledgement( + destinationReferences: Map, +) { + private val destinationReferenceSnapshot = destinationReferences.toMap() + + /** Defensive snapshot of the sink-owned response. */ + val destinationReferences: Map + get() = destinationReferenceSnapshot.toMap() + + init { + require(destinationReferenceSnapshot.values.all { it.isNotBlank() }) { + "Acknowledged destination references must not be blank." + } + } +} + +/** + * A sink must durably and idempotently apply a complete batch before returning. + * + * The sink transmits [HealthConnectExportBatch.wireOperation], the source Identifier's exact + * `system` and `value`, [HealthConnectExportBatch.wireSourceVersion], `eventSequence`, and the exact + * `bundleJson`. It must not reserialize [HealthConnectExportBatch.bundle]. Observations are + * addressed by the exact `(identifier.system, identifier.value)` pair. The producer identity and + * [HealthConnectExportBatch.eventSequence] form the idempotency key: an exact-event replay must be + * a no-op returning the same acknowledgement, while the same key with different operation, source, + * version, or checksum must fail closed. A thrown exception is a negative acknowledgement: the + * coordinator does not commit journal state and the Health Connect changes token must not advance. + */ +fun interface HealthConnectExportSink { + suspend fun apply(batch: HealthConnectExportBatch): HealthConnectExportAcknowledgement +} + +/** Coordinates conversion, one-to-many update invalidation, deletion, replay, and full resync. */ +class HealthConnectExportCoordinator( + private val converter: HealthConnectConverter, + private val journal: HealthConnectExportJournal, + private val sink: HealthConnectExportSink, +) { + private val synchronizationScope = converter.synchronizationScope + private val activeExportBuilder = HealthConnectActiveExportBuilder(synchronizationScope) + private val pendingDelivery = HealthConnectPendingExportDelivery(converter, journal, sink) + + val collectionScopeId: ScopeKey + get() = synchronizationScope.projectionScopeKey + + val repositoryScopeId: ScopeKey + get() = synchronizationScope.repositoryScopeKey + + suspend fun upsert(record: Record, convertedAt: Instant) { + val recordType = sourceRecordType(record) + val healthConnectId = record.metadata.id + journal.withSourceTransition( + synchronizationScope.repositoryScopeKey, + recordType, + healthConnectId, + ) { lease -> + upsert(record, convertedAt, lease) + } + } + + private suspend fun upsert( + record: Record, + convertedAt: Instant, + lease: HealthConnectSourceTransitionLease, + ) { + journal.pending(lease)?.let { pending -> pendingDelivery.deliver(pending, lease) } + val prior = journal.entry(lease) + + val preview = try { + converter.preview(record, convertedAt, prior?.lastEventSequence) + } catch (error: HealthConnectRecordRejected) { + reject(record, lease, convertedAt, error) + return + } + if (prior.requiresExplicitContractMigration(preview, synchronizationScope.projectionScopeKey)) { + throw HealthConnectConversionContractMigrationRequired(preview.sourceRecordType) + } + when { + migrateSourceIdentityIfRequired(record, preview, prior, convertedAt, lease) -> Unit + prior?.isUnchangedActiveProjection(preview, synchronizationScope.projectionScopeKey) == true -> Unit + prior?.state == HealthConnectExportState.ACTIVE && prior.outputIdentifiers.isNotEmpty() -> { + // A changed immutable source version is a new event. Retract its complete prior + // graph before publishing the replacement; active and lifecycle assertions never + // share one collection Bundle. + pendingDelivery.retract(prior, convertedAt, lease) + upsert(record, convertedAt, lease) + } + storeZeroOutputLocally(record, preview, prior, lease) -> Unit + else -> publishActive(record, convertedAt, preview, prior, lease) + } + } + + private suspend fun publishActive( + record: Record, + convertedAt: Instant, + preview: HealthConnectConversion, + prior: HealthConnectExportJournalEntry?, + lease: HealthConnectSourceTransitionLease, + ) { + activeExportBuilder.draft(record, preview, prior) + val pending = journal.stage(lease, prior?.revision) { eventSequence -> + val conversion = converter.convert(record, convertedAt, eventSequence) + activeExportBuilder.draft(record, conversion, prior) + } + if ( + pending.operation != HealthConnectExportOperation.ACTIVE || + pending.sourceVersion != preview.sourceLastModified + ) { + error("The journal returned an unrelated pending event while holding the source-transition lease.") + } + pendingDelivery.deliver(pending, lease) + } + + private suspend fun migrateSourceIdentityIfRequired( + record: Record, + conversion: HealthConnectConversion, + prior: HealthConnectExportJournalEntry?, + convertedAt: Instant, + lease: HealthConnectSourceTransitionLease, + ): Boolean { + if (!prior.requiresSourceIdentityRetirement(conversion, synchronizationScope.projectionScopeKey)) return false + val priorEntry = requireNotNull(prior) + if (priorEntry.state == HealthConnectExportState.ACTIVE && priorEntry.outputIdentifiers.isNotEmpty()) { + pendingDelivery.retract(priorEntry, convertedAt, lease) + } + upsert(record, convertedAt, lease) + return true + } + + private suspend fun storeZeroOutputLocally( + record: Record, + conversion: HealthConnectConversion, + prior: HealthConnectExportJournalEntry?, + lease: HealthConnectSourceTransitionLease, + ): Boolean { + val hasPriorActiveOutputs = prior?.state == HealthConnectExportState.ACTIVE && + prior.outputIdentifiers.isNotEmpty() + if (conversion.outputIdentifiers.isNotEmpty() || hasPriorActiveOutputs) return false + journal.storeLocal(lease, prior?.revision, activeExportBuilder.entry(record, conversion, prior)) + return true + } + + suspend fun delete(recordType: String, healthConnectId: String, invalidatedAt: Instant) { + journal.withSourceTransition( + synchronizationScope.repositoryScopeKey, + recordType, + healthConnectId, + ) { lease -> + delete(recordType, healthConnectId, invalidatedAt, lease) + } + } + + private suspend fun delete( + recordType: String, + healthConnectId: String, + invalidatedAt: Instant, + lease: HealthConnectSourceTransitionLease, + ) { + journal.pending(lease)?.let { pending -> pendingDelivery.deliver(pending, lease) } + val prior = journal.entry(lease) + if (prior == null) { + journal.recordUnmatchedDeletion( + lease, + HealthConnectUnmatchedDeletion( + synchronizationScope.repositoryScopeKey, + synchronizationScope.projectionScopeKey, + recordType, + healthConnectId, + invalidatedAt, + ), + ) + return + } + if (prior.state == HealthConnectExportState.INVALIDATED) return + + if (prior.outputIdentifiers.isEmpty()) { + invalidateLocal(prior, invalidatedAt, lease) + return + } + + pendingDelivery.retract(prior, invalidatedAt, lease) + } + + private suspend fun invalidateLocal( + prior: HealthConnectExportJournalEntry, + invalidatedAt: Instant, + lease: HealthConnectSourceTransitionLease, + ) { + HealthConnectWireFormat.requireFhirInstant(invalidatedAt, "Local invalidation time") + journal.storeLocal( + lease, + prior.revision, + prior.copy( + projectionScopeKey = synchronizationScope.projectionScopeKey, + bundle = prior.bundle.copy().apply { + timestampElement = org.hl7.fhir.r4.model.InstantType(invalidatedAt.toString()) + }, + state = HealthConnectExportState.INVALIDATED, + invalidatedAt = invalidatedAt, + ), + ) + } + + /** + * Reconciles a complete, one-type read against durable state. + * + * Each acknowledged record is independently journaled, making a failed reconciliation safe to + * retry. Records absent from the complete read are invalidated; a later deletion-change replay + * is recognized by the retained local invalidation marker. + */ + suspend fun reconcile( + recordType: String, + observedAt: () -> Instant, + readAll: suspend () -> List, + ) { + journal.withReconciliationLease( + synchronizationScope.repositoryScopeKey, + recordType, + ) { reconciliationLease -> + // The complete source read is part of the fenced interval. Accepting an already-read + // List here would leave a read-to-lease race in which another coordinator could publish + // a newly visible source and then have this reconciliation retract it as absent. + val ordered = readAll().sortedBy { it.metadata.id } + val convertedAt = observedAt() + require(ordered.all { sourceRecordType(it) == recordType }) { + "A full reconciliation may contain only $recordType records." + } + require(ordered.map { it.metadata.id }.distinct().size == ordered.size) { + "A full reconciliation cannot contain the same Health Connect id twice." + } + + // Validate every candidate graph before replaying or publishing any sink event. Typed + // record rejections remain source-local and are durably recorded during their later + // transition. + ordered.forEach { record -> + try { + converter.preview(record, convertedAt, null) + } catch (_: HealthConnectRecordRejected) { + // The source-local transition below records this exact rejection after preflight. + } + } + + journal.pendingForType(reconciliationLease) + .sortedWith(compareBy(HealthConnectPendingExport::eventSequence)) + .forEach { snapshot -> + journal.withSourceTransition( + synchronizationScope.repositoryScopeKey, + recordType, + snapshot.healthConnectId, + reconciliationLease, + ) { sourceLease -> + journal.pending(sourceLease)?.let { pendingDelivery.deliver(it, sourceLease) } + } + } + + ordered.forEach { record -> + journal.withSourceTransition( + synchronizationScope.repositoryScopeKey, + recordType, + record.metadata.id, + reconciliationLease, + ) { sourceLease -> + upsert(record, convertedAt, sourceLease) + } + } + + val presentIds = ordered.map { it.metadata.id }.toSet() + journal.entries(reconciliationLease) + .filter { it.state != HealthConnectExportState.INVALIDATED && it.healthConnectId !in presentIds } + .sortedBy { it.healthConnectId } + .forEach { entry -> + journal.withSourceTransition( + synchronizationScope.repositoryScopeKey, + recordType, + entry.healthConnectId, + reconciliationLease, + ) { sourceLease -> + delete(recordType, entry.healthConnectId, convertedAt, sourceLease) + } + } + } + } + + private suspend fun reject( + record: Record, + lease: HealthConnectSourceTransitionLease, + observedAt: Instant, + error: HealthConnectRecordRejected, + ) { + val healthConnectId = record.metadata.id + val prior = journal.entry(lease) + if (prior?.state == HealthConnectExportState.ACTIVE) { + delete(lease.recordType, healthConnectId, observedAt, lease) + } + journal.recordRejectedRecord( + lease, + HealthConnectRejectedRecord( + repositoryScopeKey = synchronizationScope.repositoryScopeKey, + projectionScopeKey = synchronizationScope.projectionScopeKey, + recordType = lease.recordType, + healthConnectId = healthConnectId, + sourceLastModified = record.metadata.lastModifiedTime, + observedAt = observedAt, + reason = error.message ?: "Health Connect Record conversion failed.", + ), + ) + } + + private fun sourceRecordType(record: Record): String = RecordType.from(record).identifier +} + +private fun HealthConnectExportJournalEntry.semanticallyEquals(conversion: HealthConnectConversion): Boolean { + val previousObservations = observations.sortedBy(::outputIdentifierKey) + val currentObservations = conversion.observations.sortedBy(::outputIdentifierKey) + if ( + previousObservations.size != currentObservations.size || + previousObservations.zip(currentObservations).any { (previous, current) -> !previous.equalsDeep(current) } + ) { + return false + } + + val previousContext = bundle.entry + .filterNot { it.resource is Observation || it.resource is org.hl7.fhir.r4.model.Provenance } + .associate { it.fullUrl to it.resource } + val currentContext = conversion.bundle.entry + .filterNot { it.resource is Observation || it.resource is org.hl7.fhir.r4.model.Provenance } + .associate { it.fullUrl to it.resource } + return previousContext.keys == currentContext.keys && previousContext.all { (fullUrl, resource) -> + resource.equalsDeep(currentContext.getValue(fullUrl)) + } +} + +private fun HealthConnectExportJournalEntry.isUnchangedActiveProjection( + conversion: HealthConnectConversion, + currentProjectionScopeKey: ScopeKey, +): Boolean { + val zeroOutputTimestampMatches = conversion.outputIdentifiers.isNotEmpty() || + sourceLastModified == conversion.sourceLastModified + return state == HealthConnectExportState.ACTIVE && + projectionScopeKey == currentProjectionScopeKey && + zeroOutputTimestampMatches && + semanticallyEquals(conversion) +} + +private fun HealthConnectExportJournalEntry?.requiresExplicitContractMigration( + conversion: HealthConnectConversion, + currentProjectionScopeKey: ScopeKey, +): Boolean = this != null && + state == HealthConnectExportState.ACTIVE && + projectionScopeKey == currentProjectionScopeKey && + ( + conversionContractVersion != conversion.conversionContractVersion || + !sourceRecordIdentifier.sameCompleteIdentifier(conversion.sourceRecordIdentifier) + ) + +private fun HealthConnectExportJournalEntry?.requiresSourceIdentityRetirement( + conversion: HealthConnectConversion, + currentProjectionScopeKey: ScopeKey, +): Boolean = this != null && + state == HealthConnectExportState.ACTIVE && + outputIdentifiers.isNotEmpty() && + projectionScopeKey != currentProjectionScopeKey && + !sourceRecordIdentifier.sameCompleteIdentifier(conversion.sourceRecordIdentifier) + +private fun org.hl7.fhir.r4.model.Identifier.sameCompleteIdentifier( + other: org.hl7.fhir.r4.model.Identifier, +): Boolean = system == other.system && value == other.value + +private fun outputIdentifierKey(observation: Observation): FhirIdentifierKey = + observationIdentity(observation).key() diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectExportJournal.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectExportJournal.kt new file mode 100644 index 00000000..f72fce71 --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectExportJournal.kt @@ -0,0 +1,652 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import org.hl7.fhir.r4.formats.IParser +import org.hl7.fhir.r4.formats.JsonParser +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.Identifier +import org.hl7.fhir.r4.model.Observation +import org.hl7.fhir.r4.model.Parameters +import org.hl7.fhir.r4.model.Provenance +import java.time.Instant + +/** Fenced ownership of one source-record transition. The journal, not the coordinator, issues it. */ +data class HealthConnectSourceTransitionLease( + val repositoryScopeKey: ScopeKey, + val recordType: String, + val healthConnectId: String, + val fence: HealthConnectJournalFence, + val reconciliationFence: HealthConnectJournalFence? = null, +) { + init { + require(recordType.isNotBlank() && healthConnectId.isNotBlank()) { + "A source-transition lease must identify one source Record." + } + } + + override fun toString(): String = + "HealthConnectSourceTransitionLease(" + + "repositoryScopeKey=$repositoryScopeKey, recordType=$recordType, " + + "healthConnectId=, fence=$fence, reconciliationFence=$reconciliationFence)" +} + +/** Fenced exclusive ownership of one complete-read reconciliation boundary. */ +data class HealthConnectReconciliationLease( + val repositoryScopeKey: ScopeKey, + val recordType: String, + val fence: HealthConnectJournalFence, +) { + init { + require(recordType.isNotBlank()) { "A reconciliation lease must identify one source Record type." } + } +} + +/** + * Durable state required to resolve Health Connect deletions and one-to-many conversions. + * + * Health Connect deletion changes contain only the Health Connect id. Implementations persist an + * active entry only after every upsert has succeeded. An acknowledged retraction becomes a durable + * local invalidation marker so a replay can be distinguished from a genuinely unknown deletion. + * This marker is journal state, not a copied FHIR clinical resource. Its retention and compaction + * are explicit application policy. Event sequences are + * allocated from one durable, monotonically increasing counter for the entire producer outbox, + * across all record types and source ids. + */ +@Suppress("TooManyFunctions") +interface HealthConnectExportJournal { + /** + * Runs one source transition under a renewable, monotonically fenced cross-instance lease. + * + * The implementation must exclude another transition for the same source Record across threads, + * coordinator instances, and processes. It must renew ownership without holding a database + * transaction across sink I/O. Every mutating method below verifies [HealthConnectSourceTransitionLease.fence] + * and fails closed after lease loss. A staged exact event remains durable for the next owner. + * When [reconciliationLease] is supplied, it must be the still-current parent lease for this + * repository and Record type; this permits a reconciliation owner to take child source leases. + */ + suspend fun withSourceTransition( + repositoryScopeKey: ScopeKey, + recordType: String, + healthConnectId: String, + reconciliationLease: HealthConnectReconciliationLease? = null, + block: suspend (HealthConnectSourceTransitionLease) -> T, + ): T + + /** + * Runs a complete-read reconciliation under a renewable, exclusive, monotonically fenced lease. + * Ordinary source transitions for this repository/type must wait, while child leases carrying + * this reconciliation fence remain admitted. No database transaction is held across [block]. + */ + suspend fun withReconciliationLease( + repositoryScopeKey: ScopeKey, + recordType: String, + block: suspend (HealthConnectReconciliationLease) -> T, + ): T + + suspend fun entry(lease: HealthConnectSourceTransitionLease): HealthConnectExportJournalEntry? + + suspend fun entries(lease: HealthConnectReconciliationLease): List + + /** Returns the durable outbox event currently blocking this source Record, if one exists. */ + suspend fun pending(lease: HealthConnectSourceTransitionLease): HealthConnectPendingExport? + + /** Enumerates every durable outbox event for one repository and record type. */ + suspend fun pendingForType(lease: HealthConnectReconciliationLease): List + + /** + * Atomically allocates a monotonically increasing decimal event sequence and stores a draft. + * + * The implementation passes the candidate sequence to [buildDraft] before committing either + * the counter or outbox row. The pure callback uses that sequence in Bundle/Provenance technical + * identities. If it throws, neither value is committed. If an event for this source Record is + * already pending, return it unchanged without invoking [buildDraft]. Implementations must + * serialize concurrent calls so no two committed events receive the same sequence. + */ + suspend fun stage( + lease: HealthConnectSourceTransitionLease, + expectedRevision: HealthConnectJournalRevision?, + buildDraft: (eventSequence: EventSequence) -> HealthConnectPendingExportDraft, + ): HealthConnectPendingExport + + /** + * Atomically stores [entry] and removes the exact [pending] outbox event with fence and base-state CAS. + * Repeating completion for the same event and exact resulting revision is a successful no-op. + */ + suspend fun complete( + lease: HealthConnectSourceTransitionLease, + pending: HealthConnectPendingExport, + entry: HealthConnectExportJournalEntry, + ) + + /** + * CAS-stores a source transition that has neither active outputs nor a retraction event. + * Implementations must reject an [entry] whose retained Bundle contains an output Identifier; + * local storage cannot be used to bypass durable sink acknowledgement. + */ + suspend fun storeLocal( + lease: HealthConnectSourceTransitionLease, + expectedRevision: HealthConnectJournalRevision?, + entry: HealthConnectExportJournalEntry, + ) + + /** + * Durably records a deletion that has no export journal row, then allows token progress. + * + * Such a deletion can be legitimate when a Record was inserted and deleted before polling or + * was excluded by collection filters. The row is upserted by `(recordType, healthConnectId)` + * and preserves its first observation time. Retaining it makes possible journal loss visible. + * Token-store failure can replay the same deletion with a later local clock value; that replay + * must not create another quarantine row. + */ + suspend fun recordUnmatchedDeletion( + lease: HealthConnectSourceTransitionLease, + deletion: HealthConnectUnmatchedDeletion, + ) + + /** Durably upserts one rejected source version before the changes token may advance. */ + suspend fun recordRejectedRecord( + lease: HealthConnectSourceTransitionLease, + rejected: HealthConnectRejectedRecord, + ) +} + +/** + * What the journal remembers about one source record after its conversion was acknowledged. + * + * The entry is keyed by the Health Connect id and the scope it was exported under, so a later + * read of the same record can tell an unchanged row from one that has to be re-sent. + */ +@Suppress("LongParameterList") +class HealthConnectExportJournalEntry( + val repositoryScopeKey: ScopeKey, + val projectionScopeKey: ScopeKey, + val recordType: String, + val healthConnectId: String, + val dataOriginPackage: String, + val sourceLastModified: Instant, + val conversionContractVersion: String, + sourceRecordIdentifier: Identifier, + observations: List, + bundle: Bundle, + destinationReferences: Map, + val lastEventSequence: EventSequence? = null, + val state: HealthConnectExportState = HealthConnectExportState.ACTIVE, + val invalidatedAt: Instant? = null, +) { + private val sourceRecordIdentifierSnapshot = sourceRecordIdentifier.copy() + private val observationSnapshots = observations.map(Observation::copy) + private val bundleSnapshot = bundle.copy() + private val destinationReferenceSnapshot = destinationReferences.toMap() + + init { + require(recordType.isNotBlank()) { "The source record type must not be blank." } + require(healthConnectId.isNotBlank()) { "The Health Connect id must not be blank." } + require(dataOriginPackage.isNotBlank()) { "The data-origin package must not be blank." } + GroveUnicode.requireScalarText(dataOriginPackage, "Journal data-origin package") + require(conversionContractVersion.isNotBlank()) { "The conversion-contract version must not be blank." } + require( + sourceRecordIdentifierSnapshot.hasSystem() && sourceRecordIdentifierSnapshot.hasValue() && + sourceRecordIdentifierSnapshot.hasGroveRole(GroveIdentifierRole.SOURCE_RECORD), + ) { + "The source record identifier must contain a complete typed source-record pair." + } + sourceRecordIdentifierSnapshot.key() + require( + observationSnapshots.all { observation -> + observation.identifier.count { + it.hasGroveRole(GroveIdentifierRole.SOURCE_RECORD) && it.hasSystem() && it.hasValue() + } == 1 && + observation.identifier.count { + it.hasGroveRole(GroveIdentifierRole.SOURCE_OUTPUT) && it.hasSystem() && it.hasValue() + } == 1 + }, + ) { "The journal must retain a complete snapshot of every derived Observation." } + require( + observationSnapshots.all { observation -> + observation.identifier.single { it.hasGroveRole(GroveIdentifierRole.SOURCE_RECORD) } + .equalsDeep(sourceRecordIdentifierSnapshot) + }, + ) { "Every journal Observation must identify the entry's exact source Record." } + require(bundleSnapshot.type == Bundle.BundleType.COLLECTION) { + "The journal must retain the complete collection Bundle for replay and invalidation." + } + bundleSnapshot.requireGroveEntryIdentitySelection() + bundleSnapshot.requireGroveReferencePolicy() + val retainedOutputIdentifiers = bundleSnapshot.groveOutputIdentifiers() + if (state == HealthConnectExportState.ACTIVE && retainedOutputIdentifiers.isNotEmpty()) { + bundleSnapshot.requireGroveActiveExchangeContract(sourceRecordIdentifierSnapshot) + } + val bundledObservations = bundleSnapshot.entry.mapNotNull { it.resource as? Observation } + require( + bundledObservations.size == observationSnapshots.size && + bundledObservations.sortedBy(::journalObservationKey) + .zip(observationSnapshots.sortedBy(::journalObservationKey)) + .all { (bundled, retained) -> bundled.equalsDeep(retained) }, + ) { "The journal Observation snapshot must exactly match its retained Bundle." } + val lifecycleSources = bundleSnapshot.entry + .mapNotNull { it.resource as? Provenance } + .flatMap(Provenance::getEntity) + .filter { it.role == Provenance.ProvenanceEntityRole.SOURCE } + .mapNotNull { entity -> + entity.what.identifier.takeIf { identifier -> identifier.hasSystem() && identifier.hasValue() } + } + val hasExactLifecycleSource = lifecycleSources.size == 1 && + lifecycleSources.single().equalsDeep(sourceRecordIdentifierSnapshot) + require( + (lifecycleSources.isEmpty() && bundleSnapshot.groveOutputIdentifiers().isEmpty()) || + hasExactLifecycleSource, + ) { + "A journal lifecycle source Identifier must exactly match its source state; only local zero-output state omits it." + } + require( + destinationReferenceSnapshot.isEmpty() || + destinationReferenceSnapshot.keys == retainedOutputIdentifiers.map(Identifier::key).toSet(), + ) { + "A completed journal entry must retain one destination reference for every output identifier." + } + require(destinationReferenceSnapshot.values.all { it.isNotBlank() }) { + "Destination references must not be blank." + } + require((state == HealthConnectExportState.INVALIDATED) == (invalidatedAt != null)) { + "Only invalidated journal entries carry an invalidation time." + } + } + + val sourceRecordIdentifier: Identifier + get() = sourceRecordIdentifierSnapshot.copy() + + val observations: List + get() = observationSnapshots.map(Observation::copy) + + val bundle: Bundle + get() = bundleSnapshot.copy() + + val destinationReferences: Map + get() = destinationReferenceSnapshot.toMap() + + val observationIdentifiers: List + get() = observationSnapshots.map { observation -> observationIdentity(observation).copy() } + + val outputIdentifiers: List + get() = bundleSnapshot.groveOutputIdentifiers().map(Identifier::copy) + + val revision: HealthConnectJournalRevision = HealthConnectJournalRevision( + "v1:${HealthConnectWireFormat.sha256(GroveExchangeProtocol.frameFields(revisionFields()))}", + ) + + @Suppress("LongParameterList") + fun copy( + repositoryScopeKey: ScopeKey = this.repositoryScopeKey, + projectionScopeKey: ScopeKey = this.projectionScopeKey, + recordType: String = this.recordType, + healthConnectId: String = this.healthConnectId, + dataOriginPackage: String = this.dataOriginPackage, + sourceLastModified: Instant = this.sourceLastModified, + conversionContractVersion: String = this.conversionContractVersion, + sourceRecordIdentifier: Identifier = this.sourceRecordIdentifier, + observations: List = this.observations, + bundle: Bundle = this.bundle, + destinationReferences: Map = this.destinationReferences, + lastEventSequence: EventSequence? = this.lastEventSequence, + state: HealthConnectExportState = this.state, + invalidatedAt: Instant? = this.invalidatedAt, + ): HealthConnectExportJournalEntry = HealthConnectExportJournalEntry( + repositoryScopeKey, + projectionScopeKey, + recordType, + healthConnectId, + dataOriginPackage, + sourceLastModified, + conversionContractVersion, + sourceRecordIdentifier, + observations, + bundle, + destinationReferences, + lastEventSequence, + state, + invalidatedAt, + ) + + private fun revisionFields(): List = buildList { + add(repositoryScopeKey.value) + add(projectionScopeKey.value) + add(recordType) + add(healthConnectId) + add(dataOriginPackage) + add(HealthConnectWireFormat.sourceVersion(sourceLastModified)) + add(conversionContractVersion) + add(journalIdentifierJson(sourceRecordIdentifierSnapshot)) + add(HealthConnectWireFormat.bundleJson(bundleSnapshot)) + destinationReferenceSnapshot.toSortedMap().forEach { (identifier, reference) -> + add(identifier.system) + add(identifier.value) + add(reference) + } + add(lastEventSequence?.value.orEmpty()) + add(state.name) + add(invalidatedAt?.let(HealthConnectWireFormat::sourceVersion).orEmpty()) + } +} + +/** Whether a journal entry still describes the record, or was superseded by a later export. */ +enum class HealthConnectExportState { + ACTIVE, + INVALIDATED, +} + +/** Whether an export carries a converted record or withdraws one the source has deleted. */ +enum class HealthConnectExportOperation(val wireValue: String) { + ACTIVE("active"), + RETRACTION("retraction"), +} + +/** + * A conversion that is ready to be handed to the outbox but has not yet been given its sequence. + * + * The draft computes and holds the exact serialized payload, so the checksum the sink acknowledges + * is the one that was built here rather than a re-serialization that could differ. + */ +@Suppress("LongParameterList") +class HealthConnectPendingExportDraft( + val repositoryScopeKey: ScopeKey, + val projectionScopeKey: ScopeKey, + val operation: HealthConnectExportOperation, + val recordType: String, + val healthConnectId: String, + sourceRecordIdentifier: Identifier, + val sourceVersion: Instant, + bundle: Bundle, + retractedTargets: Set, + nextEntry: HealthConnectExportJournalEntry, +) { + private val sourceRecordIdentifierSnapshot = sourceRecordIdentifier.copy() + private val bundleSnapshot = bundle.copy() + private val retractedTargetSnapshot = retractedTargets.toSet() + private val nextEntrySnapshot = nextEntry.copy() + + val sourceRecordIdentifier: Identifier + get() = sourceRecordIdentifierSnapshot.copy() + + val bundle: Bundle + get() = bundleSnapshot.copy() + + val retractedTargets: Set + get() = retractedTargetSnapshot.toSet() + + val nextEntry: HealthConnectExportJournalEntry + get() = nextEntrySnapshot.copy() + + val bundleJson: String = HealthConnectWireFormat.bundleJson(bundleSnapshot) + val payloadSha256: String = HealthConnectWireFormat.sha256(bundleJson) + + init { + require(recordType.isNotBlank() && healthConnectId.isNotBlank()) { + "A pending export must identify its source Record." + } + require( + sourceRecordIdentifierSnapshot.hasSystem() && sourceRecordIdentifierSnapshot.hasValue() && + sourceRecordIdentifierSnapshot.hasGroveRole(GroveIdentifierRole.SOURCE_RECORD), + ) { + "A pending export must carry the exact typed source-record identifier." + } + require(bundleSnapshot.type == Bundle.BundleType.COLLECTION && bundleSnapshot.entry.isNotEmpty()) { + "A pending export must retain a complete non-empty collection Bundle." + } + requirePendingTransition( + repositoryScopeKey, + projectionScopeKey, + operation, + recordType, + healthConnectId, + sourceRecordIdentifierSnapshot, + sourceVersion, + bundleSnapshot, + retractedTargetSnapshot, + nextEntrySnapshot, + ) + } +} + +/** + * A sequenced export waiting for its sink acknowledgement. + * + * The payload, its checksum, and the Bundle are all retained and cross-checked on construction: a + * stored row whose JSON no longer matches its Bundle is a corrupted outbox, not a resendable event. + */ +@Suppress("LongParameterList") +class HealthConnectPendingExport( + val eventSequence: EventSequence, + val baseRevision: HealthConnectJournalRevision?, + val repositoryScopeKey: ScopeKey, + val projectionScopeKey: ScopeKey, + val operation: HealthConnectExportOperation, + val recordType: String, + val healthConnectId: String, + sourceRecordIdentifier: Identifier, + val sourceVersion: Instant, + bundle: Bundle, + val bundleJson: String, + val payloadSha256: String, + retractedTargets: Set, + nextEntry: HealthConnectExportJournalEntry, +) { + private val sourceRecordIdentifierSnapshot = sourceRecordIdentifier.copy() + private val bundleSnapshot = bundle.copy() + private val retractedTargetSnapshot = retractedTargets.toSet() + private val nextEntrySnapshot = nextEntry.copy() + + val sourceRecordIdentifier: Identifier + get() = sourceRecordIdentifierSnapshot.copy() + + val bundle: Bundle + get() = bundleSnapshot.copy() + + val retractedTargets: Set + get() = retractedTargetSnapshot.toSet() + + val nextEntry: HealthConnectExportJournalEntry + get() = nextEntrySnapshot.copy() + + init { + require(payloadSha256 == HealthConnectWireFormat.sha256(bundleJson)) { + "The stored outbox checksum must match its exact UTF-8 Bundle JSON." + } + val parsedBundle = runCatching { JsonParser().parse(bundleJson) as? Bundle }.getOrNull() + require(parsedBundle != null && parsedBundle.equalsDeep(bundleSnapshot)) { + "The stored outbox Bundle must exactly match its authoritative JSON payload." + } + requirePendingTransition( + repositoryScopeKey, + projectionScopeKey, + operation, + recordType, + healthConnectId, + sourceRecordIdentifierSnapshot, + sourceVersion, + bundleSnapshot, + retractedTargetSnapshot, + nextEntrySnapshot, + ) + } + + internal fun batch(): HealthConnectExportBatch = HealthConnectExportBatch( + eventSequence = eventSequence, + operation = operation, + sourceRecordIdentifier = sourceRecordIdentifierSnapshot.copy(), + sourceVersion = sourceVersion, + bundle = bundleSnapshot.copy(), + bundleJson = bundleJson, + payloadSha256 = payloadSha256, + retractedTargets = retractedTargetSnapshot.toSet(), + ) + + /** Derives the only journal state that may complete this exact, validated outbox payload. */ + internal fun acknowledgedEntry( + destinationReferences: Map, + ): HealthConnectExportJournalEntry { + val next = nextEntrySnapshot + val acknowledged = when (next.state) { + HealthConnectExportState.ACTIVE -> { + val expected = next.outputIdentifiers.map(Identifier::key).toSet() + require(destinationReferences.keys == expected) { + "The sink must acknowledge one destination reference for every active output." + } + next.copy(destinationReferences = destinationReferences) + } + HealthConnectExportState.INVALIDATED, + -> { + require(destinationReferences.isEmpty()) { + "A retraction assertion cannot acknowledge active destination references." + } + next + } + } + return acknowledged.copy(lastEventSequence = eventSequence) + } + + @Suppress("LongParameterList") + fun copy( + eventSequence: EventSequence = this.eventSequence, + baseRevision: HealthConnectJournalRevision? = this.baseRevision, + repositoryScopeKey: ScopeKey = this.repositoryScopeKey, + projectionScopeKey: ScopeKey = this.projectionScopeKey, + operation: HealthConnectExportOperation = this.operation, + recordType: String = this.recordType, + healthConnectId: String = this.healthConnectId, + sourceRecordIdentifier: Identifier = this.sourceRecordIdentifier, + sourceVersion: Instant = this.sourceVersion, + bundle: Bundle = this.bundle, + bundleJson: String = this.bundleJson, + payloadSha256: String = this.payloadSha256, + retractedTargets: Set = this.retractedTargets, + nextEntry: HealthConnectExportJournalEntry = this.nextEntry, + ): HealthConnectPendingExport = HealthConnectPendingExport( + eventSequence, + baseRevision, + repositoryScopeKey, + projectionScopeKey, + operation, + recordType, + healthConnectId, + sourceRecordIdentifier, + sourceVersion, + bundle, + bundleJson, + payloadSha256, + retractedTargets, + nextEntry, + ) +} + +@Suppress("LongParameterList") +private fun requirePendingTransition( + repositoryScopeKey: ScopeKey, + projectionScopeKey: ScopeKey, + operation: HealthConnectExportOperation, + recordType: String, + healthConnectId: String, + sourceRecordIdentifier: Identifier, + sourceVersion: Instant, + bundle: Bundle, + retractedTargets: Set, + nextEntry: HealthConnectExportJournalEntry, +) { + require( + nextEntry.repositoryScopeKey == repositoryScopeKey && + nextEntry.projectionScopeKey == projectionScopeKey && + nextEntry.recordType == recordType && + nextEntry.healthConnectId == healthConnectId && + nextEntry.sourceLastModified == sourceVersion && + nextEntry.sourceRecordIdentifier.equalsDeep(sourceRecordIdentifier), + ) { "The pending transition and its next journal entry must identify the exact same source state." } + require(nextEntry.bundle.equalsDeep(bundle)) { + "The pending transition's next journal entry must retain its exact acknowledged Bundle." + } + require(nextEntry.destinationReferences.isEmpty()) { + "A staged next journal entry cannot contain destination references before acknowledgement." + } + when (operation) { + HealthConnectExportOperation.ACTIVE -> require( + nextEntry.state == HealthConnectExportState.ACTIVE && + nextEntry.invalidatedAt == null && retractedTargets.isEmpty(), + ) { "An active pending transition must produce active state without retraction targets." } + HealthConnectExportOperation.RETRACTION -> require( + nextEntry.state == HealthConnectExportState.INVALIDATED && + nextEntry.invalidatedAt != null && nextEntry.observations.isEmpty() && + retractedTargets.isNotEmpty(), + ) { "A retraction pending transition must produce invalidated state and exact targets." } + } +} + +private fun journalObservationKey(observation: Observation): FhirIdentifierKey = + observationIdentity(observation).key() + +private fun journalIdentifierJson(identifier: Identifier): String = + JsonParser().setOutputStyle(IParser.OutputStyle.NORMAL).composeString( + Parameters().apply { + addParameter().apply { + name = "identifier" + value = identifier.copy() + } + }, + ) + +/** A Health Connect deletion for a record this journal never exported, retained for audit. */ +data class HealthConnectUnmatchedDeletion( + val repositoryScopeKey: ScopeKey, + val projectionScopeKey: ScopeKey, + val recordType: String, + val healthConnectId: String, + val observedAt: Instant, +) { + init { + require(recordType.isNotBlank() && healthConnectId.isNotBlank()) { + "An unmatched deletion must identify its source Record." + } + } + + override fun toString(): String = + "HealthConnectUnmatchedDeletion(" + + "repositoryScopeKey=$repositoryScopeKey, projectionScopeKey=$projectionScopeKey, " + + "recordType=$recordType, healthConnectId=, observedAt=$observedAt)" +} + +/** A record the converter refused, kept so a deployment can see what its source data contains. */ +data class HealthConnectRejectedRecord( + val repositoryScopeKey: ScopeKey, + val projectionScopeKey: ScopeKey, + val recordType: String, + val healthConnectId: String, + val sourceLastModified: Instant, + val observedAt: Instant, + val reason: String, +) { + init { + require(recordType.isNotBlank() && healthConnectId.isNotBlank() && reason.isNotBlank()) { + "A rejected Record requires a type, source id, and nonempty reason." + } + } + + override fun toString(): String = + "HealthConnectRejectedRecord(" + + "repositoryScopeKey=$repositoryScopeKey, projectionScopeKey=$projectionScopeKey, " + + "recordType=$recordType, healthConnectId=, " + + "sourceLastModified=$sourceLastModified, observedAt=$observedAt, reason=$reason)" +} + +/** + * Raised when the journal holds entries written under a different conversion contract version. + * + * Re-exporting under a new contract is a deployment decision, so the producer stops rather than + * silently mixing two contract versions in one destination. + */ +class HealthConnectConversionContractMigrationRequired(recordType: String) : + IllegalStateException( + "A conversion-contract migration for $recordType requires an explicit scoped baseline.", + ) diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectFieldDispositions.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectFieldDispositions.kt new file mode 100644 index 00000000..bc2d8624 --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectFieldDispositions.kt @@ -0,0 +1,237 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +/** What this producer deliberately does with one public AndroidX Health Connect source field. */ +internal data class HealthConnectFieldDisposition( + val status: Status, + val fhirPath: String? = null, + val rationale: String? = null, +) { + enum class Status { MAPPED, INTENTIONALLY_OMITTED, REJECTED, UNAVAILABLE } + + init { + require((status == Status.MAPPED) == (fhirPath != null)) { + "A mapped source field requires one FHIR destination; other dispositions must not claim one." + } + require(status == Status.MAPPED || !rationale.isNullOrBlank()) { + "A non-mapped source field requires a reviewed rationale." + } + } +} + +/** + * Executable AndroidX 1.1.0 field inventory for every source type this producer calls supported. + * + * Tests compare these keys to the pinned bytecode's public getters, so an AndroidX field addition + * cannot silently inherit "supported" status. Nested source types are inventoried separately. + */ +@Suppress("LargeClass") +internal object HealthConnectFieldDispositions { + const val SOURCE_VERSION = "1.1.0" + + private fun mapped(path: String) = HealthConnectFieldDisposition( + HealthConnectFieldDisposition.Status.MAPPED, + fhirPath = path, + ) + + private fun omitted(reason: String) = HealthConnectFieldDisposition( + HealthConnectFieldDisposition.Status.INTENTIONALLY_OMITTED, + rationale = reason, + ) + + private val instant = mapOf( + "time" to mapped("Observation.effectiveDateTime"), + "zoneOffset" to mapped("Observation.effectiveDateTime lexical offset"), + "metadata" to mapped("Observation.identifier/issued/device/extension and Provenance.entity/agent"), + ) + private val interval = mapOf( + "startTime" to mapped("Observation.effectivePeriod.start"), + "startZoneOffset" to mapped("Observation.effectivePeriod.start lexical offset"), + "endTime" to mapped("Observation.effectivePeriod.end"), + "endZoneOffset" to mapped("Observation.effectivePeriod.end lexical offset"), + "metadata" to mapped("Observation.identifier/issued/device/extension and Provenance.entity/agent"), + ) + private fun instant(vararg fields: Pair) = + instant + fields.associate { (field, path) -> field to mapped(path) } + private fun interval(vararg fields: Pair) = + interval + fields.associate { (field, path) -> field to mapped(path) } + + private val nutrientFields = setOf( + "biotin", "caffeine", "calcium", "energy", "energyFromFat", "chloride", "cholesterol", "chromium", + "copper", "dietaryFiber", "folate", "folicAcid", "iodine", "iron", "magnesium", "manganese", + "molybdenum", "monounsaturatedFat", "niacin", "pantothenicAcid", "phosphorus", "polyunsaturatedFat", + "potassium", "protein", "riboflavin", "saturatedFat", "selenium", "sodium", "sugar", "thiamin", + "totalCarbohydrate", "totalFat", "transFat", "unsaturatedFat", "vitaminA", "vitaminB12", "vitaminB6", + "vitaminC", "vitaminD", "vitaminE", "vitaminK", "zinc", + ) + + val records: Map> = mapOf( + "ActiveCaloriesBurnedRecord" to interval("energy" to "Observation.valueQuantity"), + "BasalBodyTemperatureRecord" to instant( + "temperature" to "Observation.valueQuantity", + "measurementLocation" to "Observation.bodySite", + ), + "BasalMetabolicRateRecord" to instant("basalMetabolicRate" to "Observation.valueQuantity"), + "BloodGlucoseRecord" to instant( + "level" to "Observation.valueQuantity", + "specimenSource" to "Observation.specimen and specimen-specific profile/code", + "mealType" to "health-connect-glucose-meal-context extension", + "relationToMeal" to "health-connect-glucose-meal-context extension", + ), + "BloodPressureRecord" to instant( + "systolic" to "Observation.component[systolic].valueQuantity", + "diastolic" to "Observation.component[diastolic].valueQuantity", + "bodyPosition" to "observation-bodyPosition extension", + "measurementLocation" to "Observation.bodySite", + ), + "BodyFatRecord" to instant("percentage" to "Observation.valueQuantity"), + "BodyTemperatureRecord" to instant( + "temperature" to "Observation.valueQuantity", + "measurementLocation" to "Observation.bodySite", + ), + "BodyWaterMassRecord" to instant("mass" to "Observation.valueQuantity"), + "BoneMassRecord" to instant("mass" to "Observation.valueQuantity"), + "CervicalMucusRecord" to instant( + "appearance" to "Observation.valueCodeableConcept", + "sensation" to "Observation.component[cervical-mucus-sensation]", + ), + "CyclingPedalingCadenceRecord" to interval("samples" to "one Observation per Sample"), + "DistanceRecord" to interval("distance" to "Observation.valueQuantity"), + "ElevationGainedRecord" to interval("elevation" to "Observation.valueQuantity"), + "ExerciseSessionRecord" to interval( + "exerciseType" to "Observation.valueCodeableConcept", + "title" to "health-connect-session-title extension when retention policy admits it", + "notes" to "Observation.note.text when retention policy admits it", + "segments" to "one workout-segment member Observation per ExerciseSegment", + "laps" to "one workout-segment member Observation per ExerciseLap", + ) + mapOf( + "exerciseRouteResult" to omitted( + "Grove 0.6.0 admits no safe route geometry profile; route data requires a separately reviewed source artifact.", + ), + "plannedExerciseSessionId" to omitted( + "The referenced PlannedExerciseSessionRecord source type is explicitly deferred in AndroidX 1.1.0 support.", + ), + ), + "FloorsClimbedRecord" to interval("floors" to "Observation.valueQuantity"), + "HeartRateRecord" to interval("samples" to "one Observation per Sample"), + "HeartRateVariabilityRmssdRecord" to instant( + "heartRateVariabilityMillis" to "Observation.valueQuantity", + ), + "HeightRecord" to instant("height" to "Observation.valueQuantity"), + "HydrationRecord" to interval("volume" to "Observation.valueQuantity"), + "IntermenstrualBleedingRecord" to instant(), + "LeanBodyMassRecord" to instant("mass" to "Observation.valueQuantity"), + "MenstruationFlowRecord" to instant("flow" to "Observation.valueCodeableConcept"), + "MenstruationPeriodRecord" to interval(), + "MindfulnessSessionRecord" to interval( + "mindfulnessSessionType" to "Observation.method", + "title" to "health-connect-session-title extension when retention policy admits it", + "notes" to "Observation.note.text when retention policy admits it", + ), + "NutritionRecord" to nutrition(), + "OvulationTestRecord" to instant("result" to "Observation.valueCodeableConcept"), + "OxygenSaturationRecord" to instant("percentage" to "Observation.valueQuantity"), + "PowerRecord" to interval("samples" to "one Observation per Sample"), + "RespiratoryRateRecord" to instant("rate" to "Observation.valueQuantity"), + "RestingHeartRateRecord" to instant("beatsPerMinute" to "Observation.valueQuantity"), + "SexualActivityRecord" to instant("protectionUsed" to "Observation.valueCodeableConcept"), + "SkinTemperatureRecord" to interval( + "deltas" to "one Observation per Delta", + "baseline" to "combined with Delta for Observation.valueQuantity", + "measurementLocation" to "Observation.bodySite", + ), + "SleepSessionRecord" to interval( + "title" to "health-connect-session-title extension when retention policy admits it", + "notes" to "Observation.note.text when retention policy admits it", + "stages" to "one member Observation per Stage in exact platform-list occurrence order", + ), + "SpeedRecord" to interval("samples" to "one Observation per Sample"), + "StepsCadenceRecord" to interval("samples" to "one Observation per Sample"), + "StepsRecord" to interval("count" to "Observation.valueQuantity"), + "TotalCaloriesBurnedRecord" to interval("energy" to "Observation.valueQuantity"), + "Vo2MaxRecord" to instant( + "vo2MillilitersPerMinuteKilogram" to "Observation.valueQuantity", + "measurementMethod" to "Observation.method", + ), + "WeightRecord" to instant("weight" to "Observation.valueQuantity"), + "WheelchairPushesRecord" to interval("count" to "Observation.valueQuantity"), + ) + + val metadata: Map = mapOf( + "recordingMethod" to mapped("grove-recording-method extension"), + "id" to mapped( + "opaque source-record/source-output HMAC input; optional governed Identifier on the designated primary output", + ), + "dataOrigin" to mapped("source-application Provenance agent and writer-application identity"), + "lastModifiedTime" to mapped("Observation.issued and durable source-version state"), + "clientRecordId" to mapped("opaque writer-record identifier"), + "clientRecordVersion" to mapped("grove-writer-record-version extension"), + "device" to mapped("explicitly governed recording Device reference when a stable per-unit token exists"), + ) + + val nested: Map> = mapOf( + "HeartRateRecord.Sample" to mapOf( + "time" to mapped("Observation.effectiveDateTime and source-list occurrence output discriminator"), + "beatsPerMinute" to mapped("Observation.valueQuantity"), + ), + "CyclingPedalingCadenceRecord.Sample" to mapOf( + "time" to mapped("Observation.effectiveDateTime and source-list occurrence output discriminator"), + "revolutionsPerMinute" to mapped("Observation.valueQuantity"), + ), + "PowerRecord.Sample" to mapOf( + "time" to mapped("Observation.effectiveDateTime and source-list occurrence output discriminator"), + "power" to mapped("Observation.valueQuantity"), + ), + "SpeedRecord.Sample" to mapOf( + "time" to mapped("Observation.effectiveDateTime and source-list occurrence output discriminator"), + "speed" to mapped("Observation.valueQuantity"), + ), + "StepsCadenceRecord.Sample" to mapOf( + "time" to mapped("Observation.effectiveDateTime and source-list occurrence output discriminator"), + "rate" to mapped("Observation.valueQuantity"), + ), + "SleepSessionRecord.Stage" to mapOf( + "startTime" to mapped("member Observation.effectivePeriod.start and occurrence discriminator"), + "endTime" to mapped("member Observation.effectivePeriod.end and occurrence discriminator"), + "stage" to mapped("member Observation.valueCodeableConcept and occurrence discriminator"), + ), + "SkinTemperatureRecord.Delta" to mapOf( + "time" to mapped("Observation.effectiveDateTime and source-list occurrence output discriminator"), + "delta" to mapped("combined with baseline for Observation.valueQuantity"), + ), + "ExerciseSegment" to mapOf( + "startTime" to mapped("member Observation.effectivePeriod.start"), + "endTime" to mapped("member Observation.effectivePeriod.end"), + "segmentType" to mapped("member Observation.valueCodeableConcept"), + "repetitions" to mapped("member Observation.component[repetitions]"), + ), + "ExerciseLap" to mapOf( + "startTime" to mapped("member Observation.effectivePeriod.start"), + "endTime" to mapped("member Observation.effectivePeriod.end"), + "length" to mapped("member Observation.component[lap-length]"), + ), + ) + + init { + check(records.keys == HealthConnectCatalog.supportedRecordTypeIdentifiers) { + "Every supported Health Connect type requires one field-disposition inventory." + } + } + + private fun nutrition(): Map = interval + + nutrientFields.associateWith { mapped("one nutrient Observation.valueQuantity when present") } + + mapOf( + "name" to omitted( + "Free-text meal names are not retained without a dedicated deployment privacy policy and profile element.", + ), + "mealType" to omitted( + "The current nutrition profiles describe nutrient results and do not define meal-event context semantics.", + ), + ) +} diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectGraphAssembly.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectGraphAssembly.kt new file mode 100644 index 00000000..687db4c2 --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectGraphAssembly.kt @@ -0,0 +1,221 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import androidx.health.connect.client.records.metadata.Metadata +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.CodeType +import org.hl7.fhir.r4.model.DateTimeType +import org.hl7.fhir.r4.model.Extension +import org.hl7.fhir.r4.model.InstantType +import org.hl7.fhir.r4.model.Observation +import org.hl7.fhir.r4.model.Period +import org.hl7.fhir.r4.model.Provenance +import org.hl7.fhir.r4.model.Reference +import org.hl7.fhir.r4.model.Resource +import org.hl7.fhir.r4.model.Type +import java.time.Instant + +@Suppress("LongParameterList") +internal fun HealthConnectConverter.conversion( + metadata: Metadata, + recordType: String, + source: HealthConnectSourceIdentity, + observations: List, + convertedAt: Instant, + eventSequence: EventSequence, + resolvedContext: ResolvedFhirContext, + conversionResources: List> = emptyList(), +): HealthConnectConversion { + if (convertedAt < metadata.lastModifiedTime) { + throw InvalidHealthConnectRecord( + "The conversion event cannot precede the source version's lastModifiedTime.", + ) + } + attachRecordTypeLineage(observations, recordType) + val provenance = observations.takeIf { it.isNotEmpty() }?.let { + conversionProvenance( + it, + conversionResources, + source, + sourceActivityTime(it), + convertedAt, + resolvedContext, + ) + } + val bundle = bundle( + observations, + provenance, + convertedAt, + eventSequence, + resolvedContext, + conversionResources, + ) + return HealthConnectConversion( + conversionContractVersion = synchronizationScope.conversionContractVersion, + sourceRecordIdentifier = source.identifier, + sourceRecordType = recordType, + sourceLastModified = metadata.lastModifiedTime, + observations = observations, + provenance = provenance, + bundle = bundle, + ) +} +internal fun HealthConnectConverter.attachRecordTypeLineage(observations: List, recordType: String) { + observations.forEach { observation -> + check( + observation.getExtensionsByUrl( + HealthConnectContract.HEALTH_CONNECT_RECORD_TYPE_EXTENSION, + ).isEmpty(), + ) { "Health Connect Record-type lineage must be assigned exactly once." } + observation.addExtension( + Extension( + HealthConnectContract.HEALTH_CONNECT_RECORD_TYPE_EXTENSION, + CodeType(recordType), + ), + ) + } +} + +internal fun HealthConnectConverter.conversionProvenance( + outputs: List, + conversionResources: List>, + source: HealthConnectSourceIdentity, + occurredAt: Type, + convertedAt: Instant, + resolvedContext: ResolvedFhirContext, +): Provenance = Provenance().apply { + meta.addProfile(HealthConnectContract.HEALTH_CONNECT_PROVENANCE_PROFILE) + occurred = occurredAt.copy() as Type + recordedElement = InstantType(convertedAt.toString()) + activity = concept( + HealthConnectContract.RECORD_LIFECYCLE, + "transform", + "Transform/Translate Record Lifecycle Event", + ) + addAgent().apply { + type = concept(HealthConnectContract.PROVENANCE_PARTICIPANT, "assembler", "Assembler") + who = resolvedContext.assembler.copy() + } + outputs.forEach { observation -> + addTarget( + Reference().apply { + reference = GroveExchangeIdentity.fullUrl(outputIdentifier(observation)) + type = "Observation" + identifier = outputIdentifier(observation).copy() + }, + ) + } + conversionResources.forEach { output -> + addTarget( + Reference().apply { + reference = output.fullUrl + type = output.resource.fhirType() + identifier = output.entryIdentifier.copy() + }, + ) + } + addEntity().apply { + role = Provenance.ProvenanceEntityRole.SOURCE + what = Reference().apply { identifier = source.identifier.copy() } + addAgent().apply { + type = concept(HealthConnectContract.PROVENANCE_PARTICIPANT, "enterer", "Enterer") + who = resolvedContext.dataOriginApplication.copy() + } + } +} + +/** + * Derives Provenance.occurred[x] from the clinical source activity, never the source row's + * administrative modification time. A multi-point series uses the exact emitted point span; + * an interval output preserves its broadest exact effective Period. + */ +internal fun HealthConnectConverter.sourceActivityTime(outputs: List): Type { + val dateTimes = outputs.mapNotNull { it.effective as? DateTimeType } + val periods = outputs.mapNotNull { it.effective as? Period } + check(dateTimes.size + periods.size == outputs.size) { + "Every Health Connect output requires an effective dateTime or Period." + } + if (periods.isNotEmpty()) { + val starts = periods.map { it.startElement } + dateTimes + val ends = periods.map { it.endElement } + dateTimes + return Period().apply { + startElement = starts.minWith(HealthConnectConverter.EFFECTIVE_DATE_TIME_ORDER).copy() + endElement = ends.maxWith(HealthConnectConverter.EFFECTIVE_DATE_TIME_ORDER).copy() + } + } + check(dateTimes.isNotEmpty()) { "Conversion Provenance requires at least one source activity time." } + val sorted = dateTimes.sortedWith(HealthConnectConverter.EFFECTIVE_DATE_TIME_ORDER) + return if (sorted.first().valueAsString == sorted.last().valueAsString) { + sorted.first().copy() + } else { + Period().apply { + startElement = sorted.first().copy() + endElement = sorted.last().copy() + } + } +} + +@Suppress("LongParameterList") +internal fun HealthConnectConverter.bundle( + observations: List, + provenance: Provenance?, + convertedAt: Instant, + eventSequence: EventSequence, + resolvedContext: ResolvedFhirContext, + conversionResources: List>, +): Bundle = Bundle().apply { + identifier = bundleIdentifier(eventSequence) + meta.addProfile(HealthConnectContract.MOBILE_EXCHANGE_BUNDLE_PROFILE) + type = Bundle.BundleType.COLLECTION + timestampElement = InstantType(convertedAt.toString()) + resolvedContext.resources.forEach { resolved -> + addGroveEntry(resolved.entryIdentifier, resolved.resource.copy()) + } + conversionResources.forEach { resolved -> + addGroveEntry(resolved.entryIdentifier, resolved.resource.copy()) + } + observations.sortedBy { outputIdentifier(it).value }.forEach { observation -> + addGroveEntry(outputIdentifier(observation), observation.copy()) + } + provenance?.let { + addGroveEntry( + HealthConnectIdentity.conversionNode( + context.entryNodeIdentifierSystem, + identifier, + ), + it.copy(), + ) + } + check(entry.map { it.fullUrl }.distinct().size == entry.size) { + "A Grove exchange Bundle cannot contain duplicate fullUrl values." + } +} + +internal fun HealthConnectConverter.sourceIdentity( + metadata: Metadata, + recordTypeToken: String, +): HealthConnectSourceIdentity { + val packageName = metadata.dataOrigin.packageName + val invalidReason = when { + metadata.id.isBlank() -> + "Health Connect metadata.id is absent; convert records only after reading them." + packageName.isBlank() -> + "Health Connect dataOrigin.packageName is absent." + !metadata.lastModifiedTime.isAfter(Instant.EPOCH) -> + "Health Connect lastModifiedTime must be a post-insertion instant after the Unix epoch." + metadata.lastModifiedTime > HealthConnectWireFormat.MAX_FHIR_INSTANT -> + "Health Connect lastModifiedTime must have a four-digit FHIR year no later than 9999." + else -> null + } + invalidReason?.let { throw InvalidHealthConnectRecord(it) } + requireSourceScalarText(metadata.id, "Health Connect metadata.id") + requireSourceScalarText(packageName, "Health Connect dataOrigin.packageName") + metadata.validatedClientRecordId() + return synchronizationScope.sourceRecordIdentifier(recordTypeToken, metadata.id) +} diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectIdentity.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectIdentity.kt new file mode 100644 index 00000000..6b30718d --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectIdentity.kt @@ -0,0 +1,385 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import org.hl7.fhir.r4.model.Identifier +import java.security.MessageDigest +import java.time.Instant +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeFormatterBuilder +import java.util.Base64 + +/** Domain-separated opaque identities defined by the Grove Health Connect 0.6.0 contract. */ +@Suppress("TooManyFunctions") +internal object HealthConnectIdentity { + fun record( + key: GroveHmacIdentityKey, + repositoryScope: FhirIdentifierKey, + recordType: String, + rawRecordId: String, + ): HealthConnectSourceIdentity { + require(recordType in HealthConnectCatalog.allRecordTypeIdentifiers) { + "Record type must belong to the closed Health Connect 1.1.0 inventory." + } + require(rawRecordId.isNotEmpty()) { "Health Connect raw record id must not be empty." } + return HealthConnectSourceIdentity( + adapterId = ADAPTER_ID, + sourceType = recordType, + repositoryScope = repositoryScope, + nativeRecordId = rawRecordId, + identifier = key.identifier( + GroveOpaqueIdentityKind.SOURCE_RECORD, + ADAPTER_ID, + recordType, + repositoryScope.system, + repositoryScope.value, + rawRecordId, + ), + ) + } + + /** A Device instance requires a governed per-unit token; model/manufacturer are not identity. */ + fun recordingDevice( + key: GroveHmacIdentityKey, + subjectKey: FhirIdentifierKey, + stablePerUnitToken: String, + ): Identifier { + require(stablePerUnitToken.isNotBlank()) { + "A recording Device instance requires a stable, explicitly governed per-unit token." + } + return key.identifier( + GroveOpaqueIdentityKind.RECORDING_DEVICE, + ADAPTER_ID, + subjectKey.system, + subjectKey.value, + stablePerUnitToken, + ) + } + + /** Event-bound immutable application/host facts; never a mutable long-lived Device identity. */ + fun deviceSnapshot( + key: GroveHmacIdentityKey, + event: Identifier, + deviceRole: String, + sourceDeviceToken: String, + ): Identifier { + require(event.hasSystem() && event.hasValue() && event.hasGroveRole(GroveIdentifierRole.EVENT)) { + "A Device snapshot requires its complete typed exchange-event Identifier." + } + require(deviceRole.matches(DEVICE_ROLE)) { + "A Device snapshot role must use the closed lowercase token grammar." + } + GroveUnicode.requireScalarText(sourceDeviceToken, "Device snapshot source token") + require(sourceDeviceToken.isNotBlank()) { "A Device snapshot requires a nonblank source token." } + return key.identifier( + GroveOpaqueIdentityKind.DEVICE_SNAPSHOT, + event.system, + event.value, + deviceRole, + sourceDeviceToken, + ) + } + + fun writerRecord( + key: GroveHmacIdentityKey, + writerApplication: FhirIdentifierKey, + clientRecordId: String, + ): Identifier = key.identifier( + GroveOpaqueIdentityKind.WRITER_RECORD, + writerApplication.system, + writerApplication.value, + clientRecordId, + ) + + fun heartRateSampleOutput( + key: GroveHmacIdentityKey, + source: HealthConnectSourceIdentity, + sampleTime: Instant, + duplicateOccurrence: Int, + ): Identifier = sampleOutput( + key, + source, + sampleTime, + duplicateOccurrence, + ) + + fun seriesSampleOutput( + key: GroveHmacIdentityKey, + source: HealthConnectSourceIdentity, + sampleTime: Instant, + duplicateOccurrence: Int, + ): Identifier = sampleOutput( + key, + source, + sampleTime, + duplicateOccurrence, + ) + + private fun sampleOutput( + key: GroveHmacIdentityKey, + source: HealthConnectSourceIdentity, + sampleTime: Instant, + duplicateOccurrence: Int, + ): Identifier { + require(duplicateOccurrence >= 0) { "Sample identity requires an unsigned duplicate occurrence." } + return key.identifier( + GroveOpaqueIdentityKind.SOURCE_OUTPUT, + source.components + listOf( + SAMPLE_OUTPUT_ROLE, + listOf(sampleTime.utc9(), duplicateOccurrence.toString()) + .joinToString(OUTPUT_DISCRIMINATOR_SEPARATOR), + ), + ) + } + + fun nutrientOutput( + key: GroveHmacIdentityKey, + source: HealthConnectSourceIdentity, + nutrientToken: String, + ): Identifier { + require(nutrientToken in NUTRIENT_TOKENS) { + "Nutrient identity requires an admitted Health Connect dietary measurement token." + } + return key.identifier( + GroveOpaqueIdentityKind.SOURCE_OUTPUT, + source.components + listOf(PRESENT_FIELD_OUTPUT_ROLE, nutrientToken), + ) + } + + fun sleepStageOutput( + key: GroveHmacIdentityKey, + source: HealthConnectSourceIdentity, + start: Instant, + end: Instant, + sourceStageToken: String, + duplicateOccurrence: Int, + ): Identifier { + require(sourceStageToken in SLEEP_STAGE_TOKENS) { + "Sleep-stage identity requires an exact Health Connect 1.1.0 stage token." + } + require(duplicateOccurrence >= 0) { "Sleep-stage identity requires an unsigned duplicate occurrence." } + return key.identifier( + GroveOpaqueIdentityKind.SOURCE_OUTPUT, + source.components + listOf( + SLEEP_STAGE_OUTPUT_ROLE, + listOf(start.utc9(), end.utc9(), sourceStageToken, duplicateOccurrence.toString()) + .joinToString(OUTPUT_DISCRIMINATOR_SEPARATOR), + ), + ) + } + + fun segmentOutput( + key: GroveHmacIdentityKey, + source: HealthConnectSourceIdentity, + start: Instant, + end: Instant, + sourceSegmentToken: String, + duplicateOccurrence: Int, + ): Identifier { + require(sourceSegmentToken in HealthConnectWorkoutVocabulary.segmentIdentityTokens) { + "Workout-segment identity requires an exact Health Connect 1.1.0 segment token." + } + require(duplicateOccurrence >= 0) { "Workout-segment identity requires an unsigned duplicate occurrence." } + return key.identifier( + GroveOpaqueIdentityKind.SOURCE_OUTPUT, + source.components + listOf( + WORKOUT_SEGMENT_OUTPUT_ROLE, + listOf(start.utc9(), end.utc9(), sourceSegmentToken, duplicateOccurrence.toString()) + .joinToString(OUTPUT_DISCRIMINATOR_SEPARATOR), + ), + ) + } + + fun specimenOutput( + key: GroveHmacIdentityKey, + source: HealthConnectSourceIdentity, + sourceSpecimenToken: String, + ): Identifier { + require(sourceSpecimenToken in SPECIMEN_TOKENS) { + "Specimen identity requires an admitted Health Connect specimen token." + } + return key.identifier( + GroveOpaqueIdentityKind.SOURCE_OUTPUT, + source.components + listOf(SPECIMEN_OUTPUT_ROLE, sourceSpecimenToken), + ) + } + + fun singleOutput( + key: GroveHmacIdentityKey, + source: HealthConnectSourceIdentity, + measurementId: String, + ): Identifier { + require(MEASUREMENT_ID.matches(measurementId)) { + "An exactly-one output requires its canonical measurement id." + } + return key.identifier( + GroveOpaqueIdentityKind.SOURCE_OUTPUT, + source.components + listOf(SINGLE_OUTPUT_ROLE, measurementId), + ) + } + + fun exchange( + eventSystem: String, + producerInstance: String, + eventSequence: EventSequence, + ): Identifier { + requireAbsoluteSystem(eventSystem, "event Identifier system") + require(PRODUCER_INSTANCE.matches(producerInstance)) { + "Producer instance must use canonical lowercase RFC 4122 UUID text." + } + return Identifier().apply { + system = eventSystem + value = "e2:$producerInstance:${eventSequence.value}" + type = org.hl7.fhir.r4.model.CodeableConcept( + org.hl7.fhir.r4.model.Coding( + HealthConnectContract.GROVE_IDENTIFIER_ROLE, + GroveIdentifierRole.EVENT.code, + GroveIdentifierRole.EVENT.display, + ), + ) + } + } + + fun conversionNode( + entryNodeSystem: String, + event: Identifier, + ): Identifier = eventNode(entryNodeSystem, event, "conversion-provenance", 0) + + fun retractionNode( + entryNodeSystem: String, + event: Identifier, + ): Identifier = eventNode(entryNodeSystem, event, "retraction-provenance", 0) + + /** Names a resource without a protocol-selected business identity inside one immutable event. */ + fun contextNode( + entryNodeSystem: String, + event: Identifier, + resourceRole: String, + ordinal: Int, + ): Identifier = eventNode(entryNodeSystem, event, resourceRole, ordinal) + + private fun eventNode( + entryNodeSystem: String, + event: Identifier, + resourceRole: String, + ordinal: Int, + ): Identifier { + requireAbsoluteSystem(entryNodeSystem, "entry-node Identifier system") + require(event.hasSystem() && event.hasValue() && event.hasGroveRole(GroveIdentifierRole.EVENT)) { + "An entry node requires its complete typed exchange-event Identifier." + } + require(resourceRole.matches(Regex("[a-z][a-z0-9-]*")) && ordinal >= 0) { + "Entry-node role and ordinal must use the closed lexical grammar." + } + val digest = framedSha256( + listOf( + ENTRY_NODE_DOMAIN, + event.system, + event.value, + resourceRole, + ordinal.toString(), + ), + ) + return Identifier().apply { + system = entryNodeSystem + value = "n2:$resourceRole:$ordinal:$digest" + type = org.hl7.fhir.r4.model.CodeableConcept( + org.hl7.fhir.r4.model.Coding( + HealthConnectContract.GROVE_IDENTIFIER_ROLE, + GroveIdentifierRole.ENTRY_NODE.code, + GroveIdentifierRole.ENTRY_NODE.display, + ), + ) + } + } + + private fun framedSha256(fields: List): String { + val preimage = GroveExchangeProtocol.frameFields(fields) + return Base64.getUrlEncoder().withoutPadding() + .encodeToString(MessageDigest.getInstance("SHA-256").digest(preimage)) + } + + private fun requireAbsoluteSystem(system: String, field: String) { + GroveUnicode.requireScalarText(system, field) + require(system.isAbsoluteAsciiUri()) { + "$field must be a deployment-owned absolute ASCII RFC 3986 URI." + } + } + + private fun Instant.utc9(): String { + HealthConnectWireFormat.requireFhirInstant(this, "Health Connect identity instant") + return UTC_NANOSECOND.format(this) + } + + private val UTC_NANOSECOND: DateTimeFormatter = + DateTimeFormatterBuilder().appendInstant(INSTANT_FRACTION_DIGITS).toFormatter() + private val PRODUCER_INSTANCE = Regex( + "[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", + ) + private val DEVICE_ROLE = Regex("[a-z][a-z0-9-]*") + private val MEASUREMENT_ID = Regex("[a-z][a-z0-9-]*") + private const val ENTRY_NODE_DOMAIN = "org.grovealliance.fhir.entry-node.v2" + private const val ADAPTER_ID = "health-connect" + private const val OUTPUT_DISCRIMINATOR_SEPARATOR = "|" + private const val SINGLE_OUTPUT_ROLE = "single" + private const val SAMPLE_OUTPUT_ROLE = "sample" + private const val SLEEP_STAGE_OUTPUT_ROLE = "sleep-stage" + private const val PRESENT_FIELD_OUTPUT_ROLE = "present-field" + private const val SPECIMEN_OUTPUT_ROLE = "specimen" + private const val WORKOUT_SEGMENT_OUTPUT_ROLE = "workout-segment" + + private val NUTRIENT_TOKENS = + HealthConnectContract.mobileDietaryProfiles.keys + setOf( + "dietary-energy-from-fat", + "dietary-fat-trans", + "dietary-fat-unsaturated", + "dietary-folic-acid", + ) + + private val SLEEP_STAGE_TOKENS = setOf( + "STAGE_TYPE_UNKNOWN", + "STAGE_TYPE_AWAKE", + "STAGE_TYPE_SLEEPING", + "STAGE_TYPE_OUT_OF_BED", + "STAGE_TYPE_LIGHT", + "STAGE_TYPE_DEEP", + "STAGE_TYPE_REM", + "STAGE_TYPE_AWAKE_IN_BED", + ) + private val SPECIMEN_TOKENS = setOf( + "SPECIMEN_SOURCE_WHOLE_BLOOD", + "SPECIMEN_SOURCE_CAPILLARY_BLOOD", + "SPECIMEN_SOURCE_PLASMA", + "SPECIMEN_SOURCE_SERUM", + "SPECIMEN_SOURCE_INTERSTITIAL_FLUID", + ) + private const val INSTANT_FRACTION_DIGITS = 9 +} + +/** The exact catalog components and opaque identifier for one source record. */ +internal data class HealthConnectSourceIdentity( + val adapterId: String, + val sourceType: String, + val repositoryScope: FhirIdentifierKey, + val nativeRecordId: String, + val identifier: Identifier, +) { + val components: List + get() = listOf( + adapterId, + sourceType, + repositoryScope.system, + repositoryScope.value, + nativeRecordId, + ) + + override fun toString(): String = + "HealthConnectSourceIdentity(" + + "adapterId=$adapterId, sourceType=$sourceType, " + + "repositoryScope=$repositoryScope, nativeRecordId=, identifier=)" +} diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectMappingSupport.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectMappingSupport.kt new file mode 100644 index 00000000..6cfd0beb --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectMappingSupport.kt @@ -0,0 +1,224 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import androidx.health.connect.client.records.BloodGlucoseRecord +import androidx.health.connect.client.records.HeartRateRecord +import androidx.health.connect.client.records.SleepSessionRecord +import androidx.health.connect.client.records.metadata.Metadata +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.Extension +import org.hl7.fhir.r4.model.Identifier +import org.hl7.fhir.r4.model.Quantity +import java.math.BigDecimal +import java.math.RoundingMode +import java.time.Instant +import java.time.ZoneOffset + +internal fun requireSourceScalarText(value: String, field: String): String = try { + GroveUnicode.requireScalarText(value, field) +} catch (error: IllegalArgumentException) { + throw InvalidHealthConnectRecord(requireNotNull(error.message), error) +} + +/** Validates the all-or-nothing writer identity represented by AndroidX Metadata. */ +internal fun Metadata.validatedClientRecordId(): String? { + val id = clientRecordId ?: return null + if (id.isBlank()) { + throw InvalidHealthConnectRecord( + "Health Connect metadata.clientRecordId must not be blank when present.", + ) + } + requireSourceScalarText(id, "Health Connect metadata.clientRecordId") + if (clientRecordVersion < 0) { + throw InvalidHealthConnectRecord( + "Health Connect metadata.clientRecordVersion must be non-negative when clientRecordId is present.", + ) + } + return id +} + +internal fun validate(record: HeartRateRecord) { + val invalidReason = when { + record.startTime.isAfter(record.endTime) -> + "HeartRateRecord startTime must not be after endTime." + record.samples.any { it.time < record.startTime || it.time > record.endTime } -> + "Heart-rate sample lies outside its source record interval." + else -> null + } + invalidReason?.let { throw InvalidHealthConnectRecord(it) } +} + +internal fun HealthConnectConverter.sampleIdentifier( + sourceIdentifier: HealthConnectSourceIdentity, + sample: HeartRateRecord.Sample, + occurrence: Int, +): Identifier = HealthConnectIdentity.heartRateSampleOutput( + synchronizationScope.identityKey, + sourceIdentifier, + sample.time, + occurrence, +) + +internal fun HealthConnectConverter.sleepStageIdentifier( + sourceIdentifier: HealthConnectSourceIdentity, + stage: SleepSessionRecord.Stage, + occurrence: Int, +): Identifier = HealthConnectIdentity.sleepStageOutput( + synchronizationScope.identityKey, + sourceIdentifier, + stage.startTime, + stage.endTime, + sleepStageCoding(stage.stage).sourceCode, + occurrence, +) + +@Suppress("LongMethod") +internal fun bloodGlucoseDefinition(specimenSource: Int): BloodGlucoseDefinition = when (specimenSource) { + BloodGlucoseRecord.SPECIMEN_SOURCE_WHOLE_BLOOD -> BloodGlucoseDefinition( + measurement = "blood-glucose", + profile = HealthConnectContract.HEALTH_CONNECT_WHOLE_BLOOD_GLUCOSE_PROFILE, + loinc = "2339-0", + loincDisplay = "Glucose [Mass/volume] in Blood", + specimenSourceCode = "SPECIMEN_SOURCE_WHOLE_BLOOD", + specimenType = Coding( + HealthConnectContract.SNOMED_CT, + "258580003", + "Whole blood sample", + ), + ) + BloodGlucoseRecord.SPECIMEN_SOURCE_CAPILLARY_BLOOD -> BloodGlucoseDefinition( + measurement = "capillary-blood-glucose", + profile = HealthConnectContract.HEALTH_CONNECT_CAPILLARY_BLOOD_GLUCOSE_PROFILE, + loinc = "32016-8", + loincDisplay = "Glucose [Mass/volume] in Capillary blood", + specimenSourceCode = "SPECIMEN_SOURCE_CAPILLARY_BLOOD", + specimenType = Coding( + HealthConnectContract.SNOMED_CT, + "122554006", + "Capillary blood specimen", + ), + ) + BloodGlucoseRecord.SPECIMEN_SOURCE_PLASMA -> BloodGlucoseDefinition( + measurement = "serum-plasma-glucose", + profile = HealthConnectContract.HEALTH_CONNECT_SERUM_PLASMA_GLUCOSE_PROFILE, + loinc = "2345-7", + loincDisplay = "Glucose [Mass/volume] in Serum or Plasma", + specimenSourceCode = "SPECIMEN_SOURCE_PLASMA", + specimenType = Coding( + HealthConnectContract.SNOMED_CT, + "119361006", + "Plasma specimen", + ), + ) + BloodGlucoseRecord.SPECIMEN_SOURCE_SERUM -> BloodGlucoseDefinition( + measurement = "serum-plasma-glucose", + profile = HealthConnectContract.HEALTH_CONNECT_SERUM_PLASMA_GLUCOSE_PROFILE, + loinc = "2345-7", + loincDisplay = "Glucose [Mass/volume] in Serum or Plasma", + specimenSourceCode = "SPECIMEN_SOURCE_SERUM", + specimenType = Coding( + HealthConnectContract.SNOMED_CT, + "119364003", + "Serum specimen", + ), + ) + BloodGlucoseRecord.SPECIMEN_SOURCE_INTERSTITIAL_FLUID -> BloodGlucoseDefinition( + measurement = "interstitial-glucose", + profile = HealthConnectContract.HEALTH_CONNECT_INTERSTITIAL_GLUCOSE_PROFILE, + loinc = "99504-3", + loincDisplay = "Glucose [Mass/volume] in Interstitial fluid", + specimenSourceCode = "SPECIMEN_SOURCE_INTERSTITIAL_FLUID", + specimenType = Coding( + HealthConnectContract.SNOMED_CT, + "258479004", + "Interstitial fluid specimen", + ), + ) + BloodGlucoseRecord.SPECIMEN_SOURCE_TEARS -> throw InvalidHealthConnectRecord( + "Health Connect tear glucose has no admitted shared Grove Mobile profile in 0.6.0.", + ) + BloodGlucoseRecord.SPECIMEN_SOURCE_UNKNOWN -> throw InvalidHealthConnectRecord( + "Health Connect blood glucose requires an explicit supported specimen source.", + ) + else -> throw InvalidHealthConnectRecord( + "Unsupported Health Connect blood-glucose specimen source: $specimenSource", + ) +} + +internal fun HealthConnectConverter.specimenIdentifier( + sourceIdentifier: HealthConnectSourceIdentity, + specimenSourceCode: String, +): Identifier = + HealthConnectIdentity.specimenOutput( + synchronizationScope.identityKey, + sourceIdentifier, + specimenSourceCode, + ) + +internal fun HealthConnectConverter.glucoseMealContext(record: BloodGlucoseRecord): Extension? { + val relation = bloodGlucoseRelationToMeal(record.relationToMeal) + val meal = bloodGlucoseMealType(record.mealType) + if (relation == null && meal == null) return null + return Extension(HealthConnectContract.HEALTH_CONNECT_GLUCOSE_MEAL_CONTEXT).apply { + relation?.let { addExtension(Extension("relationToMeal", it)) } + meal?.let { addExtension(Extension("mealType", it)) } + } +} + +internal fun HealthConnectConverter.recordingMethod(metadata: Metadata): Extension? { + val code = when (metadata.recordingMethod) { + Metadata.RECORDING_METHOD_UNKNOWN -> return null + Metadata.RECORDING_METHOD_ACTIVELY_RECORDED -> "actively-recorded" + Metadata.RECORDING_METHOD_AUTOMATICALLY_RECORDED -> "automatically-recorded" + Metadata.RECORDING_METHOD_MANUAL_ENTRY -> "manual-entry" + else -> throw InvalidHealthConnectRecord("Unsupported Health Connect recording method: ${metadata.recordingMethod}") + } + return Extension( + HealthConnectContract.RECORDING_METHOD_EXTENSION, + Coding(HealthConnectContract.GROVE_RECORDING_METHOD, code, null), + ) +} + +internal fun quantity(value: BigDecimal, code: String, unit: String): Quantity = + Quantity().setValue(value).setSystem(HealthConnectContract.UCUM).setCode(code).setUnit(unit) + +internal fun Instant.fhirDateTime(zoneOffset: ZoneOffset?, field: String): String { + HealthConnectWireFormat.requireFhirInstant(this, field) + val canonical = mobileEffectiveInstant() + HealthConnectWireFormat.requireFhirInstant( + canonical, + "$field after Mobile millisecond canonicalization", + ) + if (zoneOffset == null) return canonical.toString() + val fhirOffsetRange = + -HealthConnectConverter.MAX_FHIR_OFFSET_SECONDS..HealthConnectConverter.MAX_FHIR_OFFSET_SECONDS + if ( + zoneOffset.totalSeconds % HealthConnectConverter.SECONDS_PER_MINUTE != 0 || + zoneOffset.totalSeconds !in fhirOffsetRange + ) { + throw InvalidHealthConnectRecord( + "$field offset must use whole minutes in the FHIR range -14:00 through +14:00.", + ) + } + val local = canonical.atOffset(zoneOffset) + if (local.year !in HealthConnectConverter.MIN_FHIR_YEAR..HealthConnectConverter.MAX_FHIR_YEAR) { + throw InvalidHealthConnectRecord("$field must retain a four-digit FHIR year after applying its offset.") + } + return HealthConnectConverter.FHIR_OFFSET_DATE_TIME.format(local) +} + +/** Applies the source-neutral Mobile effective-time policy without altering identity instants. */ +internal fun Instant.mobileEffectiveInstant(): Instant { + val exactEpochMilliseconds = BigDecimal.valueOf(epochSecond) + .multiply(HealthConnectConverter.MILLISECONDS_PER_SECOND) + .add(BigDecimal.valueOf(nano.toLong(), HealthConnectConverter.NANOSECONDS_TO_MILLISECONDS_SCALE)) + return Instant.ofEpochMilli( + exactEpochMilliseconds.setScale(0, RoundingMode.HALF_EVEN).longValueExact(), + ) +} diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectNativeIdentifierDisclosure.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectNativeIdentifierDisclosure.kt new file mode 100644 index 00000000..37d236ac --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectNativeIdentifierDisclosure.kt @@ -0,0 +1,115 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import org.hl7.fhir.r4.model.CodeableConcept +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.Identifier + +/** One immutable coding in an optional source-native [Identifier.type]. */ +data class HealthConnectNativeIdentifierTypeCoding( + val system: String, + val code: String, + val display: String? = null, +) { + init { + GroveUnicode.requireScalarText(system, "Native Identifier.type Coding.system") + GroveUnicode.requireScalarText(code, "Native Identifier.type Coding.code") + display?.let { + GroveUnicode.requireScalarText(it, "Native Identifier.type Coding.display") + require(it.isNotBlank()) { "Native Identifier.type Coding.display must not be blank." } + } + require(system.isAbsoluteAsciiUri()) { + "Native Identifier.type Coding.system must be an absolute ASCII RFC 3986 URI." + } + require(system != HealthConnectContract.GROVE_IDENTIFIER_ROLE) { + "A source-native Identifier.type cannot claim a Grove graph identity role." + } + require(FHIR_CODE.matches(code)) { + "Native Identifier.type Coding.code must have no leading, trailing, consecutive, or control whitespace." + } + } + + internal fun coding(): Coding = Coding(system, code, display) + + private companion object { + val FHIR_CODE = Regex("""[^\s\p{Cc}]+(?: [^\s\p{Cc}]+)*""") + } +} + +/** Immutable narrow CodeableConcept input for an optional source-native [Identifier.type]. */ +class HealthConnectNativeIdentifierType( + codings: List = emptyList(), + val text: String? = null, +) { + private val codingSnapshot = codings.toList() + + val codings: List + get() = codingSnapshot.toList() + + init { + text?.let { + GroveUnicode.requireScalarText(it, "Native Identifier.type text") + require(it.isNotBlank()) { "Native Identifier.type text must not be blank." } + } + require(codingSnapshot.isNotEmpty() || text != null) { + "Native Identifier.type requires at least one coding or nonblank text." + } + } + + internal fun concept(): CodeableConcept = CodeableConcept().apply { + codingSnapshot.forEach { addCoding(it.coding()) } + text = this@HealthConnectNativeIdentifierType.text + } +} + +/** + * Explicit opt-in to disclose Health Connect [androidx.health.connect.client.records.metadata.Metadata.id]. + * + * [system] must name the caller-governed repository/store key space in which that native value is + * unique. The native value itself is taken directly from Metadata at conversion time, so callers + * cannot accidentally disclose a different identifier. This Identifier supplements Grove's + * mandatory opaque identities and is never used as an entry, event, or retraction key. + */ +class HealthConnectNativeIdentifierDisclosure( + val system: String, + val type: HealthConnectNativeIdentifierType? = null, +) { + init { + GroveUnicode.requireScalarText(system, "Native Identifier.system") + require(system.isAbsoluteAsciiUri()) { + "Native Identifier.system must be a caller-owned absolute ASCII RFC 3986 URI." + } + require(system != HealthConnectContract.GROVE_IDENTIFIER_ROLE) { + "Native Identifier.system cannot be the Grove graph-role CodeSystem." + } + } + + internal fun identifier( + nativeId: String, + eventIdentifierSystem: String, + entryNodeIdentifierSystem: String, + identityKey: GroveHmacIdentityKey, + ): Identifier { + GroveUnicode.requireScalarText(nativeId, "Metadata.id") + require(nativeId.isNotBlank()) { "Metadata.id must not be blank." } + val reservedSystems = buildSet { + add(eventIdentifierSystem) + add(entryNodeIdentifierSystem) + GroveOpaqueIdentityKind.entries.forEach { add(identityKey.identifierSystem(it)) } + } + require(system !in reservedSystems) { + "Native Identifier.system requires its own repository/store namespace, never a Grove identity system." + } + return Identifier().apply { + system = this@HealthConnectNativeIdentifierDisclosure.system + value = nativeId + this@HealthConnectNativeIdentifierDisclosure.type?.let { type = it.concept() } + } + } +} diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectNutrientOutputs.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectNutrientOutputs.kt new file mode 100644 index 00000000..13ba6887 --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectNutrientOutputs.kt @@ -0,0 +1,145 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import androidx.health.connect.client.records.NutritionRecord + +/** Every nutrient the adapter projects out of one Health Connect NutritionRecord. */ +internal fun mobileNutrient( + measurement: String, + display: String, + unit: String, + extract: (NutritionRecord) -> Double?, +): NutrientSpec = NutrientSpec( + measurement, + MobileQuantitySpec( + profile = requireNotNull(HealthConnectContract.mobileDietaryProfiles[measurement]), + category = null, + codeSystem = HealthConnectContract.GROVE_MOBILE_MEASUREMENT, + code = measurement, + display = display, + unitCode = unit, + unitDisplay = unit, + ), + extract, +) + +internal fun loincNutrient( + measurement: String, + code: String, + display: String, + unit: String, + extract: (NutritionRecord) -> Double?, +): NutrientSpec = NutrientSpec( + measurement, + MobileQuantitySpec( + profile = requireNotNull(HealthConnectContract.mobileDietaryProfiles[measurement]), + category = null, + codeSystem = HealthConnectContract.LOINC, + code = code, + display = display, + unitCode = unit, + unitDisplay = unit, + ), + extract, +) + +internal fun healthConnectNutrient( + measurement: String, + profile: String, + display: String, + unit: String, + extract: (NutritionRecord) -> Double?, +): NutrientSpec = NutrientSpec( + measurement, + MobileQuantitySpec( + profile = profile, + category = null, + codeSystem = HealthConnectContract.HEALTH_CONNECT_MEASUREMENT, + code = measurement, + display = display, + unitCode = unit, + unitDisplay = unit, + adapterSpecific = true, + ), + extract, +) + +/** Every admitted Nutrition output in the catalog's measurement order; absent fields emit nothing. */ +internal val NUTRIENT_OUTPUTS: List = listOf( + mobileNutrient("dietary-biotin", "Dietary biotin", "ug") { it.biotin?.inMicrograms }, + mobileNutrient("dietary-caffeine", "Dietary caffeine", "mg") { it.caffeine?.inMilligrams }, + mobileNutrient("dietary-calcium", "Dietary calcium", "mg") { it.calcium?.inMilligrams }, + loincNutrient("dietary-carbohydrates", "9060-5", "Carbohydrate intake Measured", "g") { + it.totalCarbohydrate?.inGrams + }, + mobileNutrient("dietary-chloride", "Dietary chloride", "mg") { it.chloride?.inMilligrams }, + mobileNutrient("dietary-cholesterol", "Dietary cholesterol", "mg") { it.cholesterol?.inMilligrams }, + mobileNutrient("dietary-chromium", "Dietary chromium", "ug") { it.chromium?.inMicrograms }, + mobileNutrient("dietary-copper", "Dietary copper", "ug") { it.copper?.inMicrograms }, + loincNutrient("dietary-energy", "9052-2", "Calorie intake total", "kcal") { it.energy?.inKilocalories }, + healthConnectNutrient( + "dietary-energy-from-fat", + HealthConnectContract.HEALTH_CONNECT_DIETARY_ENERGY_FROM_FAT_PROFILE, + "Nutrition energy from fat", + "kcal", + ) { it.energyFromFat?.inKilocalories }, + mobileNutrient("dietary-fat-monounsaturated", "Dietary monounsaturated fat", "g") { + it.monounsaturatedFat?.inGrams + }, + mobileNutrient("dietary-fat-polyunsaturated", "Dietary polyunsaturated fat", "g") { + it.polyunsaturatedFat?.inGrams + }, + mobileNutrient("dietary-fat-saturated", "Dietary saturated fat", "g") { it.saturatedFat?.inGrams }, + loincNutrient("dietary-fat-total", "9067-0", "Fat intake Measured", "g") { it.totalFat?.inGrams }, + healthConnectNutrient( + "dietary-fat-trans", + HealthConnectContract.HEALTH_CONNECT_DIETARY_FAT_TRANS_PROFILE, + "Nutrition trans fat", + "g", + ) { it.transFat?.inGrams }, + healthConnectNutrient( + "dietary-fat-unsaturated", + HealthConnectContract.HEALTH_CONNECT_DIETARY_FAT_UNSATURATED_PROFILE, + "Nutrition unsaturated fat", + "g", + ) { it.unsaturatedFat?.inGrams }, + mobileNutrient("dietary-fiber", "Dietary fiber", "g") { it.dietaryFiber?.inGrams }, + mobileNutrient("dietary-folate", "Dietary folate", "ug") { it.folate?.inMicrograms }, + healthConnectNutrient( + "dietary-folic-acid", + HealthConnectContract.HEALTH_CONNECT_DIETARY_FOLIC_ACID_PROFILE, + "Nutrition folic acid", + "ug", + ) { it.folicAcid?.inMicrograms }, + mobileNutrient("dietary-iodine", "Dietary iodine", "ug") { it.iodine?.inMicrograms }, + mobileNutrient("dietary-iron", "Dietary iron", "mg") { it.iron?.inMilligrams }, + mobileNutrient("dietary-magnesium", "Dietary magnesium", "mg") { it.magnesium?.inMilligrams }, + mobileNutrient("dietary-manganese", "Dietary manganese", "mg") { it.manganese?.inMilligrams }, + mobileNutrient("dietary-molybdenum", "Dietary molybdenum", "ug") { it.molybdenum?.inMicrograms }, + mobileNutrient("dietary-niacin", "Dietary niacin", "mg") { it.niacin?.inMilligrams }, + mobileNutrient("dietary-pantothenic-acid", "Dietary pantothenic acid", "mg") { + it.pantothenicAcid?.inMilligrams + }, + mobileNutrient("dietary-phosphorus", "Dietary phosphorus", "mg") { it.phosphorus?.inMilligrams }, + mobileNutrient("dietary-potassium", "Dietary potassium", "mg") { it.potassium?.inMilligrams }, + loincNutrient("dietary-protein", "9080-3", "Protein intake Measured", "g") { it.protein?.inGrams }, + mobileNutrient("dietary-riboflavin", "Dietary riboflavin", "mg") { it.riboflavin?.inMilligrams }, + mobileNutrient("dietary-selenium", "Dietary selenium", "ug") { it.selenium?.inMicrograms }, + mobileNutrient("dietary-sodium", "Dietary sodium", "mg") { it.sodium?.inMilligrams }, + mobileNutrient("dietary-sugar", "Dietary sugar", "g") { it.sugar?.inGrams }, + mobileNutrient("dietary-thiamin", "Dietary thiamin", "mg") { it.thiamin?.inMilligrams }, + mobileNutrient("dietary-vitamin-a", "Dietary vitamin A", "ug") { it.vitaminA?.inMicrograms }, + mobileNutrient("dietary-vitamin-b12", "Dietary vitamin B12", "ug") { it.vitaminB12?.inMicrograms }, + mobileNutrient("dietary-vitamin-b6", "Dietary vitamin B6", "mg") { it.vitaminB6?.inMilligrams }, + mobileNutrient("dietary-vitamin-c", "Dietary vitamin C", "mg") { it.vitaminC?.inMilligrams }, + mobileNutrient("dietary-vitamin-d", "Dietary vitamin D", "ug") { it.vitaminD?.inMicrograms }, + mobileNutrient("dietary-vitamin-e", "Dietary vitamin E", "mg") { it.vitaminE?.inMilligrams }, + mobileNutrient("dietary-vitamin-k", "Dietary vitamin K", "ug") { it.vitaminK?.inMicrograms }, + mobileNutrient("dietary-zinc", "Dietary zinc", "mg") { it.zinc?.inMilligrams }, +) diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectNutritionConversion.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectNutritionConversion.kt new file mode 100644 index 00000000..d8a78020 --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectNutritionConversion.kt @@ -0,0 +1,64 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import androidx.health.connect.client.records.NutritionRecord +import org.hl7.fhir.r4.model.DateTimeType +import org.hl7.fhir.r4.model.Period +import java.time.Instant + +internal fun HealthConnectConverter.convertNutrition( + record: NutritionRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion { + if (!record.startTime.isBefore(record.endTime)) { + throw InvalidHealthConnectRecord("NutritionRecord must have a positive interval.") + } + val source = sourceIdentity(record.metadata, HealthConnectConverter.NUTRITION_RECORD) + val resolvedContext = context.resolve( + record.metadata, + synchronizationScope.identityKey, + bundleIdentifier(eventSequence), + ) + val observations = NUTRIENT_OUTPUTS.mapNotNull { nutrient -> + val value = nutrient.extract(record) ?: return@mapNotNull null + val decimal = value.fhirDecimal(nutrient.spec.display, nutrient.spec.valueDomain) + baseObservation( + record.metadata, + source, + HealthConnectIdentity.nutrientOutput( + synchronizationScope.identityKey, + source, + nutrient.measurement, + ), + resolvedContext, + ).apply { + claimProfile(nutrient.spec) + code = concept(nutrient.spec.codeSystem, nutrient.spec.code, nutrient.spec.display) + effective = Period().apply { + startElement = DateTimeType( + record.startTime.fhirDateTime(record.startZoneOffset, "Nutrition start time"), + ) + endElement = DateTimeType( + record.endTime.fhirDateTime(record.endZoneOffset, "Nutrition end time"), + ) + } + this.value = quantity(decimal, nutrient.spec.unitCode, nutrient.spec.unitDisplay) + } + } + return conversion( + record.metadata, + HealthConnectConverter.NUTRITION_RECORD, + source, + observations, + convertedAt, + eventSequence, + resolvedContext, + ) +} diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectObservationConstruction.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectObservationConstruction.kt new file mode 100644 index 00000000..4339385e --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectObservationConstruction.kt @@ -0,0 +1,228 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import androidx.health.connect.client.records.metadata.Metadata +import org.hl7.fhir.r4.model.CodeableConcept +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.DateTimeType +import org.hl7.fhir.r4.model.Extension +import org.hl7.fhir.r4.model.Identifier +import org.hl7.fhir.r4.model.InstantType +import org.hl7.fhir.r4.model.Observation +import org.hl7.fhir.r4.model.Period +import java.math.BigDecimal +import java.math.RoundingMode +import java.time.Instant +import java.time.ZoneOffset +import java.time.temporal.ChronoUnit + +internal fun Observation.claimProfile(spec: MobileQuantitySpec) = + claimProfile(spec.profile, spec.adapterSpecific) + +internal fun Observation.claimProfile(spec: MobileCodedSpec) = + claimProfile(spec.profile, spec.adapterSpecific) + +internal fun Observation.claimProfile(profile: String, adapterSpecific: Boolean) { + if (adapterSpecific) { + claimAdapterSpecificProfile(profile) + } else { + claimMeasurementProfile(profile) + } +} + +@Suppress("LongParameterList") +internal fun HealthConnectConverter.convertInstantCoded( + metadata: Metadata, + recordType: String, + time: Instant, + offset: ZoneOffset?, + spec: MobileCodedSpec, + value: CodeableConcept, + component: Observation.ObservationComponentComponent? = null, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion { + val source = sourceIdentity(metadata, recordType) + val resolvedContext = context.resolve( + metadata, + synchronizationScope.identityKey, + bundleIdentifier(eventSequence), + ) + val observation = baseObservation( + metadata, + source, + null, + resolvedContext, + spec.measurement, + ).apply { + claimProfile(spec) + spec.category?.let { addCategory(category(it)) } + code = concept(spec.codeSystem, spec.code, spec.display) + effective = DateTimeType(time.fhirDateTime(offset, "${spec.display} time")) + this.value = value + component?.let { addComponent(it) } + } + return conversion( + metadata, + recordType, + source, + listOf(observation), + convertedAt, + eventSequence, + resolvedContext, + ) +} + +@Suppress("LongParameterList") +internal fun HealthConnectConverter.convertPeriodCoded( + metadata: Metadata, + recordType: String, + start: Instant, + startOffset: ZoneOffset?, + end: Instant, + endOffset: ZoneOffset?, + spec: MobileCodedSpec, + value: CodeableConcept, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion { + if (!start.isBefore(end)) throw InvalidHealthConnectRecord("$recordType must have a positive interval.") + val source = sourceIdentity(metadata, recordType) + val resolvedContext = context.resolve( + metadata, + synchronizationScope.identityKey, + bundleIdentifier(eventSequence), + ) + val observation = baseObservation( + metadata, + source, + null, + resolvedContext, + spec.measurement, + ).apply { + claimProfile(spec) + spec.category?.let { addCategory(category(it)) } + code = concept(spec.codeSystem, spec.code, spec.display) + effective = Period().apply { + startElement = DateTimeType(start.fhirDateTime(startOffset, "${spec.display} start time")) + endElement = DateTimeType(end.fhirDateTime(endOffset, "${spec.display} end time")) + } + this.value = value + } + return conversion( + metadata, + recordType, + source, + listOf(observation), + convertedAt, + eventSequence, + resolvedContext, + ) +} + +internal fun codedValue(sharedSystem: String, sourceSystem: String, coding: SourceCodedValue): CodeableConcept = + CodeableConcept().apply { + addCoding(Coding(sharedSystem, coding.sharedCode, coding.sharedDisplay)) + addCoding(Coding(sourceSystem, coding.sourceCode, coding.sourceDisplay)) + } + +internal fun category(code: String): CodeableConcept = concept( + HealthConnectContract.OBSERVATION_CATEGORY, + code, + when (code) { + HealthConnectConverter.VITAL_SIGNS_CATEGORY -> "Vital Signs" + HealthConnectConverter.LABORATORY_CATEGORY -> "Laboratory" + HealthConnectConverter.ACTIVITY_CATEGORY -> "Activity" + else -> code + }, +) + +/** Keeps a stripped decimal in plain notation so a multiple of ten never gains an exponent. */ +internal fun BigDecimal.withPlainScale(): BigDecimal = if (scale() < 0) setScale(0) else this + +internal fun Double.fhirDecimal( + field: String, + valueDomain: QuantityValueDomain = QuantityValueDomain(), + exactValue: BigDecimal? = null, +): BigDecimal { + if (!isFinite()) throw InvalidHealthConnectRecord("$field must be finite.") + return valueDomain.requireValue(exactValue ?: BigDecimal.valueOf(this), field) +} + +internal fun mindfulnessDurationMinutes(start: Instant, end: Instant): BigDecimal { + val nanos = ChronoUnit.NANOS.between(start, end) + if (nanos <= 0L) { + throw InvalidHealthConnectRecord("MindfulnessSessionRecord must have a positive interval.") + } + return BigDecimal.valueOf(nanos) + .divide(HealthConnectConverter.NANOSECONDS_PER_MINUTE, HealthConnectConverter.SESSION_DURATION_SCALE, RoundingMode.HALF_EVEN) + .stripTrailingZeros() + .withPlainScale() +} + +internal fun HealthConnectConverter.baseObservation( + metadata: Metadata, + sourceIdentity: HealthConnectSourceIdentity, + outputIdentifier: Identifier?, + resolvedContext: ResolvedFhirContext, + singleMeasurementId: String? = null, +): Observation = + Observation().apply { + require((outputIdentifier == null) == (singleMeasurementId != null)) { + "An Observation requires either an explicit multi-output identity or one exactly-one measurement id." + } + addIdentifier(sourceIdentity.identifier.copy()) + addIdentifier( + outputIdentifier ?: HealthConnectIdentity.singleOutput( + synchronizationScope.identityKey, + sourceIdentity, + requireNotNull(singleMeasurementId), + ), + ) + if (singleMeasurementId != null) { + context.nativeIdentifierDisclosure?.identifier( + nativeId = metadata.id, + eventIdentifierSystem = context.eventIdentifierSystem, + entryNodeIdentifierSystem = context.entryNodeIdentifierSystem, + identityKey = synchronizationScope.identityKey, + )?.let(::addIdentifier) + } + status = Observation.ObservationStatus.FINAL + subject = resolvedContext.subject.copy() + // lastModifiedTime, not the conversion instant: the emitted graph has to be identical + // for an unchanged source version, or a re-read stops deduplicating and the outbox + // replays it. The conversion event itself is recorded on Provenance. + issuedElement = InstantType(metadata.lastModifiedTime.toString()) + clientRecordIdentity(metadata) + + recordingMethod(metadata)?.let { addExtension(it) } + resolvedContext.recordingDevice?.let { device = it.copy() } + resolvedContext.researchStudies.forEach { study -> + addExtension( + Extension( + HealthConnectContract.RESEARCH_STUDY_EXTENSION, + study.copy(), + ), + ) + } + } + +internal fun Observation.claimMeasurementProfile(sharedProfile: String) { + check(meta.profile.isEmpty()) { "Measurement profile claims must be assigned exactly once." } + meta.addProfile(sharedProfile) + meta.addProfile(HealthConnectContract.HEALTH_CONNECT_OBSERVATION_PROFILE) +} + +internal fun Observation.claimAdapterSpecificProfile(adapterProfile: String) { + check(adapterProfile in HealthConnectContract.adapterSpecificObservationProfiles) { + "Only an admitted Health Connect-specific Observation profile may use this claim mode." + } + check(meta.profile.isEmpty()) { "Measurement profile claims must be assigned exactly once." } + meta.addProfile(adapterProfile) +} diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectPendingExportDelivery.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectPendingExportDelivery.kt new file mode 100644 index 00000000..c8d2f428 --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectPendingExportDelivery.kt @@ -0,0 +1,68 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import java.time.Instant + +/** Owns exact pending delivery and construction of prior-output retraction events. */ +internal class HealthConnectPendingExportDelivery( + converter: HealthConnectConverter, + private val journal: HealthConnectExportJournal, + private val sink: HealthConnectExportSink, +) { + private val synchronizationScope = converter.synchronizationScope + private val retractionBuilder = HealthConnectRetractionBuilder(converter) + + suspend fun retract( + prior: HealthConnectExportJournalEntry, + retractedAt: Instant, + lease: HealthConnectSourceTransitionLease, + ) { + HealthConnectWireFormat.requireFhirInstant(retractedAt, "Retraction event time") + val targets = retractionBuilder.targets(prior) + require(targets.isNotEmpty()) { "An exchange retraction must name at least one prior output." } + val pending = journal.stage( + lease, + prior.revision, + ) { eventSequence -> + val retractionBundle = retractionBuilder.bundle(prior, targets, retractedAt, eventSequence) + HealthConnectPendingExportDraft( + repositoryScopeKey = synchronizationScope.repositoryScopeKey, + projectionScopeKey = synchronizationScope.projectionScopeKey, + operation = HealthConnectExportOperation.RETRACTION, + recordType = prior.recordType, + healthConnectId = prior.healthConnectId, + sourceRecordIdentifier = prior.sourceRecordIdentifier.copy(), + sourceVersion = prior.sourceLastModified, + bundle = retractionBundle, + retractedTargets = targets, + nextEntry = prior.copy( + projectionScopeKey = synchronizationScope.projectionScopeKey, + observations = emptyList(), + bundle = retractionBundle.copy(), + destinationReferences = emptyMap(), + state = HealthConnectExportState.INVALIDATED, + invalidatedAt = retractedAt, + ), + ) + } + check(pending.operation == HealthConnectExportOperation.RETRACTION) { + "The journal returned an unrelated pending event while holding the source-transition lease." + } + deliver(pending, lease) + } + + suspend fun deliver( + pending: HealthConnectPendingExport, + lease: HealthConnectSourceTransitionLease, + ) { + val acknowledgement = sink.apply(pending.batch()) + val completedEntry = pending.acknowledgedEntry(acknowledgement.destinationReferences) + journal.complete(lease, pending, completedEntry) + } +} diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectReproductiveConversions.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectReproductiveConversions.kt new file mode 100644 index 00000000..4e06fbf7 --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectReproductiveConversions.kt @@ -0,0 +1,180 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import androidx.health.connect.client.records.CervicalMucusRecord +import androidx.health.connect.client.records.IntermenstrualBleedingRecord +import androidx.health.connect.client.records.MenstruationFlowRecord +import androidx.health.connect.client.records.MenstruationPeriodRecord +import androidx.health.connect.client.records.OvulationTestRecord +import androidx.health.connect.client.records.SexualActivityRecord +import org.hl7.fhir.r4.model.CodeableConcept +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.Observation +import java.time.Instant + +internal fun HealthConnectConverter.convertMenstruationFlow( + record: MenstruationFlowRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion = convertInstantCoded( + metadata = record.metadata, + recordType = HealthConnectConverter.MENSTRUATION_FLOW_RECORD, + time = record.time, + offset = record.zoneOffset, + spec = MobileCodedSpec( + profile = HealthConnectContract.MOBILE_MENSTRUATION_FLOW_PROFILE, + category = null, + code = "menstruation-flow", + display = "Menstruation flow", + ), + value = codedValue( + HealthConnectContract.GROVE_MENSTRUATION_FLOW, + HealthConnectContract.HEALTH_CONNECT_MENSTRUATION_FLOW, + menstruationFlowCoding(record.flow), + ), + convertedAt = convertedAt, + eventSequence = eventSequence, +) + +internal fun HealthConnectConverter.convertOvulationTest( + record: OvulationTestRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion = convertInstantCoded( + metadata = record.metadata, + recordType = HealthConnectConverter.OVULATION_TEST_RECORD, + time = record.time, + offset = record.zoneOffset, + spec = MobileCodedSpec( + profile = HealthConnectContract.MOBILE_OVULATION_TEST_RESULT_PROFILE, + category = null, + code = "ovulation-test-result", + display = "Ovulation test result", + ), + value = codedValue( + HealthConnectContract.GROVE_OVULATION_TEST_RESULT, + HealthConnectContract.HEALTH_CONNECT_OVULATION_TEST_RESULT, + ovulationTestCoding(record.result), + ), + convertedAt = convertedAt, + eventSequence = eventSequence, +) + +internal fun HealthConnectConverter.convertSexualActivity( + record: SexualActivityRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion = convertInstantCoded( + metadata = record.metadata, + recordType = HealthConnectConverter.SEXUAL_ACTIVITY_RECORD, + time = record.time, + offset = record.zoneOffset, + spec = MobileCodedSpec( + profile = HealthConnectContract.MOBILE_SEXUAL_ACTIVITY_PROFILE, + category = null, + code = "sexual-activity", + display = "Sexual activity", + ), + value = codedValue( + HealthConnectContract.GROVE_SEXUAL_ACTIVITY, + HealthConnectContract.HEALTH_CONNECT_SEXUAL_ACTIVITY_PROTECTION, + sexualActivityCoding(record.protectionUsed), + ), + convertedAt = convertedAt, + eventSequence = eventSequence, +) + +internal fun HealthConnectConverter.convertCervicalMucus( + record: CervicalMucusRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion { + val component = cervicalMucusSensationCoding(record.sensation)?.let { sensation -> + Observation.ObservationComponentComponent().apply { + code = concept( + HealthConnectContract.GROVE_MOBILE_MEASUREMENT, + "cervical-mucus-sensation", + "Cervical mucus sensation", + ) + value = codedValue( + HealthConnectContract.GROVE_CERVICAL_MUCUS_SENSATION, + HealthConnectContract.HEALTH_CONNECT_CERVICAL_MUCUS_SENSATION, + sensation, + ) + } + } + return convertInstantCoded( + metadata = record.metadata, + recordType = HealthConnectConverter.CERVICAL_MUCUS_RECORD, + time = record.time, + offset = record.zoneOffset, + spec = MobileCodedSpec( + profile = HealthConnectContract.MOBILE_CERVICAL_MUCUS_QUALITY_PROFILE, + category = null, + code = "cervical-mucus-quality", + display = "Cervical mucus quality", + ), + value = codedValue( + HealthConnectContract.GROVE_CERVICAL_MUCUS_QUALITY, + HealthConnectContract.HEALTH_CONNECT_CERVICAL_MUCUS_APPEARANCE, + cervicalMucusAppearanceCoding(record.appearance), + ), + component = component, + convertedAt = convertedAt, + eventSequence = eventSequence, + ) +} + +internal fun HealthConnectConverter.convertIntermenstrualBleeding( + record: IntermenstrualBleedingRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion = convertInstantCoded( + metadata = record.metadata, + recordType = HealthConnectConverter.INTERMENSTRUAL_BLEEDING_RECORD, + time = record.time, + offset = record.zoneOffset, + spec = MobileCodedSpec( + profile = HealthConnectContract.MOBILE_INTERMENSTRUAL_BLEEDING_PROFILE, + category = null, + code = "intermenstrual-bleeding", + display = "Intermenstrual bleeding", + ), + value = CodeableConcept( + Coding(HealthConnectContract.GROVE_INTERMENSTRUAL_BLEEDING, "present", "Present"), + ), + convertedAt = convertedAt, + eventSequence = eventSequence, +) + +internal fun HealthConnectConverter.convertMenstruationPeriod( + record: MenstruationPeriodRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion = convertPeriodCoded( + metadata = record.metadata, + recordType = HealthConnectConverter.MENSTRUATION_PERIOD_RECORD, + start = record.startTime, + startOffset = record.startZoneOffset, + end = record.endTime, + endOffset = record.endZoneOffset, + spec = MobileCodedSpec( + profile = HealthConnectContract.HEALTH_CONNECT_MENSTRUATION_PERIOD_PROFILE, + category = null, + code = "menstruation-period", + display = "Menstruation period", + codeSystem = HealthConnectContract.HEALTH_CONNECT_MEASUREMENT, + adapterSpecific = true, + ), + value = CodeableConcept( + Coding(HealthConnectContract.HEALTH_CONNECT_MENSTRUATION_PERIOD, "present", "Present"), + ), + convertedAt = convertedAt, + eventSequence = eventSequence, +) diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectRetractionBuilder.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectRetractionBuilder.kt new file mode 100644 index 00000000..085351ea --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectRetractionBuilder.kt @@ -0,0 +1,178 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.CodeType +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.DateTimeType +import org.hl7.fhir.r4.model.Extension +import org.hl7.fhir.r4.model.InstantType +import org.hl7.fhir.r4.model.Observation +import org.hl7.fhir.r4.model.Provenance +import org.hl7.fhir.r4.model.Reference +import java.time.Instant + +/** Selects and renders the complete prior graph named by one retraction event. */ +internal class HealthConnectRetractionBuilder( + private val converter: HealthConnectConverter, +) { + fun targets(entry: HealthConnectExportJournalEntry): Set { + val childOutputIdentifiers = entry.childOutputIdentifiers() + return ( + entry.observationRetractionTargets(childOutputIdentifiers) + + entry.nonObservationOutputRetractionTargets() + + entry.deviceSnapshotRetractionTargets() + ).toSet() + } + + fun bundle( + entry: HealthConnectExportJournalEntry, + targets: Set, + retractedAt: Instant, + eventSequence: EventSequence, + ): Bundle = Bundle().apply { + identifier = converter.bundleIdentifier(eventSequence) + meta.addProfile(HealthConnectContract.MOBILE_RETRACTION_BUNDLE_PROFILE) + type = Bundle.BundleType.COLLECTION + timestampElement = InstantType(retractedAt.toString()) + addGroveEntry( + HealthConnectIdentity.retractionNode( + converter.entryNodeIdentifierSystem, + identifier, + ), + entry.provenance(targets, retractedAt, eventSequence), + ) + } + + private fun HealthConnectExportJournalEntry.childOutputIdentifiers(): Set = + bundle.entry + .mapNotNull { it.resource as? Observation } + .flatMap(Observation::getHasMember) + .map { member -> resolveMemberOutput(member) } + .toSet() + + private fun HealthConnectExportJournalEntry.resolveMemberOutput(member: Reference): FhirIdentifierKey { + require(member.hasReference()) { + "An active member output must use a literal reference inside its event Bundle." + } + val memberResource = bundle.entry.singleOrNull { it.fullUrl == member.reference }?.resource + ?: throw IllegalArgumentException( + "An active member output reference must resolve exactly once inside its event Bundle.", + ) + require(memberResource is Observation) { + "An active Observation.hasMember target must resolve to an Observation." + } + return observationIdentity(memberResource).key() + } + + private fun HealthConnectExportJournalEntry.observationRetractionTargets( + childOutputIdentifiers: Set, + ): List = observations.map { observation -> + val identifier = observationIdentity(observation) + val identifierKey = identifier.key() + HealthConnectRetractionTarget( + identifier = identifierKey, + identifierRole = GroveIdentifierRole.SOURCE_OUTPUT, + resourceType = observation.fhirType(), + role = if (identifierKey in childOutputIdentifiers) { + HealthConnectRetractionTargetRole.CHILD_OUTPUT + } else { + HealthConnectRetractionTargetRole.PRIMARY_OUTPUT + }, + ) + } + + private fun HealthConnectExportJournalEntry.nonObservationOutputRetractionTargets() = + bundle.entry.mapNotNull { entry -> + if (entry.resource is Observation) return@mapNotNull null + entry.resource.typedGroveIdentifiers( + "Retraction ${entry.resource.fhirType()} output", + )[GroveIdentifierRole.SOURCE_OUTPUT]?.let { identifier -> + val targetRole = when (entry.resource.fhirType()) { + "DocumentReference" -> HealthConnectRetractionTargetRole.SOURCE_ARTIFACT + "Specimen" -> HealthConnectRetractionTargetRole.SPECIMEN + "VisionPrescription", "MedicationAdministration", "MedicationStatement" -> + HealthConnectRetractionTargetRole.PRIMARY_OUTPUT + else -> return@let null + } + HealthConnectRetractionTarget( + identifier = identifier.key(), + identifierRole = GroveIdentifierRole.SOURCE_OUTPUT, + resourceType = entry.resource.fhirType(), + role = targetRole, + ) + } + } + + private fun HealthConnectExportJournalEntry.deviceSnapshotRetractionTargets() = + bundle.entry.mapNotNull { entry -> + if (entry.resource !is org.hl7.fhir.r4.model.Device) return@mapNotNull null + entry.resource.typedGroveIdentifiers( + "Retraction Device snapshot", + )[GroveIdentifierRole.DEVICE_SNAPSHOT]?.let { identifier -> + HealthConnectRetractionTarget( + identifier = identifier.key(), + identifierRole = GroveIdentifierRole.DEVICE_SNAPSHOT, + resourceType = entry.resource.fhirType(), + role = HealthConnectRetractionTargetRole.DEVICE_SNAPSHOT, + ) + } + } + + private fun HealthConnectExportJournalEntry.provenance( + targets: Set, + retractedAt: Instant, + eventSequence: EventSequence, + ): Provenance { + val sourceIdentifier = sourceRecordIdentifier + return Provenance().apply { + meta.addProfile(HealthConnectContract.MOBILE_RETRACTION_PROVENANCE_PROFILE) + occurred = DateTimeType(retractedAt.toString()) + recordedElement = InstantType(retractedAt.toString()) + activity = concept( + HealthConnectContract.GROVE_LIFECYCLE_EVENT, + "source-record-retracted", + "Source record retracted", + ) + addAgent().apply { + type = concept(HealthConnectContract.PROVENANCE_PARTICIPANT, "assembler", "Assembler") + who = Reference().apply { + type = "Device" + identifier = converter.assemblerSnapshotIdentifier(eventSequence) + } + } + addEntity().apply { + role = Provenance.ProvenanceEntityRole.SOURCE + what = Reference().setIdentifier(sourceIdentifier) + } + targets.sortedWith(compareBy({ it.identifier }, { it.resourceType }, { it.role.code })).forEach { + addTarget(it.reference()) + } + } + } + + private fun HealthConnectRetractionTarget.reference(): Reference = Reference().apply { + type = resourceType + identifier = this@reference.identifier.identifier().apply { + type = org.hl7.fhir.r4.model.CodeableConcept( + Coding( + HealthConnectContract.GROVE_IDENTIFIER_ROLE, + identifierRole.code, + identifierRole.display, + ), + ) + } + addExtension( + Extension( + HealthConnectContract.GROVE_RETRACTION_TARGET_ROLE, + CodeType(role.code), + ), + ) + } +} diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectSampledQuantityConversions.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectSampledQuantityConversions.kt new file mode 100644 index 00000000..a3220dd2 --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectSampledQuantityConversions.kt @@ -0,0 +1,143 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import androidx.health.connect.client.records.CyclingPedalingCadenceRecord +import androidx.health.connect.client.records.PowerRecord +import androidx.health.connect.client.records.SkinTemperatureRecord +import androidx.health.connect.client.records.SpeedRecord +import androidx.health.connect.client.records.StepsCadenceRecord +import java.time.Instant + +internal fun HealthConnectConverter.convertCyclingCadence( + record: CyclingPedalingCadenceRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion = convertSampleSeries( + metadata = record.metadata, + recordType = HealthConnectConverter.CYCLING_PEDALING_CADENCE_RECORD, + start = record.startTime, + end = record.endTime, + samples = record.samples.map { SeriesSample(it.time, it.revolutionsPerMinute) }, + spec = MobileQuantitySpec( + profile = HealthConnectContract.MOBILE_CYCLING_CADENCE_PROFILE, + category = HealthConnectConverter.ACTIVITY_CATEGORY, + codeSystem = HealthConnectContract.GROVE_MOBILE_MEASUREMENT, + code = "cycling-cadence", + display = "Cycling cadence", + unitCode = "/min", + unitDisplay = "revolutions/minute", + ), + convertedAt = convertedAt, + eventSequence = eventSequence, +) + +internal fun HealthConnectConverter.convertPower( + record: PowerRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion = convertSampleSeries( + metadata = record.metadata, + recordType = HealthConnectConverter.POWER_RECORD, + start = record.startTime, + end = record.endTime, + samples = record.samples.map { SeriesSample(it.time, it.power.inWatts) }, + spec = MobileQuantitySpec( + profile = HealthConnectContract.MOBILE_POWER_PROFILE, + category = HealthConnectConverter.ACTIVITY_CATEGORY, + codeSystem = HealthConnectContract.GROVE_MOBILE_MEASUREMENT, + code = "power", + display = "Power", + unitCode = "W", + unitDisplay = "W", + ), + convertedAt = convertedAt, + eventSequence = eventSequence, +) + +internal fun HealthConnectConverter.convertSpeed( + record: SpeedRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion = convertSampleSeries( + metadata = record.metadata, + recordType = HealthConnectConverter.SPEED_RECORD, + start = record.startTime, + end = record.endTime, + samples = record.samples.map { SeriesSample(it.time, it.speed.inMetersPerSecond) }, + spec = MobileQuantitySpec( + profile = HealthConnectContract.MOBILE_SPEED_PROFILE, + category = HealthConnectConverter.ACTIVITY_CATEGORY, + codeSystem = HealthConnectContract.GROVE_MOBILE_MEASUREMENT, + code = "speed", + display = "Speed", + unitCode = "m/s", + unitDisplay = "m/s", + ), + convertedAt = convertedAt, + eventSequence = eventSequence, +) + +internal fun HealthConnectConverter.convertStepCadence( + record: StepsCadenceRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion = convertSampleSeries( + metadata = record.metadata, + recordType = HealthConnectConverter.STEPS_CADENCE_RECORD, + start = record.startTime, + end = record.endTime, + samples = record.samples.map { SeriesSample(it.time, it.rate) }, + spec = MobileQuantitySpec( + profile = HealthConnectContract.HEALTH_CONNECT_STEP_CADENCE_PROFILE, + category = HealthConnectConverter.ACTIVITY_CATEGORY, + codeSystem = HealthConnectContract.HEALTH_CONNECT_MEASUREMENT, + code = "step-cadence", + display = "Step cadence", + unitCode = "{steps}/min", + unitDisplay = "steps/minute", + adapterSpecific = true, + ), + convertedAt = convertedAt, + eventSequence = eventSequence, +) + +internal fun HealthConnectConverter.convertSkinTemperature( + record: SkinTemperatureRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion { + val baseline = record.baseline + if (record.deltas.isNotEmpty() && baseline == null) { + throw InvalidHealthConnectRecord( + "SkinTemperatureRecord deltas require an explicit baseline to state absolute skin temperatures.", + ) + } + return convertSampleSeries( + metadata = record.metadata, + recordType = HealthConnectConverter.SKIN_TEMPERATURE_RECORD, + start = record.startTime, + end = record.endTime, + samples = record.deltas.map { delta -> + SeriesSample(delta.time, requireNotNull(baseline).inCelsius + delta.delta.inCelsius) + }, + spec = MobileQuantitySpec( + profile = HealthConnectContract.MOBILE_SKIN_TEMPERATURE_PROFILE, + category = HealthConnectConverter.VITAL_SIGNS_CATEGORY, + codeSystem = HealthConnectContract.LOINC, + code = "61008-9", + display = "Body surface temperature", + unitCode = "Cel", + unitDisplay = "Cel", + valueDomain = QuantityValueDomain.UNBOUNDED, + ), + bodySite = skinTemperatureMeasurementLocation(record.measurementLocation), + convertedAt = convertedAt, + eventSequence = eventSequence, + ) +} diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectSeriesConversions.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectSeriesConversions.kt new file mode 100644 index 00000000..9dd6524e --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectSeriesConversions.kt @@ -0,0 +1,300 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import androidx.health.connect.client.records.HeartRateRecord +import androidx.health.connect.client.records.StepsRecord +import androidx.health.connect.client.records.WeightRecord +import androidx.health.connect.client.records.metadata.Metadata +import org.hl7.fhir.r4.model.CodeableConcept +import org.hl7.fhir.r4.model.DateTimeType +import org.hl7.fhir.r4.model.Period +import java.math.BigDecimal +import java.time.Instant +import java.time.ZoneOffset + +internal fun HealthConnectConverter.convertHeartRate( + record: HeartRateRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion { + val source = sourceIdentity(record.metadata, HealthConnectConverter.HEART_RATE_RECORD) + val resolvedContext = context.resolve( + record.metadata, + synchronizationScope.identityKey, + bundleIdentifier(eventSequence), + ) + validate(record) + + // Assign occurrences in the exact platform list order before deterministic output sorting. + // Clinical values are deliberately excluded so a corrected value retains its source slot. + val identifiedSamples = assignSourceListOccurrences(record.samples) { it.time } + val observations = identifiedSamples + .sortedWith(compareBy({ it.first.time }, { it.second })) + .map { (sample, occurrence) -> + HealthConnectWireFormat.requireFhirInstant(sample.time, "Heart-rate sample time") + val output = sampleIdentifier(source, sample, occurrence) + baseObservation(record.metadata, source, output, resolvedContext).apply { + claimMeasurementProfile(HealthConnectContract.MOBILE_HEART_RATE_PROFILE) + addCategory(concept(HealthConnectContract.OBSERVATION_CATEGORY, "vital-signs", "Vital Signs")) + code = concept(HealthConnectContract.LOINC, "8867-4", "Heart rate") + // Health Connect supplies offsets for the containing Record, not for each Sample. + effective = DateTimeType(sample.time.fhirDateTime(null, "Heart-rate sample time")) + value = quantity(sample.beatsPerMinute.toBigDecimal(), "/min", "beats/minute") + } + } + return conversion( + record.metadata, + HealthConnectConverter.HEART_RATE_RECORD, + source, + observations, + convertedAt, + eventSequence, + resolvedContext, + ) +} + +internal fun HealthConnectConverter.convertSteps( + record: StepsRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion { + val source = sourceIdentity(record.metadata, HealthConnectConverter.STEPS_RECORD) + val resolvedContext = context.resolve( + record.metadata, + synchronizationScope.identityKey, + bundleIdentifier(eventSequence), + ) + if (!record.startTime.isBefore(record.endTime)) { + throw InvalidHealthConnectRecord("StepsRecord must have a positive interval.") + } + val count = HealthConnectContract.quantityValueDomains.getValue("step-count") + .requireValue(record.count.toBigDecimal(), "StepsRecord count") + val observation = baseObservation( + record.metadata, + source, + null, + resolvedContext, + "step-count", + ).apply { + claimMeasurementProfile(HealthConnectContract.MOBILE_STEP_COUNT_PROFILE) + addCategory(concept(HealthConnectContract.OBSERVATION_CATEGORY, "activity", "Activity")) + code = concept( + HealthConnectContract.GROVE_MOBILE_MEASUREMENT, + "step-count-total", + "Step count total", + ) + effective = Period().apply { + startElement = DateTimeType(record.startTime.fhirDateTime(record.startZoneOffset, "Steps start time")) + endElement = DateTimeType(record.endTime.fhirDateTime(record.endZoneOffset, "Steps end time")) + } + value = quantity(count, "{steps}", "steps") + } + return conversion( + record.metadata, + HealthConnectConverter.STEPS_RECORD, + source, + listOf(observation), + convertedAt, + eventSequence, + resolvedContext, + ) +} + +internal fun HealthConnectConverter.convertWeight( + record: WeightRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion { + val source = sourceIdentity(record.metadata, HealthConnectConverter.WEIGHT_RECORD) + val resolvedContext = context.resolve( + record.metadata, + synchronizationScope.identityKey, + bundleIdentifier(eventSequence), + ) + val kilograms = record.weight.inKilograms + if (!kilograms.isFinite() || kilograms < 0.0 || kilograms > HealthConnectConverter.MAX_WEIGHT_KILOGRAMS) { + throw InvalidHealthConnectRecord("WeightRecord must contain a finite weight in [0, 1000] kg.") + } + val observation = baseObservation( + record.metadata, + source, + null, + resolvedContext, + "body-weight", + ).apply { + claimMeasurementProfile(HealthConnectContract.MOBILE_BODY_WEIGHT_PROFILE) + addCategory(concept(HealthConnectContract.OBSERVATION_CATEGORY, "vital-signs", "Vital Signs")) + code = concept(HealthConnectContract.LOINC, "29463-7", "Body weight") + effective = DateTimeType(record.time.fhirDateTime(record.zoneOffset, "Weight time")) + value = quantity(BigDecimal.valueOf(kilograms), "kg", "kg") + } + return conversion( + record.metadata, + HealthConnectConverter.WEIGHT_RECORD, + source, + listOf(observation), + convertedAt, + eventSequence, + resolvedContext, + ) +} + +@Suppress("LongParameterList") +internal fun HealthConnectConverter.convertInstantQuantity( + metadata: Metadata, + recordType: String, + time: Instant, + offset: ZoneOffset?, + value: Double, + spec: MobileQuantitySpec, + bodySite: CodeableConcept? = null, + method: CodeableConcept? = null, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion { + val source = sourceIdentity(metadata, recordType) + val resolvedContext = context.resolve( + metadata, + synchronizationScope.identityKey, + bundleIdentifier(eventSequence), + ) + val decimal = value.fhirDecimal(spec.display, spec.valueDomain) + val observation = baseObservation( + metadata, + source, + null, + resolvedContext, + spec.measurement, + ).apply { + claimProfile(spec) + spec.category?.let { addCategory(category(it)) } + code = concept(spec.codeSystem, spec.code, spec.display) + effective = DateTimeType(time.fhirDateTime(offset, "${spec.display} time")) + this.value = quantity(decimal, spec.unitCode, spec.unitDisplay) + this.bodySite = bodySite + this.method = method + } + return conversion( + metadata, + recordType, + source, + listOf(observation), + convertedAt, + eventSequence, + resolvedContext, + ) +} + +@Suppress("LongParameterList") +internal fun HealthConnectConverter.convertIntervalQuantity( + metadata: Metadata, + recordType: String, + start: Instant, + startOffset: ZoneOffset?, + end: Instant, + endOffset: ZoneOffset?, + value: Double, + exactValue: BigDecimal? = null, + spec: MobileQuantitySpec, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion { + if (!start.isBefore(end)) throw InvalidHealthConnectRecord("$recordType must have a positive interval.") + val source = sourceIdentity(metadata, recordType) + val resolvedContext = context.resolve( + metadata, + synchronizationScope.identityKey, + bundleIdentifier(eventSequence), + ) + val decimal = value.fhirDecimal(spec.display, spec.valueDomain, exactValue) + val observation = baseObservation( + metadata, + source, + null, + resolvedContext, + spec.measurement, + ).apply { + claimProfile(spec) + spec.category?.let { addCategory(category(it)) } + code = concept(spec.codeSystem, spec.code, spec.display) + effective = Period().apply { + startElement = DateTimeType(start.fhirDateTime(startOffset, "${spec.display} start time")) + endElement = DateTimeType(end.fhirDateTime(endOffset, "${spec.display} end time")) + } + this.value = quantity(decimal, spec.unitCode, spec.unitDisplay) + } + return conversion( + metadata, + recordType, + source, + listOf(observation), + convertedAt, + eventSequence, + resolvedContext, + ) +} + +@Suppress("LongParameterList") +internal fun HealthConnectConverter.convertSampleSeries( + metadata: Metadata, + recordType: String, + start: Instant, + end: Instant, + samples: List, + spec: MobileQuantitySpec, + bodySite: CodeableConcept? = null, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion { + if (start.isAfter(end)) { + throw InvalidHealthConnectRecord("$recordType startTime must not be after endTime.") + } + if (samples.any { it.time < start || it.time > end }) { + throw InvalidHealthConnectRecord("${spec.display} sample lies outside its source record interval.") + } + val source = sourceIdentity(metadata, recordType) + val resolvedContext = context.resolve( + metadata, + synchronizationScope.identityKey, + bundleIdentifier(eventSequence), + ) + // The adapter preserves the platform list through SeriesSample. Occurrences therefore bind + // equal-time samples to source slots before deterministic output sorting changes their order. + val identifiedSamples = assignSourceListOccurrences(samples) { it.time } + val observations = identifiedSamples + .sortedWith(compareBy({ it.first.time }, { it.second })) + .map { (sample, occurrence) -> + HealthConnectWireFormat.requireFhirInstant(sample.time, "${spec.display} sample time") + val decimal = sample.value.fhirDecimal("${spec.display} sample value", spec.valueDomain) + val output = HealthConnectIdentity.seriesSampleOutput( + synchronizationScope.identityKey, + source, + sample.time, + occurrence, + ) + baseObservation(metadata, source, output, resolvedContext).apply { + claimProfile(spec) + spec.category?.let { addCategory(category(it)) } + code = concept(spec.codeSystem, spec.code, spec.display) + // Health Connect supplies offsets for the containing Record, not for each Sample. + effective = DateTimeType(sample.time.fhirDateTime(null, "${spec.display} sample time")) + value = quantity(decimal, spec.unitCode, spec.unitDisplay) + this.bodySite = bodySite?.copy() + } + } + return conversion( + metadata, + recordType, + source, + observations, + convertedAt, + eventSequence, + resolvedContext, + ) +} diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectSessionConversions.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectSessionConversions.kt new file mode 100644 index 00000000..e2952333 --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectSessionConversions.kt @@ -0,0 +1,362 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +@file:OptIn(androidx.health.connect.client.feature.ExperimentalMindfulnessSessionApi::class) + +package org.grovealliance.health.fhir + +import androidx.health.connect.client.records.ExerciseSessionRecord +import androidx.health.connect.client.records.MindfulnessSessionRecord +import androidx.health.connect.client.records.SleepSessionRecord +import androidx.health.connect.client.records.metadata.Metadata +import org.hl7.fhir.r4.model.CodeableConcept +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.DateTimeType +import org.hl7.fhir.r4.model.Observation +import org.hl7.fhir.r4.model.Period +import org.hl7.fhir.r4.model.Reference +import java.math.BigDecimal +import java.math.RoundingMode +import java.time.Instant +import java.time.temporal.ChronoUnit + +internal fun HealthConnectConverter.convertMindfulnessSession( + record: MindfulnessSessionRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion { + val minutes = mindfulnessDurationMinutes(record.startTime, record.endTime) + val source = sourceIdentity(record.metadata, HealthConnectConverter.MINDFULNESS_SESSION_RECORD) + val resolvedContext = context.resolve( + record.metadata, + synchronizationScope.identityKey, + bundleIdentifier(eventSequence), + ) + val observation = baseObservation( + record.metadata, + source, + null, + resolvedContext, + "mindfulness-session", + ).apply { + claimMeasurementProfile(HealthConnectContract.MOBILE_MINDFULNESS_SESSION_PROFILE) + addCategory(category(HealthConnectConverter.ACTIVITY_CATEGORY)) + code = concept( + HealthConnectContract.GROVE_MOBILE_MEASUREMENT, + "mindfulness-session-duration", + "Mindfulness session duration", + ) + effective = Period().apply { + startElement = DateTimeType( + record.startTime.fhirDateTime(record.startZoneOffset, "Mindfulness start time"), + ) + endElement = DateTimeType( + record.endTime.fhirDateTime(record.endZoneOffset, "Mindfulness end time"), + ) + } + value = quantity(minutes, "min", "min") + method = CodeableConcept(mindfulnessSessionTypeCoding(record.mindfulnessSessionType)) + retainSessionText( + record.title, + record.notes, + HealthConnectConverter.MINDFULNESS_SESSION_RECORD, + ) + } + return conversion( + record.metadata, + HealthConnectConverter.MINDFULNESS_SESSION_RECORD, + source, + listOf(observation), + convertedAt, + eventSequence, + resolvedContext, + ) +} + +@Suppress("LongMethod") +internal fun HealthConnectConverter.convertSleepDuration( + record: SleepSessionRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion { + val nanos = ChronoUnit.NANOS.between(record.startTime, record.endTime) + if (nanos <= 0L) throw InvalidHealthConnectRecord("SleepSessionRecord must have a positive interval.") + val hours = BigDecimal.valueOf(nanos) + .divide(HealthConnectConverter.NANOSECONDS_PER_HOUR, HealthConnectConverter.SESSION_DURATION_SCALE, RoundingMode.HALF_EVEN) + .stripTrailingZeros() + .withPlainScale() + val source = sourceIdentity(record.metadata, HealthConnectConverter.SLEEP_SESSION_RECORD) + val resolvedContext = context.resolve( + record.metadata, + synchronizationScope.identityKey, + bundleIdentifier(eventSequence), + ) + // Health Connect's platform list order is the only available stable slot identity for + // otherwise-identical stages. Assign duplicate occurrences before presentation sorting; + // sorting by a mutable value or iterating an unordered collection would change identities. + val identifiedStages = assignSourceListOccurrences(record.stages) { stage -> + Triple(stage.startTime, stage.endTime, stage.stage) + } + val stages = identifiedStages + .sortedWith( + compareBy( + { it.first.startTime }, + { it.first.endTime }, + { it.first.stage }, + { it.second }, + ), + ) + .map { (stage, occurrence) -> + if ( + !stage.startTime.isBefore(stage.endTime) || + stage.startTime < record.startTime || + stage.endTime > record.endTime + ) { + throw InvalidHealthConnectRecord( + "Every SleepSessionRecord stage must be a positive interval inside its session.", + ) + } + val stageIdentity = sleepStageIdentifier(source, stage, occurrence) + val stageCoding = sleepStageCoding(stage.stage) + baseObservation(record.metadata, source, stageIdentity, resolvedContext).apply { + claimMeasurementProfile(HealthConnectContract.MOBILE_SLEEP_STAGE_PROFILE) + addCategory(category(HealthConnectConverter.ACTIVITY_CATEGORY)) + code = concept( + HealthConnectContract.GROVE_MOBILE_MEASUREMENT, + "sleep-stage", + "Sleep stage", + ) + effective = Period().apply { + startElement = DateTimeType( + stage.startTime.fhirDateTime(null, "Sleep-stage start time"), + ) + endElement = DateTimeType( + stage.endTime.fhirDateTime(null, "Sleep-stage end time"), + ) + } + value = CodeableConcept().apply { + addCoding( + Coding( + HealthConnectContract.GROVE_SLEEP_STAGE, + stageCoding.sharedCode, + stageCoding.sharedDisplay, + ), + ) + addCoding( + Coding( + HealthConnectContract.HEALTH_CONNECT_SLEEP_STAGE, + stageCoding.sourceCode, + stageCoding.sourceDisplay, + ), + ) + } + } + } + val summary = baseObservation( + record.metadata, + source, + null, + resolvedContext, + "sleep-duration", + ).apply { + claimMeasurementProfile(HealthConnectContract.MOBILE_SLEEP_DURATION_PROFILE) + addCategory(category(HealthConnectConverter.ACTIVITY_CATEGORY)) + code = concept(HealthConnectContract.LOINC, "93832-4", "Sleep duration") + effective = Period().apply { + startElement = DateTimeType(record.startTime.fhirDateTime(record.startZoneOffset, "Sleep start time")) + endElement = DateTimeType(record.endTime.fhirDateTime(record.endZoneOffset, "Sleep end time")) + } + value = quantity(hours, "h", "h") + stages.forEach { stage -> + addHasMember(Reference(GroveExchangeIdentity.fullUrl(outputIdentifier(stage)))) + } + retainSessionText( + record.title, + record.notes, + HealthConnectConverter.SLEEP_SESSION_RECORD, + ) + } + return conversion( + record.metadata, + HealthConnectConverter.SLEEP_SESSION_RECORD, + source, + listOf(summary) + stages, + convertedAt, + eventSequence, + resolvedContext, + ) +} + +internal fun HealthConnectConverter.convertExerciseSession( + record: ExerciseSessionRecord, + convertedAt: Instant, + eventSequence: EventSequence, +): HealthConnectConversion { + if (!record.startTime.isBefore(record.endTime)) { + throw InvalidHealthConnectRecord("ExerciseSessionRecord must have a positive interval.") + } + val source = sourceIdentity(record.metadata, HealthConnectConverter.EXERCISE_SESSION_RECORD) + val resolvedContext = context.resolve( + record.metadata, + synchronizationScope.identityKey, + bundleIdentifier(eventSequence), + ) + val children = workoutSegments(record, source, resolvedContext) + + workoutLaps(record, source, resolvedContext) + val summary = baseObservation( + record.metadata, + source, + null, + resolvedContext, + "workout", + ).apply { + claimMeasurementProfile(HealthConnectContract.MOBILE_WORKOUT_PROFILE) + addCategory(category(HealthConnectConverter.ACTIVITY_CATEGORY)) + code = concept(HealthConnectContract.GROVE_MOBILE_MEASUREMENT, "workout", "Workout session") + effective = Period().apply { + startElement = DateTimeType( + record.startTime.fhirDateTime(record.startZoneOffset, "Workout start time"), + ) + endElement = DateTimeType(record.endTime.fhirDateTime(record.endZoneOffset, "Workout end time")) + } + value = codedValue( + HealthConnectContract.GROVE_WORKOUT_ACTIVITY, + HealthConnectContract.HEALTH_CONNECT_EXERCISE_TYPE, + HealthConnectWorkoutVocabulary.activity(record.exerciseType), + ) + children.forEach { child -> + addHasMember(Reference(GroveExchangeIdentity.fullUrl(outputIdentifier(child)))) + } + retainSessionText( + record.title, + record.notes, + HealthConnectConverter.EXERCISE_SESSION_RECORD, + ) + } + return conversion( + record.metadata, + HealthConnectConverter.EXERCISE_SESSION_RECORD, + source, + listOf(summary) + children, + convertedAt, + eventSequence, + resolvedContext, + ) +} + +internal fun HealthConnectConverter.workoutSegments( + record: ExerciseSessionRecord, + source: HealthConnectSourceIdentity, + resolvedContext: ResolvedFhirContext, +): List { + val identifiedSegments = assignSourceListOccurrences(record.segments) { segment -> + Triple(segment.startTime, segment.endTime, segment.segmentType) + } + return identifiedSegments + .sortedWith(compareBy({ it.first.startTime }, { it.first.endTime }, { it.first.segmentType }, { it.second })) + .map { (segment, occurrence) -> + requireWorkoutInterval(record, segment.startTime, segment.endTime) + val classification = HealthConnectWorkoutVocabulary.segment(segment.segmentType) + workoutSegmentObservation( + record.metadata, + source, + resolvedContext, + segment.startTime, + segment.endTime, + occurrence, + classification, + ).apply { + if (segment.repetitions > 0) { + addComponent().apply { + code = workoutStatistic("repetitions", "Repetitions") + value = quantity(segment.repetitions.toBigDecimal(), "{count}", "repetitions") + } + } + } + } +} + +internal fun HealthConnectConverter.workoutLaps( + record: ExerciseSessionRecord, + source: HealthConnectSourceIdentity, + resolvedContext: ResolvedFhirContext, +): List { + val identifiedLaps = assignSourceListOccurrences(record.laps) { lap -> + lap.startTime to lap.endTime + } + return identifiedLaps + .sortedWith(compareBy({ it.first.startTime }, { it.first.endTime }, { it.second })) + .map { (lap, occurrence) -> + requireWorkoutInterval(record, lap.startTime, lap.endTime) + workoutSegmentObservation( + record.metadata, + source, + resolvedContext, + lap.startTime, + lap.endTime, + occurrence, + HealthConnectWorkoutVocabulary.lap(), + ).apply { + lap.length?.let { length -> + addComponent().apply { + code = workoutStatistic("lap-length", "Lap length") + value = quantity(length.inMeters.fhirDecimal("Workout lap length"), "m", "m") + } + } + } + } +} + +@Suppress("LongParameterList") +internal fun HealthConnectConverter.workoutSegmentObservation( + metadata: Metadata, + source: HealthConnectSourceIdentity, + resolvedContext: ResolvedFhirContext, + start: Instant, + end: Instant, + occurrence: Int, + classification: WorkoutClassification, +): Observation { + val identity = HealthConnectIdentity.segmentOutput( + synchronizationScope.identityKey, + source, + start, + end, + classification.value.sourceCode, + occurrence, + ) + return baseObservation(metadata, source, identity, resolvedContext).apply { + claimMeasurementProfile(HealthConnectContract.MOBILE_WORKOUT_SEGMENT_PROFILE) + addCategory(category(HealthConnectConverter.ACTIVITY_CATEGORY)) + code = concept( + HealthConnectContract.GROVE_MOBILE_MEASUREMENT, + "workout-segment", + "Workout segment", + ) + // Health Connect supplies offsets for the containing session, not for each segment or lap. + effective = Period().apply { + startElement = DateTimeType(start.fhirDateTime(null, "Workout-segment start time")) + endElement = DateTimeType(end.fhirDateTime(null, "Workout-segment end time")) + } + value = codedValue( + classification.sharedSystem, + HealthConnectContract.HEALTH_CONNECT_EXERCISE_SEGMENT_TYPE, + classification.value, + ) + } +} + +internal fun HealthConnectConverter.requireWorkoutInterval(record: ExerciseSessionRecord, start: Instant, end: Instant) { + if (!start.isBefore(end) || start < record.startTime || end > record.endTime) { + throw InvalidHealthConnectRecord( + "Every ExerciseSessionRecord segment and lap must be a positive interval inside its session.", + ) + } +} + +internal fun HealthConnectConverter.workoutStatistic(code: String, display: String): CodeableConcept = + concept(HealthConnectContract.GROVE_WORKOUT_STATISTIC, code, display) diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectSynchronizationConstraint.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectSynchronizationConstraint.kt new file mode 100644 index 00000000..a1cf9879 --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectSynchronizationConstraint.kt @@ -0,0 +1,79 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import androidx.health.connect.client.records.Record +import org.grovealliance.health.AnyRecordType +import org.grovealliance.health.HealthConstraint +import org.grovealliance.health.RecordType +import java.time.Instant + +/** Supplies a complete paged read for one Health Connect record type after token expiry. */ +fun interface HealthConnectFullReader { + suspend fun readAll(type: AnyRecordType): List +} + +/** + * Connects the reusable Health collector to the acknowledged FHIR export coordinator. + * + * Production wiring must provide a durable journal, an idempotent sink, and a full reader that + * drains every Health Connect page for the requested type. + */ +class HealthConnectSynchronizationConstraint( + private val coordinator: HealthConnectExportCoordinator, + private val fullReader: HealthConnectFullReader, + private val now: () -> Instant, +) : HealthConstraint { + override fun collectionScopeId(type: RecordType): String { + requireSupported(type.identifier) + // HealthConstraint is an external interface keyed by opaque strings. + return coordinator.collectionScopeId.value + } + + override fun collectionRepositoryId(type: RecordType): String { + requireSupported(type.identifier) + return coordinator.repositoryScopeId.value + } + + override suspend fun handleNewRecords( + addedRecords: Set, + type: RecordType, + ) { + requireSupported(type.identifier) + addedRecords.sortedBy { it.metadata.id }.forEach { coordinator.upsert(it, now()) } + } + + override suspend fun handleDeletedRecords( + deletedRecordIds: Set, + type: RecordType, + ) { + requireSupported(type.identifier) + deletedRecordIds.sorted().forEach { coordinator.delete(type.identifier, it, now()) } + } + + override suspend fun handleExcludedRecords( + excludedRecordIds: Set, + type: RecordType, + ) { + requireSupported(type.identifier) + excludedRecordIds.sorted().forEach { coordinator.delete(type.identifier, it, now()) } + } + + override suspend fun onFullyResyncRequired(type: RecordType) { + requireSupported(type.identifier) + coordinator.reconcile(type.identifier, now) { + fullReader.readAll(type) + } + } + + private fun requireSupported(recordType: String) { + if (recordType !in HealthConnectCatalog.supportedRecordTypeIdentifiers) { + throw UnsupportedHealthConnectRecord(recordType) + } + } +} diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectSynchronizationScope.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectSynchronizationScope.kt new file mode 100644 index 00000000..392462e9 --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectSynchronizationScope.kt @@ -0,0 +1,129 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import java.nio.charset.StandardCharsets +import java.util.UUID + +/** + * Stable, local scope for one Health Connect repository and one synchronized projection. + * + * Health Connect's `Metadata.id` is scoped to a repository. The repository scope is a complete + * deployment-owned Identifier pair that the caller must persist for the lifetime of exactly one + * repository. [generateRepositoryScope] creates an opaque UUIDv4-based pair, but a deployment may + * supply an equivalent governed pair. A scope must never be cloned into a different repository. + * [configurationFingerprint] identifies the exact source/filter configuration used by the full + * reader and must change when that projection changes. The adapter-owned conversion-contract + * version is included automatically, so an adapter upgrade cannot resume an old cursor without a + * new baseline. None of these inputs is serialized into FHIR or the exchange Bundle. + */ +class HealthConnectSynchronizationScope private constructor( + internal val repositoryScope: FhirIdentifierKey, + internal val producerInstance: String, + configurationFingerprint: String, + internal val conversionContractVersion: String, + internal val identityKey: GroveHmacIdentityKey, +) { + /** Opaque local key shared by every projection over this repository. */ + val repositoryScopeKey: ScopeKey = ScopeKey( + digest( + "repository\u0000${repositoryScope.system.sized()}\u0000${repositoryScope.value.sized()}", + ), + ) + + /** Opaque local key for token state and one exact full-read/filter configuration. */ + val projectionScopeKey: ScopeKey = ScopeKey( + digest( + "projection\u0000${repositoryScope.system.sized()}\u0000${repositoryScope.value.sized()}" + + "\u0000${configurationFingerprint.sized()}\u0000${conversionContractVersion.sized()}" + + "\u0000${identityKey.identifierSystemFamily.sized()}" + + "\u0000${identityKey.keyId.sized()}\u0000${identityKey.epoch.sized()}", + ), + ) + + init { + require(configurationFingerprint.isNotBlank()) { + "An explicit Health Connect filter/configuration fingerprint is required." + } + require(producerInstance.isCanonicalProducerUuid()) { + "The producer instance must be a durable canonical lowercase RFC 4122 UUID." + } + require(conversionContractVersion.isNotBlank()) { + "The adapter conversion-contract version must not be blank." + } + } + + /** Produces a typed opaque source-record Identifier without exposing any native input field. */ + internal fun sourceRecordIdentifier( + recordTypeToken: String, + healthConnectId: String, + ) = HealthConnectIdentity.record(identityKey, repositoryScope, recordTypeToken, healthConnectId) + + override fun equals(other: Any?): Boolean = + other is HealthConnectSynchronizationScope && + repositoryScopeKey == other.repositoryScopeKey && + projectionScopeKey == other.projectionScopeKey + + override fun hashCode(): Int = 31 * repositoryScopeKey.hashCode() + projectionScopeKey.hashCode() + + override fun toString(): String = + "HealthConnectSynchronizationScope(repositoryScopeKey=$repositoryScopeKey, " + + "projectionScopeKey=$projectionScopeKey)" + + companion object { + /** Generates a repository scope that the caller must durably bind to exactly one repository. */ + fun generateRepositoryScope(): FhirIdentifierKey = + FhirIdentifierKey("urn:uuid:${UUID.randomUUID()}", DEFAULT_REPOSITORY_PARTITION) + + fun create( + repositoryScope: FhirIdentifierKey, + producerInstance: String, + configurationFingerprint: String, + identityKey: GroveHmacIdentityKey, + ): HealthConnectSynchronizationScope = + HealthConnectSynchronizationScope( + repositoryScope, + producerInstance, + configurationFingerprint, + HealthConnectContract.CONVERSION_CONTRACT_VERSION, + identityKey, + ) + + /** Test-only seam proving an adapter contract upgrade changes the mandatory projection scope. */ + internal fun createForContractVersion( + repositoryScope: FhirIdentifierKey, + producerInstance: String, + configurationFingerprint: String, + conversionContractVersion: String, + identityKey: GroveHmacIdentityKey, + ): HealthConnectSynchronizationScope = + HealthConnectSynchronizationScope( + repositoryScope, + producerInstance, + configurationFingerprint, + conversionContractVersion, + identityKey, + ) + } +} + +private fun String.sized(): String = "${toByteArray(StandardCharsets.UTF_8).size}:$this" + +private fun digest(preimage: String): String = "v1:${HealthConnectWireFormat.sha256(preimage)}" + +private fun String.isCanonicalProducerUuid(): Boolean = runCatching { + val uuid = UUID.fromString(this) + uuid.toString() == this && + uuid.version() in MIN_RFC_4122_VERSION..MAX_RFC_4122_VERSION && + uuid.variant() == RFC_4122_VARIANT +}.getOrDefault(false) + +private const val DEFAULT_REPOSITORY_PARTITION = "default" +private const val MIN_RFC_4122_VERSION = 1 +private const val MAX_RFC_4122_VERSION = 5 +private const val RFC_4122_VARIANT = 2 diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectWireFormat.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectWireFormat.kt new file mode 100644 index 00000000..96609010 --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectWireFormat.kt @@ -0,0 +1,73 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import org.hl7.fhir.r4.formats.IParser +import org.hl7.fhir.r4.formats.JsonParser +import org.hl7.fhir.r4.model.Bundle +import java.math.BigInteger +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.time.Instant + +/** Exact, transport-neutral encodings used by the acknowledged exchange boundary. */ +object HealthConnectWireFormat { + internal val MIN_FHIR_INSTANT: Instant = Instant.parse("0001-01-01T00:00:00Z") + internal val MAX_FHIR_INSTANT: Instant = Instant.parse("9999-12-31T23:59:59.999999999Z") + + /** + * Encodes Health Connect's nanosecond-precision source revision without numeric truncation. + * + * The result is a non-negative canonical decimal string containing epoch nanoseconds. + */ + fun sourceVersion(value: Instant): String { + requireFhirInstant(value, "Wire source version") + require(!value.isBefore(Instant.EPOCH)) { "A wire source version must not precede the Unix epoch." } + return BigInteger.valueOf(value.epochSecond) + .multiply(NANOSECONDS_PER_SECOND) + .add(BigInteger.valueOf(value.nano.toLong())) + .toString() + } + + /** Serializes one complete FHIR R4 Bundle to deterministic compact JSON for a caller-owned sink. */ + fun bundleJson(bundle: Bundle): String = + JsonParser().setOutputStyle(IParser.OutputStyle.NORMAL).composeString(bundle) + + /** Rejects a Java Instant that cannot be serialized as a FHIR R4 instant/dateTime. */ + internal fun requireFhirInstant(value: Instant, field: String) { + if (value < MIN_FHIR_INSTANT || value > MAX_FHIR_INSTANT) { + throw InvalidHealthConnectRecord( + "$field must have a four-digit FHIR year in the range 0001 through 9999.", + ) + } + } + + /** Returns lowercase SHA-256 for the exact UTF-8 string. */ + fun sha256(value: String): String = + sha256(value.toByteArray(StandardCharsets.UTF_8)) + + /** Returns lowercase SHA-256 for exact bytes such as a length-framed journal state. */ + internal fun sha256(value: ByteArray): String = + MessageDigest.getInstance("SHA-256").digest(value).let(::lowercaseHex) + + private fun lowercaseHex(bytes: ByteArray): String = buildString(capacity = bytes.size * HEX_CHARS_PER_BYTE) { + bytes.forEach { byte -> + val unsigned = byte.toInt() and BYTE_MASK + append(LOWERCASE_HEX[unsigned ushr HEX_NIBBLE_BITS]) + append(LOWERCASE_HEX[unsigned and HEX_NIBBLE_MASK]) + } + } + + private val NANOSECONDS_PER_SECOND = BigInteger.valueOf(NANOSECONDS_PER_SECOND_LONG) + private const val NANOSECONDS_PER_SECOND_LONG = 1_000_000_000L + private const val LOWERCASE_HEX = "0123456789abcdef" + private const val HEX_CHARS_PER_BYTE = 2 + private const val HEX_NIBBLE_BITS = 4 + private const val HEX_NIBBLE_MASK = 0x0f + private const val BYTE_MASK = 0xff +} diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectWorkoutVocabulary.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectWorkoutVocabulary.kt new file mode 100644 index 00000000..97989cd3 --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/HealthConnectWorkoutVocabulary.kt @@ -0,0 +1,378 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import androidx.health.connect.client.records.ExerciseSegment +import androidx.health.connect.client.records.ExerciseSessionRecord + +/** A shared workout classification together with the exact Health Connect token it absorbed. */ +internal data class WorkoutClassification( + val sharedSystem: String, + val value: SourceCodedValue, +) + +private data class WorkoutCoding( + val sourceToken: String, + val sharedCode: String, +) + +private fun activity(exerciseType: Int, sourceToken: String, sharedCode: String): Pair = + exerciseType to WorkoutCoding(sourceToken, sharedCode) + +private fun segment(segmentType: Int, sourceToken: String, sharedCode: String): Pair = + segmentType to WorkoutCoding(sourceToken, sharedCode) + +private val SHARED_ACTIVITY_DISPLAYS: Map = mapOf( + "running" to "Running", + "walking" to "Walking", + "cycling" to "Cycling", + "hiking" to "Hiking", + "swimming" to "Swimming", + "strength-training" to "Strength training", + "high-intensity-interval-training" to "High-intensity interval training", + "yoga" to "Yoga", + "pilates" to "Pilates", + "rowing" to "Rowing", + "elliptical" to "Elliptical", + "stair-climbing" to "Stair climbing", + "dancing" to "Dancing", + "tennis" to "Tennis", + "table-tennis" to "Table tennis", + "badminton" to "Badminton", + "squash" to "Squash", + "basketball" to "Basketball", + "soccer" to "Soccer", + "american-football" to "American football", + "baseball" to "Baseball", + "volleyball" to "Volleyball", + "golf" to "Golf", + "boxing" to "Boxing", + "martial-arts" to "Martial arts", + "skiing" to "Skiing", + "snowboarding" to "Snowboarding", + "other" to "Other activity", +) + +private val SHARED_SEGMENT_DISPLAYS: Map = mapOf( + "lap" to "Lap", + "pause" to "Pause", + "resume" to "Resume", + "motion-paused" to "Motion paused", + "motion-resumed" to "Motion resumed", + "pause-or-resume-request" to "Pause or resume request", + "marker" to "Marker", + "segment-generic" to "Segment", + "rest" to "Rest", + "other-workout" to "Other workout", + "unknown" to "Unknown", +) + +/** Every AndroidX 1.1 exercise type; a type without a shared equivalent absorbs into #other. */ +private val EXERCISE_TYPES: Map = mapOf( + activity(ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT, "EXERCISE_TYPE_OTHER_WORKOUT", "other"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_BADMINTON, "EXERCISE_TYPE_BADMINTON", "badminton"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_BASEBALL, "EXERCISE_TYPE_BASEBALL", "baseball"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_BASKETBALL, "EXERCISE_TYPE_BASKETBALL", "basketball"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_BIKING, "EXERCISE_TYPE_BIKING", "cycling"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_BIKING_STATIONARY, "EXERCISE_TYPE_BIKING_STATIONARY", "cycling"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_BOOT_CAMP, "EXERCISE_TYPE_BOOT_CAMP", "other"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_BOXING, "EXERCISE_TYPE_BOXING", "boxing"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_CALISTHENICS, "EXERCISE_TYPE_CALISTHENICS", "other"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_CRICKET, "EXERCISE_TYPE_CRICKET", "other"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_DANCING, "EXERCISE_TYPE_DANCING", "dancing"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_ELLIPTICAL, "EXERCISE_TYPE_ELLIPTICAL", "elliptical"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_EXERCISE_CLASS, "EXERCISE_TYPE_EXERCISE_CLASS", "other"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_FENCING, "EXERCISE_TYPE_FENCING", "other"), + activity( + ExerciseSessionRecord.EXERCISE_TYPE_FOOTBALL_AMERICAN, + "EXERCISE_TYPE_FOOTBALL_AMERICAN", + "american-football", + ), + activity(ExerciseSessionRecord.EXERCISE_TYPE_FOOTBALL_AUSTRALIAN, "EXERCISE_TYPE_FOOTBALL_AUSTRALIAN", "other"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_FRISBEE_DISC, "EXERCISE_TYPE_FRISBEE_DISC", "other"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_GOLF, "EXERCISE_TYPE_GOLF", "golf"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_GUIDED_BREATHING, "EXERCISE_TYPE_GUIDED_BREATHING", "other"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_GYMNASTICS, "EXERCISE_TYPE_GYMNASTICS", "other"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_HANDBALL, "EXERCISE_TYPE_HANDBALL", "other"), + activity( + ExerciseSessionRecord.EXERCISE_TYPE_HIGH_INTENSITY_INTERVAL_TRAINING, + "EXERCISE_TYPE_HIGH_INTENSITY_INTERVAL_TRAINING", + "high-intensity-interval-training", + ), + activity(ExerciseSessionRecord.EXERCISE_TYPE_HIKING, "EXERCISE_TYPE_HIKING", "hiking"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_ICE_HOCKEY, "EXERCISE_TYPE_ICE_HOCKEY", "other"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_ICE_SKATING, "EXERCISE_TYPE_ICE_SKATING", "other"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_MARTIAL_ARTS, "EXERCISE_TYPE_MARTIAL_ARTS", "martial-arts"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_PADDLING, "EXERCISE_TYPE_PADDLING", "other"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_PARAGLIDING, "EXERCISE_TYPE_PARAGLIDING", "other"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_PILATES, "EXERCISE_TYPE_PILATES", "pilates"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_RACQUETBALL, "EXERCISE_TYPE_RACQUETBALL", "other"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_ROCK_CLIMBING, "EXERCISE_TYPE_ROCK_CLIMBING", "other"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_ROLLER_HOCKEY, "EXERCISE_TYPE_ROLLER_HOCKEY", "other"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_ROWING, "EXERCISE_TYPE_ROWING", "rowing"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_ROWING_MACHINE, "EXERCISE_TYPE_ROWING_MACHINE", "rowing"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_RUGBY, "EXERCISE_TYPE_RUGBY", "other"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_RUNNING, "EXERCISE_TYPE_RUNNING", "running"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_RUNNING_TREADMILL, "EXERCISE_TYPE_RUNNING_TREADMILL", "running"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_SAILING, "EXERCISE_TYPE_SAILING", "other"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_SCUBA_DIVING, "EXERCISE_TYPE_SCUBA_DIVING", "other"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_SKATING, "EXERCISE_TYPE_SKATING", "other"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_SKIING, "EXERCISE_TYPE_SKIING", "skiing"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_SNOWBOARDING, "EXERCISE_TYPE_SNOWBOARDING", "snowboarding"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_SNOWSHOEING, "EXERCISE_TYPE_SNOWSHOEING", "other"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_SOCCER, "EXERCISE_TYPE_SOCCER", "soccer"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_SOFTBALL, "EXERCISE_TYPE_SOFTBALL", "other"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_SQUASH, "EXERCISE_TYPE_SQUASH", "squash"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_STAIR_CLIMBING, "EXERCISE_TYPE_STAIR_CLIMBING", "stair-climbing"), + activity( + ExerciseSessionRecord.EXERCISE_TYPE_STAIR_CLIMBING_MACHINE, + "EXERCISE_TYPE_STAIR_CLIMBING_MACHINE", + "stair-climbing", + ), + activity( + ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING, + "EXERCISE_TYPE_STRENGTH_TRAINING", + "strength-training", + ), + activity(ExerciseSessionRecord.EXERCISE_TYPE_STRETCHING, "EXERCISE_TYPE_STRETCHING", "other"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_SURFING, "EXERCISE_TYPE_SURFING", "other"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_OPEN_WATER, "EXERCISE_TYPE_SWIMMING_OPEN_WATER", "swimming"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_POOL, "EXERCISE_TYPE_SWIMMING_POOL", "swimming"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_TABLE_TENNIS, "EXERCISE_TYPE_TABLE_TENNIS", "table-tennis"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_TENNIS, "EXERCISE_TYPE_TENNIS", "tennis"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_VOLLEYBALL, "EXERCISE_TYPE_VOLLEYBALL", "volleyball"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_WALKING, "EXERCISE_TYPE_WALKING", "walking"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_WATER_POLO, "EXERCISE_TYPE_WATER_POLO", "other"), + activity( + ExerciseSessionRecord.EXERCISE_TYPE_WEIGHTLIFTING, + "EXERCISE_TYPE_WEIGHTLIFTING", + "strength-training", + ), + activity(ExerciseSessionRecord.EXERCISE_TYPE_WHEELCHAIR, "EXERCISE_TYPE_WHEELCHAIR", "other"), + activity(ExerciseSessionRecord.EXERCISE_TYPE_YOGA, "EXERCISE_TYPE_YOGA", "yoga"), +) + +/** + * Every AndroidX 1.1 segment type. Health Connect states structure only for unknown, pause, rest, + * and nested other-workout segments; every remaining token is an activity segment, so it absorbs + * into the shared activity codes and falls back to #other. + */ +private val EXERCISE_SEGMENT_TYPES: Map = mapOf( + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_UNKNOWN, "EXERCISE_SEGMENT_TYPE_UNKNOWN", "unknown"), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_OTHER_WORKOUT, "EXERCISE_SEGMENT_TYPE_OTHER_WORKOUT", "other-workout"), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_PAUSE, "EXERCISE_SEGMENT_TYPE_PAUSE", "pause"), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_REST, "EXERCISE_SEGMENT_TYPE_REST", "rest"), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_ARM_CURL, "EXERCISE_SEGMENT_TYPE_ARM_CURL", "strength-training"), + segment( + ExerciseSegment.EXERCISE_SEGMENT_TYPE_BACK_EXTENSION, + "EXERCISE_SEGMENT_TYPE_BACK_EXTENSION", + "strength-training", + ), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_BALL_SLAM, "EXERCISE_SEGMENT_TYPE_BALL_SLAM", "strength-training"), + segment( + ExerciseSegment.EXERCISE_SEGMENT_TYPE_BARBELL_SHOULDER_PRESS, + "EXERCISE_SEGMENT_TYPE_BARBELL_SHOULDER_PRESS", + "strength-training", + ), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_BENCH_PRESS, "EXERCISE_SEGMENT_TYPE_BENCH_PRESS", "strength-training"), + segment( + ExerciseSegment.EXERCISE_SEGMENT_TYPE_BENCH_SIT_UP, + "EXERCISE_SEGMENT_TYPE_BENCH_SIT_UP", + "strength-training", + ), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_BIKING, "EXERCISE_SEGMENT_TYPE_BIKING", "cycling"), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_BIKING_STATIONARY, "EXERCISE_SEGMENT_TYPE_BIKING_STATIONARY", "cycling"), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_BURPEE, "EXERCISE_SEGMENT_TYPE_BURPEE", "other"), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_CRUNCH, "EXERCISE_SEGMENT_TYPE_CRUNCH", "strength-training"), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_DEADLIFT, "EXERCISE_SEGMENT_TYPE_DEADLIFT", "strength-training"), + segment( + ExerciseSegment.EXERCISE_SEGMENT_TYPE_DOUBLE_ARM_TRICEPS_EXTENSION, + "EXERCISE_SEGMENT_TYPE_DOUBLE_ARM_TRICEPS_EXTENSION", + "strength-training", + ), + segment( + ExerciseSegment.EXERCISE_SEGMENT_TYPE_DUMBBELL_CURL_LEFT_ARM, + "EXERCISE_SEGMENT_TYPE_DUMBBELL_CURL_LEFT_ARM", + "strength-training", + ), + segment( + ExerciseSegment.EXERCISE_SEGMENT_TYPE_DUMBBELL_CURL_RIGHT_ARM, + "EXERCISE_SEGMENT_TYPE_DUMBBELL_CURL_RIGHT_ARM", + "strength-training", + ), + segment( + ExerciseSegment.EXERCISE_SEGMENT_TYPE_DUMBBELL_FRONT_RAISE, + "EXERCISE_SEGMENT_TYPE_DUMBBELL_FRONT_RAISE", + "strength-training", + ), + segment( + ExerciseSegment.EXERCISE_SEGMENT_TYPE_DUMBBELL_LATERAL_RAISE, + "EXERCISE_SEGMENT_TYPE_DUMBBELL_LATERAL_RAISE", + "strength-training", + ), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_DUMBBELL_ROW, "EXERCISE_SEGMENT_TYPE_DUMBBELL_ROW", "strength-training"), + segment( + ExerciseSegment.EXERCISE_SEGMENT_TYPE_DUMBBELL_TRICEPS_EXTENSION_LEFT_ARM, + "EXERCISE_SEGMENT_TYPE_DUMBBELL_TRICEPS_EXTENSION_LEFT_ARM", + "strength-training", + ), + segment( + ExerciseSegment.EXERCISE_SEGMENT_TYPE_DUMBBELL_TRICEPS_EXTENSION_RIGHT_ARM, + "EXERCISE_SEGMENT_TYPE_DUMBBELL_TRICEPS_EXTENSION_RIGHT_ARM", + "strength-training", + ), + segment( + ExerciseSegment.EXERCISE_SEGMENT_TYPE_DUMBBELL_TRICEPS_EXTENSION_TWO_ARM, + "EXERCISE_SEGMENT_TYPE_DUMBBELL_TRICEPS_EXTENSION_TWO_ARM", + "strength-training", + ), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_ELLIPTICAL, "EXERCISE_SEGMENT_TYPE_ELLIPTICAL", "elliptical"), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_FORWARD_TWIST, "EXERCISE_SEGMENT_TYPE_FORWARD_TWIST", "other"), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_FRONT_RAISE, "EXERCISE_SEGMENT_TYPE_FRONT_RAISE", "strength-training"), + segment( + ExerciseSegment.EXERCISE_SEGMENT_TYPE_HIGH_INTENSITY_INTERVAL_TRAINING, + "EXERCISE_SEGMENT_TYPE_HIGH_INTENSITY_INTERVAL_TRAINING", + "high-intensity-interval-training", + ), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_HIP_THRUST, "EXERCISE_SEGMENT_TYPE_HIP_THRUST", "strength-training"), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_HULA_HOOP, "EXERCISE_SEGMENT_TYPE_HULA_HOOP", "other"), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_JUMPING_JACK, "EXERCISE_SEGMENT_TYPE_JUMPING_JACK", "other"), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_JUMP_ROPE, "EXERCISE_SEGMENT_TYPE_JUMP_ROPE", "other"), + segment( + ExerciseSegment.EXERCISE_SEGMENT_TYPE_KETTLEBELL_SWING, + "EXERCISE_SEGMENT_TYPE_KETTLEBELL_SWING", + "strength-training", + ), + segment( + ExerciseSegment.EXERCISE_SEGMENT_TYPE_LATERAL_RAISE, + "EXERCISE_SEGMENT_TYPE_LATERAL_RAISE", + "strength-training", + ), + segment( + ExerciseSegment.EXERCISE_SEGMENT_TYPE_LAT_PULL_DOWN, + "EXERCISE_SEGMENT_TYPE_LAT_PULL_DOWN", + "strength-training", + ), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_LEG_CURL, "EXERCISE_SEGMENT_TYPE_LEG_CURL", "strength-training"), + segment( + ExerciseSegment.EXERCISE_SEGMENT_TYPE_LEG_EXTENSION, + "EXERCISE_SEGMENT_TYPE_LEG_EXTENSION", + "strength-training", + ), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_LEG_PRESS, "EXERCISE_SEGMENT_TYPE_LEG_PRESS", "strength-training"), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_LEG_RAISE, "EXERCISE_SEGMENT_TYPE_LEG_RAISE", "strength-training"), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_LUNGE, "EXERCISE_SEGMENT_TYPE_LUNGE", "strength-training"), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_MOUNTAIN_CLIMBER, "EXERCISE_SEGMENT_TYPE_MOUNTAIN_CLIMBER", "other"), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_PILATES, "EXERCISE_SEGMENT_TYPE_PILATES", "pilates"), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_PLANK, "EXERCISE_SEGMENT_TYPE_PLANK", "other"), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_PULL_UP, "EXERCISE_SEGMENT_TYPE_PULL_UP", "strength-training"), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_PUNCH, "EXERCISE_SEGMENT_TYPE_PUNCH", "other"), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_ROWING_MACHINE, "EXERCISE_SEGMENT_TYPE_ROWING_MACHINE", "rowing"), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_RUNNING, "EXERCISE_SEGMENT_TYPE_RUNNING", "running"), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_RUNNING_TREADMILL, "EXERCISE_SEGMENT_TYPE_RUNNING_TREADMILL", "running"), + segment( + ExerciseSegment.EXERCISE_SEGMENT_TYPE_SHOULDER_PRESS, + "EXERCISE_SEGMENT_TYPE_SHOULDER_PRESS", + "strength-training", + ), + segment( + ExerciseSegment.EXERCISE_SEGMENT_TYPE_SINGLE_ARM_TRICEPS_EXTENSION, + "EXERCISE_SEGMENT_TYPE_SINGLE_ARM_TRICEPS_EXTENSION", + "strength-training", + ), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_SIT_UP, "EXERCISE_SEGMENT_TYPE_SIT_UP", "strength-training"), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_SQUAT, "EXERCISE_SEGMENT_TYPE_SQUAT", "strength-training"), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_STAIR_CLIMBING, "EXERCISE_SEGMENT_TYPE_STAIR_CLIMBING", "stair-climbing"), + segment( + ExerciseSegment.EXERCISE_SEGMENT_TYPE_STAIR_CLIMBING_MACHINE, + "EXERCISE_SEGMENT_TYPE_STAIR_CLIMBING_MACHINE", + "stair-climbing", + ), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_STRETCHING, "EXERCISE_SEGMENT_TYPE_STRETCHING", "other"), + segment( + ExerciseSegment.EXERCISE_SEGMENT_TYPE_SWIMMING_BACKSTROKE, + "EXERCISE_SEGMENT_TYPE_SWIMMING_BACKSTROKE", + "swimming", + ), + segment( + ExerciseSegment.EXERCISE_SEGMENT_TYPE_SWIMMING_BREASTSTROKE, + "EXERCISE_SEGMENT_TYPE_SWIMMING_BREASTSTROKE", + "swimming", + ), + segment( + ExerciseSegment.EXERCISE_SEGMENT_TYPE_SWIMMING_BUTTERFLY, + "EXERCISE_SEGMENT_TYPE_SWIMMING_BUTTERFLY", + "swimming", + ), + segment( + ExerciseSegment.EXERCISE_SEGMENT_TYPE_SWIMMING_FREESTYLE, + "EXERCISE_SEGMENT_TYPE_SWIMMING_FREESTYLE", + "swimming", + ), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_SWIMMING_MIXED, "EXERCISE_SEGMENT_TYPE_SWIMMING_MIXED", "swimming"), + segment( + ExerciseSegment.EXERCISE_SEGMENT_TYPE_SWIMMING_OPEN_WATER, + "EXERCISE_SEGMENT_TYPE_SWIMMING_OPEN_WATER", + "swimming", + ), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_SWIMMING_OTHER, "EXERCISE_SEGMENT_TYPE_SWIMMING_OTHER", "swimming"), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_SWIMMING_POOL, "EXERCISE_SEGMENT_TYPE_SWIMMING_POOL", "swimming"), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_UPPER_TWIST, "EXERCISE_SEGMENT_TYPE_UPPER_TWIST", "other"), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_WALKING, "EXERCISE_SEGMENT_TYPE_WALKING", "walking"), + segment( + ExerciseSegment.EXERCISE_SEGMENT_TYPE_WEIGHTLIFTING, + "EXERCISE_SEGMENT_TYPE_WEIGHTLIFTING", + "strength-training", + ), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_WHEELCHAIR, "EXERCISE_SEGMENT_TYPE_WHEELCHAIR", "other"), + segment(ExerciseSegment.EXERCISE_SEGMENT_TYPE_YOGA, "EXERCISE_SEGMENT_TYPE_YOGA", "yoga"), +) + +/** Absorbs the exact AndroidX exercise vocabularies into the shared Grove workout terminology. */ +internal object HealthConnectWorkoutVocabulary { + /** Health Connect states a lap structurally rather than as an enumerated token. */ + const val LAP_TOKEN = "EXERCISE_LAP" + const val LAP_CODE = "lap" + + /** Every token a workout-segment output identity may name, laps included. */ + val segmentIdentityTokens: Set = + EXERCISE_SEGMENT_TYPES.values.mapTo(mutableSetOf()) { it.sourceToken } + LAP_TOKEN + + fun activity(exerciseType: Int): SourceCodedValue = EXERCISE_TYPES[exerciseType] + ?.coded(SHARED_ACTIVITY_DISPLAYS) + ?: throw InvalidHealthConnectRecord("Unsupported Health Connect exercise type: $exerciseType") + + fun segment(segmentType: Int): WorkoutClassification = EXERCISE_SEGMENT_TYPES[segmentType] + ?.let { WorkoutClassification(it.sharedSystem(), it.coded(SHARED_SEGMENT_DISPLAYS + SHARED_ACTIVITY_DISPLAYS)) } + ?: throw InvalidHealthConnectRecord("Unsupported Health Connect exercise segment type: $segmentType") + + fun lap(): WorkoutClassification = WorkoutClassification( + HealthConnectContract.GROVE_WORKOUT_SEGMENT_TYPE, + SourceCodedValue( + LAP_CODE, + SHARED_SEGMENT_DISPLAYS.getValue(LAP_CODE), + LAP_TOKEN, + "Exercise lap", + ), + ) +} + +private fun WorkoutCoding.coded(displays: Map): SourceCodedValue = + SourceCodedValue(sharedCode, displays.getValue(sharedCode), sourceToken, sourceDisplay()) + +private fun WorkoutCoding.sharedSystem(): String = if (sharedCode in SHARED_SEGMENT_DISPLAYS) { + HealthConnectContract.GROVE_WORKOUT_SEGMENT_TYPE +} else { + HealthConnectContract.GROVE_WORKOUT_ACTIVITY +} + +private fun WorkoutCoding.sourceDisplay(): String = sourceToken + .substringAfter("TYPE_") + .lowercase() + .replace('_', ' ') + .replaceFirstChar(Char::uppercaseChar) diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/RoomHealthConnectExportDatabase.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/RoomHealthConnectExportDatabase.kt new file mode 100644 index 00000000..f3f641b3 --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/RoomHealthConnectExportDatabase.kt @@ -0,0 +1,265 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import androidx.room.Dao +import androidx.room.Database +import androidx.room.Entity +import androidx.room.Index +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.PrimaryKey +import androidx.room.Query +import androidx.room.RoomDatabase +import androidx.room.Upsert + +@Entity(tableName = "health_connect_export_counter") +internal data class RoomHealthConnectCounter( + @PrimaryKey val singletonId: Int = SINGLETON_ID, + val nextEventSequence: String, + val nextFence: String, +) { + companion object { + const val SINGLETON_ID = 1 + } +} + +@Entity( + tableName = "health_connect_export_entries", + primaryKeys = ["repositoryScopeKey", "recordType", "healthConnectId"], + indices = [Index(value = ["repositoryScopeKey", "recordType"])], +) +internal data class RoomHealthConnectEntry( + val repositoryScopeKey: String, + val recordType: String, + val healthConnectId: String, + val revision: String, + val encodedEntry: String, +) + +@Entity( + tableName = "health_connect_pending_exports", + primaryKeys = ["repositoryScopeKey", "recordType", "healthConnectId"], + indices = [ + Index(value = ["eventSequence"], unique = true), + Index(value = ["repositoryScopeKey", "recordType"]), + ], +) +internal data class RoomHealthConnectPendingExport( + val repositoryScopeKey: String, + val recordType: String, + val healthConnectId: String, + val eventSequence: String, + val encodedPending: String, +) + +@Entity( + tableName = "health_connect_source_leases", + primaryKeys = ["repositoryScopeKey", "recordType", "healthConnectId"], + indices = [Index(value = ["repositoryScopeKey", "recordType", "expiresAtEpochMillis"])], +) +internal data class RoomHealthConnectSourceLease( + val repositoryScopeKey: String, + val recordType: String, + val healthConnectId: String, + val owner: String, + val fence: String, + val reconciliationFence: String?, + val expiresAtEpochMillis: Long, +) + +@Entity( + tableName = "health_connect_reconciliation_leases", + primaryKeys = ["repositoryScopeKey", "recordType"], +) +internal data class RoomHealthConnectReconciliationLease( + val repositoryScopeKey: String, + val recordType: String, + val owner: String, + val fence: String, + val expiresAtEpochMillis: Long, +) + +@Entity( + tableName = "health_connect_unmatched_deletions", + primaryKeys = ["repositoryScopeKey", "recordType", "healthConnectId"], +) +internal data class RoomHealthConnectUnmatchedDeletion( + val repositoryScopeKey: String, + val projectionScopeKey: String, + val recordType: String, + val healthConnectId: String, + val observedAt: String, +) + +@Entity( + tableName = "health_connect_rejected_records", + primaryKeys = ["repositoryScopeKey", "recordType", "healthConnectId"], +) +internal data class RoomHealthConnectRejectedRecord( + val repositoryScopeKey: String, + val projectionScopeKey: String, + val recordType: String, + val healthConnectId: String, + val sourceLastModified: String, + val observedAt: String, + val reason: String, +) + +@Dao +@Suppress("TooManyFunctions") +internal interface RoomHealthConnectExportDao { + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun initializeCounter(counter: RoomHealthConnectCounter): Long + + @Query("SELECT * FROM health_connect_export_counter WHERE singletonId = 1") + suspend fun counter(): RoomHealthConnectCounter + + @Upsert + suspend fun upsertCounter(counter: RoomHealthConnectCounter) + + @Query( + "SELECT * FROM health_connect_export_entries " + + "WHERE repositoryScopeKey = :scope AND recordType = :recordType AND healthConnectId = :sourceId", + ) + suspend fun entry(scope: String, recordType: String, sourceId: String): RoomHealthConnectEntry? + + @Query( + "SELECT * FROM health_connect_export_entries " + + "WHERE repositoryScopeKey = :scope AND recordType = :recordType " + + "ORDER BY healthConnectId", + ) + suspend fun entries(scope: String, recordType: String): List + + @Upsert + suspend fun upsertEntry(entry: RoomHealthConnectEntry) + + @Query( + "SELECT * FROM health_connect_pending_exports " + + "WHERE repositoryScopeKey = :scope AND recordType = :recordType AND healthConnectId = :sourceId", + ) + suspend fun pending(scope: String, recordType: String, sourceId: String): RoomHealthConnectPendingExport? + + @Query( + "SELECT * FROM health_connect_pending_exports " + + "WHERE repositoryScopeKey = :scope AND recordType = :recordType " + + "ORDER BY LENGTH(eventSequence), eventSequence", + ) + suspend fun pendingForType(scope: String, recordType: String): List + + @Insert + suspend fun insertPending(pending: RoomHealthConnectPendingExport) + + @Query( + "DELETE FROM health_connect_pending_exports " + + "WHERE repositoryScopeKey = :scope AND recordType = :recordType AND healthConnectId = :sourceId", + ) + suspend fun deletePending(scope: String, recordType: String, sourceId: String): Int + + @Query( + "SELECT * FROM health_connect_source_leases " + + "WHERE repositoryScopeKey = :scope AND recordType = :recordType AND healthConnectId = :sourceId", + ) + suspend fun sourceLease(scope: String, recordType: String, sourceId: String): RoomHealthConnectSourceLease? + + @Upsert + suspend fun upsertSourceLease(lease: RoomHealthConnectSourceLease) + + @Query( + "UPDATE health_connect_source_leases SET expiresAtEpochMillis = :newExpiry " + + "WHERE repositoryScopeKey = :scope AND recordType = :recordType AND healthConnectId = :sourceId " + + "AND owner = :owner AND fence = :fence AND expiresAtEpochMillis > :now", + ) + @Suppress("LongParameterList") // Room binds each fenced compare-and-set column as one SQL parameter. + suspend fun renewSourceLease( + scope: String, + recordType: String, + sourceId: String, + owner: String, + fence: String, + now: Long, + newExpiry: Long, + ): Int + + @Query( + "DELETE FROM health_connect_source_leases " + + "WHERE repositoryScopeKey = :scope AND recordType = :recordType AND healthConnectId = :sourceId " + + "AND owner = :owner AND fence = :fence", + ) + suspend fun releaseSourceLease( + scope: String, + recordType: String, + sourceId: String, + owner: String, + fence: String, + ): Int + + @Query( + "SELECT COUNT(*) FROM health_connect_source_leases " + + "WHERE repositoryScopeKey = :scope AND recordType = :recordType AND expiresAtEpochMillis > :now", + ) + suspend fun activeSourceLeaseCount(scope: String, recordType: String, now: Long): Int + + @Query( + "SELECT * FROM health_connect_reconciliation_leases " + + "WHERE repositoryScopeKey = :scope AND recordType = :recordType", + ) + suspend fun reconciliationLease(scope: String, recordType: String): RoomHealthConnectReconciliationLease? + + @Upsert + suspend fun upsertReconciliationLease(lease: RoomHealthConnectReconciliationLease) + + @Query( + "UPDATE health_connect_reconciliation_leases SET expiresAtEpochMillis = :newExpiry " + + "WHERE repositoryScopeKey = :scope AND recordType = :recordType " + + "AND owner = :owner AND fence = :fence AND expiresAtEpochMillis > :now", + ) + suspend fun renewReconciliationLease( + scope: String, + recordType: String, + owner: String, + fence: String, + now: Long, + newExpiry: Long, + ): Int + + @Query( + "DELETE FROM health_connect_reconciliation_leases " + + "WHERE repositoryScopeKey = :scope AND recordType = :recordType " + + "AND owner = :owner AND fence = :fence", + ) + suspend fun releaseReconciliationLease( + scope: String, + recordType: String, + owner: String, + fence: String, + ): Int + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertUnmatchedDeletion(deletion: RoomHealthConnectUnmatchedDeletion): Long + + @Upsert + suspend fun upsertRejectedRecord(rejected: RoomHealthConnectRejectedRecord) +} + +@Database( + entities = [ + RoomHealthConnectCounter::class, + RoomHealthConnectEntry::class, + RoomHealthConnectPendingExport::class, + RoomHealthConnectSourceLease::class, + RoomHealthConnectReconciliationLease::class, + RoomHealthConnectUnmatchedDeletion::class, + RoomHealthConnectRejectedRecord::class, + ], + version = 1, + exportSchema = true, +) +internal abstract class RoomHealthConnectExportDatabase : RoomDatabase() { + abstract fun journalDao(): RoomHealthConnectExportDao +} diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/RoomHealthConnectExportJournal.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/RoomHealthConnectExportJournal.kt new file mode 100644 index 00000000..fb35dfd4 --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/RoomHealthConnectExportJournal.kt @@ -0,0 +1,642 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import android.content.Context +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.withTransaction +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.math.BigInteger +import java.time.Duration +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** Lease timing for [RoomHealthConnectExportJournal]. */ +data class RoomHealthConnectJournalOptions( + val leaseDuration: Duration = Duration.ofSeconds(DEFAULT_LEASE_SECONDS), + val unavailableLeaseRetryDelay: Duration = Duration.ofMillis(DEFAULT_RETRY_MILLIS), +) { + init { + require(!leaseDuration.isZero && !leaseDuration.isNegative) { + "The journal lease duration must be positive." + } + require(!unavailableLeaseRetryDelay.isZero && !unavailableLeaseRetryDelay.isNegative) { + "The unavailable-lease retry delay must be positive." + } + require(leaseDuration.toMillis() >= MINIMUM_LEASE_MILLIS) { + "The journal lease duration must be at least $MINIMUM_LEASE_MILLIS milliseconds." + } + } + + internal val leaseMillis: Long = leaseDuration.toMillis() + internal val retryMillis: Long = unavailableLeaseRetryDelay.toMillis().coerceAtLeast(1L) + internal val renewalMillis: Long = (leaseMillis / RENEWAL_DIVISOR).coerceAtLeast(1L) + + private companion object { + const val DEFAULT_LEASE_SECONDS = 30L + const val DEFAULT_RETRY_MILLIS = 50L + const val MINIMUM_LEASE_MILLIS = 30L + const val RENEWAL_DIVISOR = 3L + } +} + +/** A Room-backed, process-safe durable outbox and fenced Health Connect export journal. */ +@Suppress("TooManyFunctions") +class RoomHealthConnectExportJournal private constructor( + private val database: RoomHealthConnectExportDatabase, + private val options: RoomHealthConnectJournalOptions, + private val epochMillis: () -> Long, +) : HealthConnectExportJournal, AutoCloseable { + private val dao = database.journalDao() + private val sourceOwners = ConcurrentHashMap() + private val reconciliationOwners = ConcurrentHashMap() + + override suspend fun withSourceTransition( + repositoryScopeKey: ScopeKey, + recordType: String, + healthConnectId: String, + reconciliationLease: HealthConnectReconciliationLease?, + block: suspend (HealthConnectSourceTransitionLease) -> T, + ): T { + requireSourceCoordinates(recordType, healthConnectId) + reconciliationLease?.let { + require(it.repositoryScopeKey == repositoryScopeKey && it.recordType == recordType) { + "A parent reconciliation lease must cover the source transition's repository and Record type." + } + } + val ownership = awaitSourceLease( + repositoryScopeKey, + recordType, + healthConnectId, + reconciliationLease, + ) + sourceOwners[ownership.lease.fence.value] = ownership.owner + return runRenewableLease( + lease = ownership.lease, + renew = { renewSourceLease(ownership) }, + release = { releaseSourceLease(ownership) }, + block = block, + ) + } + + override suspend fun withReconciliationLease( + repositoryScopeKey: ScopeKey, + recordType: String, + block: suspend (HealthConnectReconciliationLease) -> T, + ): T { + requireRecordType(recordType) + val ownership = awaitReconciliationLease(repositoryScopeKey, recordType) + reconciliationOwners[ownership.lease.fence.value] = ownership.owner + return runRenewableLease( + lease = ownership.lease, + renew = { renewReconciliationLease(ownership) }, + release = { releaseReconciliationLease(ownership) }, + block = block, + ) + } + + override suspend fun entry(lease: HealthConnectSourceTransitionLease): HealthConnectExportJournalEntry? = + database.withTransaction { + requireSourceLeaseLocked(lease) + dao.entry(lease.repositoryScopeKey.value, lease.recordType, lease.healthConnectId) + ?.let { RoomHealthConnectJournalCodec.decodeEntry(it.encodedEntry) } + } + override suspend fun entries(lease: HealthConnectReconciliationLease): List = + database.withTransaction { + requireReconciliationLeaseLocked(lease) + dao.entries(lease.repositoryScopeKey.value, lease.recordType).map { + RoomHealthConnectJournalCodec.decodeEntry(it.encodedEntry) + } + } + + override suspend fun pending(lease: HealthConnectSourceTransitionLease): HealthConnectPendingExport? = + database.withTransaction { + requireSourceLeaseLocked(lease) + dao.pending(lease.repositoryScopeKey.value, lease.recordType, lease.healthConnectId) + ?.let { RoomHealthConnectJournalCodec.decodePending(it.encodedPending) } + } + + override suspend fun pendingForType( + lease: HealthConnectReconciliationLease, + ): List = database.withTransaction { + requireReconciliationLeaseLocked(lease) + dao.pendingForType(lease.repositoryScopeKey.value, lease.recordType).map { + RoomHealthConnectJournalCodec.decodePending(it.encodedPending) + } + } + + override suspend fun stage( + lease: HealthConnectSourceTransitionLease, + expectedRevision: HealthConnectJournalRevision?, + buildDraft: (eventSequence: EventSequence) -> HealthConnectPendingExportDraft, + ): HealthConnectPendingExport = database.withTransaction { + requireSourceLeaseLocked(lease) + dao.pending(lease.repositoryScopeKey.value, lease.recordType, lease.healthConnectId)?.let { + return@withTransaction RoomHealthConnectJournalCodec.decodePending(it.encodedPending) + } + check(currentRevision(lease) == expectedRevision) { + "The journal base revision changed before staging." + } + initializeCounterLocked() + val counter = dao.counter() + val eventSequence = EventSequence(counter.nextEventSequence) + val draft = buildDraft(eventSequence) + requireDraftMatchesLease(draft, lease) + val pending = draft.toPending(eventSequence, expectedRevision) + dao.insertPending(pending.toRoomEntity()) + dao.upsertCounter( + counter.copy(nextEventSequence = counter.nextEventSequence.incrementDecimal()), + ) + pending + } + + override suspend fun complete( + lease: HealthConnectSourceTransitionLease, + pending: HealthConnectPendingExport, + entry: HealthConnectExportJournalEntry, + ) = database.withTransaction { + requireSourceLeaseLocked(lease) + requireSourceMatchesLease(pending.repositoryScopeKey, pending.recordType, pending.healthConnectId, lease) + requireSourceMatchesLease(entry.repositoryScopeKey, entry.recordType, entry.healthConnectId, lease) + val expectedEntry = pending.acknowledgedEntry(entry.destinationReferences) + check(expectedEntry.revision == entry.revision) { + "Completion must be derived from the exact validated pending payload." + } + val storedPendingRow = dao.pending( + lease.repositoryScopeKey.value, + lease.recordType, + lease.healthConnectId, + ) + if (storedPendingRow == null) { + check(currentRevision(lease) == entry.revision) { + "Only an exact-event, exact-revision repeated completion is idempotent." + } + return@withTransaction + } + val storedPending = RoomHealthConnectJournalCodec.decodePending(storedPendingRow.encodedPending) + check(storedPending.sameExactEvent(pending)) { + "Completion did not name the exact staged event." + } + check(currentRevision(lease) == storedPending.baseRevision) { + "The journal base revision changed before completion." + } + dao.upsertEntry(entry.toRoomEntity()) + check( + dao.deletePending(lease.repositoryScopeKey.value, lease.recordType, lease.healthConnectId) == 1, + ) { "The exact pending event disappeared before completion." } + } + + override suspend fun storeLocal( + lease: HealthConnectSourceTransitionLease, + expectedRevision: HealthConnectJournalRevision?, + entry: HealthConnectExportJournalEntry, + ) = database.withTransaction { + requireSourceLeaseLocked(lease) + requireSourceMatchesLease(entry.repositoryScopeKey, entry.recordType, entry.healthConnectId, lease) + check(entry.outputIdentifiers.isEmpty()) { + "A local-only transition cannot bypass delivery of active or retraction outputs." + } + check(dao.pending(lease.repositoryScopeKey.value, lease.recordType, lease.healthConnectId) == null) { + "A local-only transition cannot replace a staged outbox event." + } + check(currentRevision(lease) == expectedRevision) { + "The journal base revision changed before local storage." + } + dao.upsertEntry(entry.toRoomEntity()) + } + + override suspend fun recordUnmatchedDeletion( + lease: HealthConnectSourceTransitionLease, + deletion: HealthConnectUnmatchedDeletion, + ) = database.withTransaction { + requireSourceLeaseLocked(lease) + requireSourceMatchesLease( + deletion.repositoryScopeKey, + deletion.recordType, + deletion.healthConnectId, + lease, + ) + dao.insertUnmatchedDeletion( + RoomHealthConnectUnmatchedDeletion( + repositoryScopeKey = deletion.repositoryScopeKey.value, + projectionScopeKey = deletion.projectionScopeKey.value, + recordType = deletion.recordType, + healthConnectId = deletion.healthConnectId, + observedAt = deletion.observedAt.toString(), + ), + ) + Unit + } + + override suspend fun recordRejectedRecord( + lease: HealthConnectSourceTransitionLease, + rejected: HealthConnectRejectedRecord, + ) = database.withTransaction { + requireSourceLeaseLocked(lease) + requireSourceMatchesLease( + rejected.repositoryScopeKey, + rejected.recordType, + rejected.healthConnectId, + lease, + ) + dao.upsertRejectedRecord( + RoomHealthConnectRejectedRecord( + repositoryScopeKey = rejected.repositoryScopeKey.value, + projectionScopeKey = rejected.projectionScopeKey.value, + recordType = rejected.recordType, + healthConnectId = rejected.healthConnectId, + sourceLastModified = rejected.sourceLastModified.toString(), + observedAt = rejected.observedAt.toString(), + reason = rejected.reason, + ), + ) + } + + override fun close() = database.close() + + private suspend fun awaitSourceLease( + scope: ScopeKey, + recordType: String, + sourceId: String, + reconciliationLease: HealthConnectReconciliationLease?, + ): SourceLeaseOwnership { + val owner = UUID.randomUUID().toString() + while (true) { + tryAcquireSourceLease(scope, recordType, sourceId, reconciliationLease, owner)?.let { return it } + delay(options.retryMillis) + } + } + + private suspend fun tryAcquireSourceLease( + scope: ScopeKey, + recordType: String, + sourceId: String, + reconciliationLease: HealthConnectReconciliationLease?, + owner: String, + ): SourceLeaseOwnership? = database.withTransaction { + val now = epochMillis() + val reconciliation = dao.reconciliationLease(scope.value, recordType) + val parentFence = reconciliationLease?.fence?.value + if (reconciliationLease == null) { + if (reconciliation != null && reconciliation.expiresAtEpochMillis > now) return@withTransaction null + } else { + val currentParentFence = reconciliationLease.fence.value + val parentOwner = reconciliationOwners[currentParentFence] ?: return@withTransaction null + if (!reconciliation.isCurrentOwner(currentParentFence, parentOwner, now)) { + throw HealthConnectJournalLeaseLostException("The parent reconciliation lease is no longer owned.") + } + } + val current = dao.sourceLease(scope.value, recordType, sourceId) + if (current != null && current.expiresAtEpochMillis > now) return@withTransaction null + val fence = allocateFenceLocked() + dao.upsertSourceLease( + RoomHealthConnectSourceLease( + repositoryScopeKey = scope.value, + recordType = recordType, + healthConnectId = sourceId, + owner = owner, + fence = fence.value, + reconciliationFence = parentFence, + expiresAtEpochMillis = leaseExpiry(now), + ), + ) + SourceLeaseOwnership( + HealthConnectSourceTransitionLease(scope, recordType, sourceId, fence, reconciliationLease?.fence), + owner, + ) + } + + private suspend fun awaitReconciliationLease( + scope: ScopeKey, + recordType: String, + ): ReconciliationLeaseOwnership { + val owner = UUID.randomUUID().toString() + while (true) { + tryAcquireReconciliationLease(scope, recordType, owner)?.let { return it } + delay(options.retryMillis) + } + } + + private suspend fun tryAcquireReconciliationLease( + scope: ScopeKey, + recordType: String, + owner: String, + ): ReconciliationLeaseOwnership? = database.withTransaction { + val now = epochMillis() + val current = dao.reconciliationLease(scope.value, recordType) + if (current != null && current.expiresAtEpochMillis > now) return@withTransaction null + if (dao.activeSourceLeaseCount(scope.value, recordType, now) != 0) return@withTransaction null + val fence = allocateFenceLocked() + dao.upsertReconciliationLease( + RoomHealthConnectReconciliationLease( + repositoryScopeKey = scope.value, + recordType = recordType, + owner = owner, + fence = fence.value, + expiresAtEpochMillis = leaseExpiry(now), + ), + ) + ReconciliationLeaseOwnership( + HealthConnectReconciliationLease(scope, recordType, fence), + owner, + ) + } + + private suspend fun renewSourceLease(ownership: SourceLeaseOwnership): Boolean = database.withTransaction { + val lease = ownership.lease + val now = epochMillis() + lease.reconciliationFence?.let { requireReconciliationLeaseLocked( + HealthConnectReconciliationLease(lease.repositoryScopeKey, lease.recordType, it), + ) } + dao.renewSourceLease( + lease.repositoryScopeKey.value, + lease.recordType, + lease.healthConnectId, + ownership.owner, + lease.fence.value, + now, + leaseExpiry(now), + ) == 1 + } + + private suspend fun renewReconciliationLease(ownership: ReconciliationLeaseOwnership): Boolean { + val lease = ownership.lease + val now = epochMillis() + return dao.renewReconciliationLease( + lease.repositoryScopeKey.value, + lease.recordType, + ownership.owner, + lease.fence.value, + now, + leaseExpiry(now), + ) == 1 + } + + private suspend fun releaseSourceLease(ownership: SourceLeaseOwnership) { + val lease = ownership.lease + try { + dao.releaseSourceLease( + lease.repositoryScopeKey.value, + lease.recordType, + lease.healthConnectId, + ownership.owner, + lease.fence.value, + ) + } finally { + sourceOwners.remove(lease.fence.value, ownership.owner) + } + } + + private suspend fun releaseReconciliationLease(ownership: ReconciliationLeaseOwnership) { + val lease = ownership.lease + try { + dao.releaseReconciliationLease( + lease.repositoryScopeKey.value, + lease.recordType, + ownership.owner, + lease.fence.value, + ) + } finally { + reconciliationOwners.remove(lease.fence.value, ownership.owner) + } + } + + private suspend fun requireSourceLeaseLocked(lease: HealthConnectSourceTransitionLease) { + val owner = sourceOwners[lease.fence.value] + ?: throw HealthConnectJournalLeaseLostException("The source-transition lease is not owned by this journal.") + val stored = dao.sourceLease(lease.repositoryScopeKey.value, lease.recordType, lease.healthConnectId) + val now = epochMillis() + if (!stored.isCurrentOwner(lease, owner, now)) { + throw HealthConnectJournalLeaseLostException("The source-transition fence is stale or expired.") + } + lease.reconciliationFence?.let { fence -> + requireReconciliationLeaseLocked( + HealthConnectReconciliationLease(lease.repositoryScopeKey, lease.recordType, fence), + ) + } + } + + private suspend fun requireReconciliationLeaseLocked(lease: HealthConnectReconciliationLease) { + val owner = reconciliationOwners[lease.fence.value] + ?: throw HealthConnectJournalLeaseLostException("The reconciliation lease is not owned by this journal.") + val stored = dao.reconciliationLease(lease.repositoryScopeKey.value, lease.recordType) + if (!stored.isCurrentOwner(lease, owner, epochMillis())) { + throw HealthConnectJournalLeaseLostException("The reconciliation fence is stale or expired.") + } + } + + private suspend fun initializeCounterLocked() { + dao.initializeCounter( + RoomHealthConnectCounter(nextEventSequence = FIRST_COUNTER_VALUE, nextFence = FIRST_COUNTER_VALUE), + ) + } + + private suspend fun allocateFenceLocked(): HealthConnectJournalFence { + initializeCounterLocked() + val counter = dao.counter() + val fence = HealthConnectJournalFence(counter.nextFence) + dao.upsertCounter(counter.copy(nextFence = counter.nextFence.incrementDecimal())) + return fence + } + + private suspend fun currentRevision(lease: HealthConnectSourceTransitionLease): HealthConnectJournalRevision? = + dao.entry(lease.repositoryScopeKey.value, lease.recordType, lease.healthConnectId) + ?.revision + ?.let(::HealthConnectJournalRevision) + + private fun leaseExpiry(now: Long): Long = + runCatching { Math.addExact(now, options.leaseMillis) }.getOrElse { Long.MAX_VALUE } + + private suspend fun runRenewableLease( + lease: L, + renew: suspend () -> Boolean, + release: suspend () -> Unit, + block: suspend (L) -> T, + ): T = coroutineScope { + val heartbeat = launch { + while (true) { + delay(options.renewalMillis) + if (!renew()) { + throw HealthConnectJournalLeaseLostException("The Room journal lease could not be renewed.") + } + } + } + try { + block(lease) + } finally { + heartbeat.cancelAndJoin() + withContext(NonCancellable) { runCatching { release() } } + } + } + + private fun HealthConnectPendingExportDraft.toPending( + sequence: EventSequence, + revision: HealthConnectJournalRevision?, + ): HealthConnectPendingExport = HealthConnectPendingExport( + eventSequence = sequence, + baseRevision = revision, + repositoryScopeKey = repositoryScopeKey, + projectionScopeKey = projectionScopeKey, + operation = operation, + recordType = recordType, + healthConnectId = healthConnectId, + sourceRecordIdentifier = sourceRecordIdentifier, + sourceVersion = sourceVersion, + bundle = bundle, + bundleJson = bundleJson, + payloadSha256 = payloadSha256, + retractedTargets = retractedTargets, + nextEntry = nextEntry, + ) + + private fun HealthConnectPendingExport.toRoomEntity() = RoomHealthConnectPendingExport( + repositoryScopeKey.value, + recordType, + healthConnectId, + eventSequence.value, + RoomHealthConnectJournalCodec.encodePending(this), + ) + + private fun HealthConnectExportJournalEntry.toRoomEntity() = RoomHealthConnectEntry( + repositoryScopeKey.value, + recordType, + healthConnectId, + revision.value, + RoomHealthConnectJournalCodec.encodeEntry(this), + ) + + private data class SourceLeaseOwnership( + val lease: HealthConnectSourceTransitionLease, + val owner: String, + ) + + private data class ReconciliationLeaseOwnership( + val lease: HealthConnectReconciliationLease, + val owner: String, + ) + + companion object { + const val DEFAULT_DATABASE_NAME = "grove_health_connect_export_journal.db" + + /** Opens the production journal without destructive migration fallback. */ + fun open( + context: Context, + databaseName: String = DEFAULT_DATABASE_NAME, + options: RoomHealthConnectJournalOptions = RoomHealthConnectJournalOptions(), + ): RoomHealthConnectExportJournal { + require(databaseName.isNotBlank()) { "The Room journal database name must not be blank." } + val database = Room.databaseBuilder( + context.applicationContext, + RoomHealthConnectExportDatabase::class.java, + databaseName, + ).setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING).build() + return RoomHealthConnectExportJournal(database, options, System::currentTimeMillis) + } + + internal fun createForTest( + database: RoomHealthConnectExportDatabase, + options: RoomHealthConnectJournalOptions, + epochMillis: () -> Long = System::currentTimeMillis, + ): RoomHealthConnectExportJournal = RoomHealthConnectExportJournal(database, options, epochMillis) + + private const val FIRST_COUNTER_VALUE = "1" + } +} + +/** Raised when a process attempts to use a stale or expired journal fencing token. */ +class HealthConnectJournalLeaseLostException(message: String) : IllegalStateException(message) + +private fun String.incrementDecimal(): String = (BigInteger(this) + BigInteger.ONE).toString() + +private fun requireRecordType(recordType: String) { + require(recordType.isNotBlank()) { "A journal lease must identify one source Record type." } + GroveUnicode.requireScalarText(recordType, "Journal Record type") +} + +private fun requireSourceCoordinates(recordType: String, sourceId: String) { + requireRecordType(recordType) + require(sourceId.isNotBlank()) { "A journal lease must identify one source Record id." } + GroveUnicode.requireScalarText(sourceId, "Journal source Record id") +} + +private fun requireDraftMatchesLease( + draft: HealthConnectPendingExportDraft, + lease: HealthConnectSourceTransitionLease, +) = requireSourceMatchesLease(draft.repositoryScopeKey, draft.recordType, draft.healthConnectId, lease) + +private fun requireSourceMatchesLease( + scope: ScopeKey, + recordType: String, + sourceId: String, + lease: HealthConnectSourceTransitionLease, +) { + require( + scope == lease.repositoryScopeKey && recordType == lease.recordType && sourceId == lease.healthConnectId, + ) { "Journal state must identify the exact source-transition lease." } +} + +private fun HealthConnectPendingExport.sameExactEvent(other: HealthConnectPendingExport): Boolean = + eventSequence == other.eventSequence && + baseRevision == other.baseRevision && + repositoryScopeKey == other.repositoryScopeKey && + projectionScopeKey == other.projectionScopeKey && + operation == other.operation && + recordType == other.recordType && + healthConnectId == other.healthConnectId && + sourceVersion == other.sourceVersion && + sourceRecordIdentifier.equalsDeep(other.sourceRecordIdentifier) && + bundleJson == other.bundleJson && + payloadSha256 == other.payloadSha256 && + retractedTargets == other.retractedTargets && + nextEntry.revision == other.nextEntry.revision + +private fun RoomHealthConnectReconciliationLease?.isCurrentOwner( + expectedFence: String, + expectedOwner: String, + now: Long, +): Boolean { + if (this == null) return false + return listOf( + fence == expectedFence, + owner == expectedOwner, + expiresAtEpochMillis > now, + ).all { it } +} + +private fun RoomHealthConnectSourceLease?.isCurrentOwner( + lease: HealthConnectSourceTransitionLease, + expectedOwner: String, + now: Long, +): Boolean { + if (this == null) return false + return listOf( + owner == expectedOwner, + fence == lease.fence.value, + reconciliationFence == lease.reconciliationFence?.value, + expiresAtEpochMillis > now, + ).all { it } +} + +private fun RoomHealthConnectReconciliationLease?.isCurrentOwner( + lease: HealthConnectReconciliationLease, + expectedOwner: String, + now: Long, +): Boolean { + if (this == null) return false + return listOf( + owner == expectedOwner, + fence == lease.fence.value, + expiresAtEpochMillis > now, + ).all { it } +} diff --git a/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/RoomHealthConnectJournalCodec.kt b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/RoomHealthConnectJournalCodec.kt new file mode 100644 index 00000000..4896f4ca --- /dev/null +++ b/health-fhir/src/main/kotlin/org/grovealliance/health/fhir/RoomHealthConnectJournalCodec.kt @@ -0,0 +1,194 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.hl7.fhir.r4.formats.IParser +import org.hl7.fhir.r4.formats.JsonParser +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.Identifier +import org.hl7.fhir.r4.model.Observation +import org.hl7.fhir.r4.model.Parameters +import java.time.Instant + +/** Lossless storage codec for Room rows; the pending Bundle JSON remains the authoritative wire bytes. */ +internal object RoomHealthConnectJournalCodec { + private val json = Json { + ignoreUnknownKeys = false + isLenient = false + } + + fun encodeEntry(entry: HealthConnectExportJournalEntry): String = entryJson(entry).toString() + + fun decodeEntry(encoded: String): HealthConnectExportJournalEntry = decodeEntry( + json.parseToJsonElement(encoded).jsonObject, + ) + + fun encodePending(pending: HealthConnectPendingExport): String = buildJsonObject { + put("eventSequence", JsonPrimitive(pending.eventSequence.value)) + put("baseRevision", pending.baseRevision?.value?.let(::JsonPrimitive) ?: JsonNull) + put("repositoryScopeKey", JsonPrimitive(pending.repositoryScopeKey.value)) + put("projectionScopeKey", JsonPrimitive(pending.projectionScopeKey.value)) + put("operation", JsonPrimitive(pending.operation.wireValue)) + put("recordType", JsonPrimitive(pending.recordType)) + put("healthConnectId", JsonPrimitive(pending.healthConnectId)) + put("sourceRecordIdentifier", JsonPrimitive(identifierJson(pending.sourceRecordIdentifier))) + put("sourceVersion", JsonPrimitive(pending.sourceVersion.toString())) + put("bundleJson", JsonPrimitive(pending.bundleJson)) + put("payloadSha256", JsonPrimitive(pending.payloadSha256)) + put("retractedTargets", targetJson(pending.retractedTargets)) + put("nextEntry", entryJson(pending.nextEntry)) + }.toString() + + fun decodePending(encoded: String): HealthConnectPendingExport { + val value = json.parseToJsonElement(encoded).jsonObject + val bundleJson = value.string("bundleJson") + return HealthConnectPendingExport( + eventSequence = EventSequence(value.string("eventSequence")), + baseRevision = value.nullableString("baseRevision")?.let(::HealthConnectJournalRevision), + repositoryScopeKey = ScopeKey(value.string("repositoryScopeKey")), + projectionScopeKey = ScopeKey(value.string("projectionScopeKey")), + operation = HealthConnectExportOperation.entries.single { + it.wireValue == value.string("operation") + }, + recordType = value.string("recordType"), + healthConnectId = value.string("healthConnectId"), + sourceRecordIdentifier = parseIdentifier(value.string("sourceRecordIdentifier")), + sourceVersion = Instant.parse(value.string("sourceVersion")), + bundle = parseBundle(bundleJson), + bundleJson = bundleJson, + payloadSha256 = value.string("payloadSha256"), + retractedTargets = parseTargets(value.array("retractedTargets")), + nextEntry = decodeEntry(value.objectValue("nextEntry")), + ) + } + + private fun entryJson(entry: HealthConnectExportJournalEntry): JsonObject = buildJsonObject { + put("repositoryScopeKey", JsonPrimitive(entry.repositoryScopeKey.value)) + put("projectionScopeKey", JsonPrimitive(entry.projectionScopeKey.value)) + put("recordType", JsonPrimitive(entry.recordType)) + put("healthConnectId", JsonPrimitive(entry.healthConnectId)) + put("dataOriginPackage", JsonPrimitive(entry.dataOriginPackage)) + put("sourceLastModified", JsonPrimitive(entry.sourceLastModified.toString())) + put("conversionContractVersion", JsonPrimitive(entry.conversionContractVersion)) + put("sourceRecordIdentifier", JsonPrimitive(identifierJson(entry.sourceRecordIdentifier))) + put("bundleJson", JsonPrimitive(HealthConnectWireFormat.bundleJson(entry.bundle))) + put("destinationReferences", destinationReferencesJson(entry.destinationReferences)) + put("lastEventSequence", entry.lastEventSequence?.value?.let(::JsonPrimitive) ?: JsonNull) + put("state", JsonPrimitive(entry.state.name)) + put("invalidatedAt", entry.invalidatedAt?.toString()?.let(::JsonPrimitive) ?: JsonNull) + } + + private fun decodeEntry(value: JsonObject): HealthConnectExportJournalEntry { + val bundle = parseBundle(value.string("bundleJson")) + return HealthConnectExportJournalEntry( + repositoryScopeKey = ScopeKey(value.string("repositoryScopeKey")), + projectionScopeKey = ScopeKey(value.string("projectionScopeKey")), + recordType = value.string("recordType"), + healthConnectId = value.string("healthConnectId"), + dataOriginPackage = value.string("dataOriginPackage"), + sourceLastModified = Instant.parse(value.string("sourceLastModified")), + conversionContractVersion = value.string("conversionContractVersion"), + sourceRecordIdentifier = parseIdentifier(value.string("sourceRecordIdentifier")), + observations = bundle.entry.mapNotNull { it.resource as? Observation }, + bundle = bundle, + destinationReferences = parseDestinationReferences(value.array("destinationReferences")), + lastEventSequence = value.nullableString("lastEventSequence")?.let(::EventSequence), + state = HealthConnectExportState.valueOf(value.string("state")), + invalidatedAt = value.nullableString("invalidatedAt")?.let(Instant::parse), + ) + } + + private fun destinationReferencesJson( + references: Map, + ): JsonArray = buildJsonArray { + references.toSortedMap().forEach { (identifier, reference) -> + add(buildJsonObject { + put("system", JsonPrimitive(identifier.system)) + put("value", JsonPrimitive(identifier.value)) + put("reference", JsonPrimitive(reference)) + }) + } + } + + private fun parseDestinationReferences(value: JsonArray): Map = + value.associate { element -> + val item = element.jsonObject + FhirIdentifierKey(item.string("system"), item.string("value")) to item.string("reference") + } + + private fun targetJson(targets: Set): JsonArray = buildJsonArray { + targets.sortedWith( + compareBy( + { it.identifier.system }, + { it.identifier.value }, + { it.resourceType }, + { it.role.code }, + ), + ).forEach { target -> + add(buildJsonObject { + put("system", JsonPrimitive(target.identifier.system)) + put("value", JsonPrimitive(target.identifier.value)) + put("identifierRole", JsonPrimitive(target.identifierRole.code)) + put("resourceType", JsonPrimitive(target.resourceType)) + put("role", JsonPrimitive(target.role.code)) + }) + } + } + + private fun parseTargets(value: JsonArray): Set = value.map { element -> + val item = element.jsonObject + HealthConnectRetractionTarget( + identifier = FhirIdentifierKey(item.string("system"), item.string("value")), + identifierRole = GroveIdentifierRole.entries.single { it.code == item.string("identifierRole") }, + resourceType = item.string("resourceType"), + role = HealthConnectRetractionTargetRole.entries.single { it.code == item.string("role") }, + ) + }.toSet() +} + +private fun identifierJson(identifier: Identifier): String = + JsonParser().setOutputStyle(IParser.OutputStyle.NORMAL).composeString( + Parameters().apply { + addParameter().apply { + name = "identifier" + value = identifier.copy() + } + }, + ) + +private fun parseIdentifier(encoded: String): Identifier { + val parameters = JsonParser().parse(encoded) as? Parameters + ?: error("Stored source Identifier wrapper is not a Parameters resource.") + return parameters.parameter.singleOrNull { it.name == "identifier" }?.value as? Identifier + ?: error("Stored source Identifier wrapper does not contain exactly one Identifier.") +} + +private fun parseBundle(encoded: String): Bundle = JsonParser().parse(encoded) as? Bundle + ?: error("Stored journal payload is not a Bundle.") + +private fun JsonObject.string(name: String): String = + get(name)?.takeUnless { it is JsonNull }?.jsonPrimitive?.content + ?: error("Stored journal payload is missing $name.") + +private fun JsonObject.nullableString(name: String): String? = + get(name)?.takeUnless { it is JsonNull }?.jsonPrimitive?.content + +private fun JsonObject.array(name: String): JsonArray = get(name) as? JsonArray + ?: error("Stored journal payload is missing array $name.") + +private fun JsonObject.objectValue(name: String): JsonObject = get(name) as? JsonObject + ?: error("Stored journal payload is missing object $name.") diff --git a/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/GroveExchangeIdentityTest.kt b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/GroveExchangeIdentityTest.kt new file mode 100644 index 00000000..83d206ea --- /dev/null +++ b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/GroveExchangeIdentityTest.kt @@ -0,0 +1,64 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import com.google.common.truth.Truth.assertThat +import org.hl7.fhir.r4.model.Identifier +import org.junit.Assert.assertThrows +import org.junit.Test + +class GroveExchangeIdentityTest { + @Test + fun `fullUrl is deterministic and its entry extension retains the exact identifier`() { + val identifier = Identifier() + .setSystem("https://xn--fsq.example/%E8%AD%98%E5%88%A5%E5%AD%90") + .setValue("café|東京") + + assertThat(GroveExchangeIdentity.fullUrl(identifier)) + .isEqualTo("urn:uuid:d35e4203-71f6-595c-bd1b-306b8414974e") + assertThat(GroveExchangeIdentity.fullUrl(identifier)).isEqualTo(GroveExchangeIdentity.fullUrl(identifier)) + assertThat( + (GroveExchangeIdentity.entryIdentifierExtension(identifier).value as Identifier).equalsDeep(identifier), + ).isTrue() + } + + @Test + fun `framing keeps separators and valid supplementary scalars unambiguous`() { + val withSeparator = Identifier().setSystem("https://example.org/system").setValue("a|b") + val withEmoji = Identifier().setSystem("https://example.org/system").setValue("a😀b") + + assertThat(GroveExchangeIdentity.fullUrl(withSeparator)) + .isNotEqualTo(GroveExchangeIdentity.fullUrl(withEmoji)) + } + + @Test + fun `rejects incomplete identifiers and isolated surrogates`() { + assertThrows(IllegalArgumentException::class.java) { + GroveExchangeIdentity.fullUrl(Identifier().setSystem("https://example.org/system")) + } + listOf("\ud800", "\udc00", "prefix\ud800suffix").forEach { invalid -> + assertThrows(IllegalArgumentException::class.java) { + GroveExchangeIdentity.identifierName("https://example.org/system", invalid) + } + } + assertThrows(IllegalArgumentException::class.java) { + GroveExchangeIdentity.fullUrl(Identifier().setSystem("not-absolute").setValue("value")) + } + listOf( + "https://例.example/識別子", + "https://example.org/has space", + "https://example.org/%ZZ", + ).forEach { invalidSystem -> + assertThrows(IllegalArgumentException::class.java) { + GroveExchangeIdentity.fullUrl( + Identifier().setSystem(invalidSystem).setValue("value"), + ) + } + } + } +} diff --git a/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/GroveExchangeProtocolCatalogTest.kt b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/GroveExchangeProtocolCatalogTest.kt new file mode 100644 index 00000000..19170a52 --- /dev/null +++ b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/GroveExchangeProtocolCatalogTest.kt @@ -0,0 +1,281 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import com.google.common.truth.Truth.assertThat +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.hl7.fhir.r4.model.Identifier +import org.junit.Test +import java.io.File +import java.time.Instant + +/** Executes the exact byte-level vectors published by catalog/exchange-protocol.json. */ +class GroveExchangeProtocolCatalogTest { + @Test + fun `vendored vectors exactly match the configured normative catalog`() { + val externalPath = System.getProperty(EXTERNAL_CATALOG_PROPERTY)?.takeIf(String::isNotBlank) ?: return + val external = Json.parseToJsonElement(File(externalPath).readText()).jsonObject + .getValue("testVectors") + + assertThat(external).isEqualTo(vectors) + } + + @Test + fun `closed HMAC kinds and arities exactly match the configured normative catalog`() { + val externalPath = System.getProperty(EXTERNAL_CATALOG_PROPERTY)?.takeIf(String::isNotBlank) ?: return + val catalogKinds = Json.parseToJsonElement(File(externalPath).readText()).jsonObject + .getValue("opaqueIdentity").jsonObject + .getValue("identityKinds").jsonArray + .associate { element -> + val kind = element.jsonObject + kind.string("kind") to Pair( + kind.string("identifierRole"), + kind.array("components").size, + ) + } + val implementationKinds = GroveOpaqueIdentityKind.entries.associate { kind -> + kind.code to (kind.identifierRole.code to kind.componentCount) + } + + assertThat(implementationKinds).isEqualTo(catalogKinds) + } + + @Test + fun `matches every normative opaque identity vector`() { + val key = GroveHmacIdentityKey.forConformanceTesting( + TEST_OPAQUE_IDENTITY_SYSTEM_FAMILY, + vectors.string("keyId"), + vectors.string("epoch"), + vectors.string("keyHex").hexBytes(), + ) + + val identities = vectors.array("identities") + assertThat(identities.map { it.jsonObject.string("identityKind") }.toSet()).isEqualTo( + GroveOpaqueIdentityKind.entries.map(GroveOpaqueIdentityKind::code).toSet(), + ) + assertThat(identities).hasSize(GroveOpaqueIdentityKind.entries.size) + identities.forEach { element -> + val vector = element.jsonObject + val kind = GroveOpaqueIdentityKind.entries.single { + it.code == vector.string("identityKind") + } + val components = vector.array("components").map { it.jsonPrimitive.content } + assertThat(key.value(kind, components)).isEqualTo(vector.string("value")) + } + } + + @Test + fun `framing counts UTF-8 bytes and rejects ambiguous or malformed Unicode`() { + assertThat(GroveExchangeProtocol.frameFields(listOf(""))).isEqualTo( + byteArrayOf(0, 0, 0, 0), + ) + assertThat(GroveExchangeProtocol.frameFields(listOf("😀"))).isEqualTo( + byteArrayOf(0, 0, 0, 4, 0xf0.toByte(), 0x9f.toByte(), 0x98.toByte(), 0x80.toByte()), + ) + assertThat(GroveExchangeProtocol.frameFields(listOf("a", "bc"))) + .isNotEqualTo(GroveExchangeProtocol.frameFields(listOf("ab", "c"))) + org.junit.Assert.assertThrows(IllegalArgumentException::class.java) { + GroveExchangeProtocol.frameFields(listOf("prefix\ud800suffix")) + } + } + + @Test + fun `every closed HMAC kind rejects both missing and excess components`() { + val key = GroveHmacIdentityKey.forConformanceTesting( + TEST_OPAQUE_IDENTITY_SYSTEM_FAMILY, + vectors.string("keyId"), + vectors.string("epoch"), + vectors.string("keyHex").hexBytes(), + ) + + GroveOpaqueIdentityKind.entries.forEach { kind -> + val exact = validComponents(kind) + assertThat(key.value(kind, exact)).matches("v2:test-key:1:[A-Za-z0-9_-]{43}") + org.junit.Assert.assertThrows(IllegalArgumentException::class.java) { + key.value(kind, exact.dropLast(1)) + } + org.junit.Assert.assertThrows(IllegalArgumentException::class.java) { + key.value(kind, exact + "excess") + } + org.junit.Assert.assertThrows(IllegalArgumentException::class.java) { + key.value(kind, exact.toMutableList().apply { this[0] = "" }) + } + org.junit.Assert.assertThrows(IllegalArgumentException::class.java) { + key.value(kind, exact.toMutableList().apply { this[0] = "prefix\ud800suffix" }) + } + } + } + + @Test + fun `provider coordinates require exact provider-specific identity domains`() { + val key = GroveHmacIdentityKey.forConformanceTesting( + TEST_OPAQUE_IDENTITY_SYSTEM_FAMILY, + vectors.string("keyId"), + vectors.string("epoch"), + vectors.string("keyHex").hexBytes(), + ) + val domainPairs = listOf( + GroveOpaqueIdentityKind.SOURCE_RECORD to GroveOpaqueIdentityKind.PROVIDER_RECORD, + GroveOpaqueIdentityKind.SOURCE_OUTPUT to GroveOpaqueIdentityKind.PROVIDER_OUTPUT, + GroveOpaqueIdentityKind.SOURCE_ARTIFACT to GroveOpaqueIdentityKind.PROVIDER_ARTIFACT, + ) + + domainPairs.forEach { (genericKind, providerKind) -> + val providerComponents = validComponents(providerKind) + assertThat(key.value(providerKind, providerComponents)) + .matches("v2:test-key:1:[A-Za-z0-9_-]{43}") + org.junit.Assert.assertThrows(IllegalArgumentException::class.java) { + key.value(genericKind, providerComponents) + } + + val genericComponents = validComponents(genericKind) + assertThat(key.value(genericKind, genericComponents)) + .matches("v2:test-key:1:[A-Za-z0-9_-]{43}") + org.junit.Assert.assertThrows(IllegalArgumentException::class.java) { + key.value(providerKind, genericComponents) + } + org.junit.Assert.assertThrows(IllegalArgumentException::class.java) { + key.value( + providerKind, + providerComponents.toMutableList().apply { this[0] = "invented-provider" }, + ) + } + } + } + + @Test + fun `rejects every vendored invalid opaque identity vector`() { + val invalidVectors = vectors.getValue("invalidIdentities").jsonArray + assertThat(invalidVectors).hasSize(EXPECTED_INVALID_IDENTITY_VECTOR_COUNT) + val key = GroveHmacIdentityKey.forConformanceTesting( + TEST_OPAQUE_IDENTITY_SYSTEM_FAMILY, + vectors.string("keyId"), + vectors.string("epoch"), + vectors.string("keyHex").hexBytes(), + ) + + invalidVectors.forEach { element -> + val vector = element.jsonObject + val kind = GroveOpaqueIdentityKind.entries.single { + it.code == vector.string("identityKind") + } + val components = vector.array("components").map { it.jsonPrimitive.content } + org.junit.Assert.assertThrows(vector.string("id"), IllegalArgumentException::class.java) { + key.value(kind, components) + } + } + } + + @Test + fun `Health Connect sample builder reproduces the normative multi-output vector`() { + val key = GroveHmacIdentityKey.forConformanceTesting( + TEST_OPAQUE_IDENTITY_SYSTEM_FAMILY, + vectors.string("keyId"), + vectors.string("epoch"), + vectors.string("keyHex").hexBytes(), + ) + val vector = vectors.array("identities") + .map { it.jsonObject } + .single { it.string("id") == "multi-output-sample" } + val source = HealthConnectIdentity.record( + key, + FhirIdentifierKey( + vector.array("components")[2].jsonPrimitive.content, + vector.array("components")[3].jsonPrimitive.content, + ), + vector.array("components")[1].jsonPrimitive.content, + vector.array("components")[4].jsonPrimitive.content, + ) + + val output = HealthConnectIdentity.heartRateSampleOutput( + key, + source, + Instant.parse("2026-08-19T10:30:00Z"), + 0, + ) + + assertThat(output.value).isEqualTo(vector.string("value")) + } + + @Test + fun `matches the normative event entry-node and fullUrl vectors`() { + val eventVector = vectors.objectValue("event") + val event = HealthConnectIdentity.exchange( + eventVector.string("system"), + eventVector.string("producerInstance"), + EventSequence(eventVector.string("sequence")), + ) + assertThat(event.value).isEqualTo(eventVector.string("value")) + + val nodeVector = vectors.objectValue("entryNode") + val node = HealthConnectIdentity.conversionNode(nodeVector.string("system"), event) + assertThat(node.value).isEqualTo(nodeVector.string("value")) + assertThat(GroveExchangeIdentity.fullUrl(node)).isEqualTo(nodeVector.string("fullUrl")) + + vectors.array("fullUrls").forEach { element -> + val vector = element.jsonObject + val identifier = Identifier() + .setSystem(vector.string("system")) + .setValue(vector.string("value")) + assertThat(GroveExchangeIdentity.fullUrl(identifier)).isEqualTo(vector.string("fullUrl")) + } + } + + private val vectors: JsonObject by lazy { + val stream = requireNotNull(javaClass.getResourceAsStream(VENDORED_RESOURCE)) { + "Missing vendored exchange-protocol test vectors." + } + stream.bufferedReader(Charsets.UTF_8).use { reader -> + Json.parseToJsonElement(reader.readText()).jsonObject + } + } + + private fun JsonObject.string(name: String): String = getValue(name).jsonPrimitive.content + + private fun JsonObject.array(name: String): JsonArray = getValue(name).jsonArray + + private fun JsonObject.objectValue(name: String): JsonObject = getValue(name).jsonObject + + private fun validComponents(kind: GroveOpaqueIdentityKind): List = + List(kind.componentCount) { index -> + when { + index != 0 -> "component-$index" + kind in PROVIDER_IDENTITY_KINDS -> "withings" + kind in GENERIC_SOURCE_IDENTITY_KINDS -> "health-connect" + else -> "component-0" + } + } + + private fun String.hexBytes(): ByteArray { + require(length % 2 == 0 && all { it.digitToIntOrNull(16) != null }) { + "A conformance key must be even-length hexadecimal." + } + return chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } + + private companion object { + const val VENDORED_RESOURCE = "/grove-exchange-protocol-test-vectors.json" + const val EXTERNAL_CATALOG_PROPERTY = "grove.exchange-protocol.catalog" + const val EXPECTED_INVALID_IDENTITY_VECTOR_COUNT = 4 + val PROVIDER_IDENTITY_KINDS = setOf( + GroveOpaqueIdentityKind.PROVIDER_RECORD, + GroveOpaqueIdentityKind.PROVIDER_OUTPUT, + GroveOpaqueIdentityKind.PROVIDER_ARTIFACT, + ) + val GENERIC_SOURCE_IDENTITY_KINDS = setOf( + GroveOpaqueIdentityKind.SOURCE_RECORD, + GroveOpaqueIdentityKind.SOURCE_OUTPUT, + GroveOpaqueIdentityKind.SOURCE_ARTIFACT, + ) + } +} diff --git a/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/GroveMobileExchangeCorpusTest.kt b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/GroveMobileExchangeCorpusTest.kt new file mode 100644 index 00000000..c1da05ce --- /dev/null +++ b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/GroveMobileExchangeCorpusTest.kt @@ -0,0 +1,277 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import com.google.common.truth.Truth.assertThat +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.hl7.fhir.r4.formats.JsonParser +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.CodeType +import org.hl7.fhir.r4.model.Identifier +import org.hl7.fhir.r4.model.Observation +import org.hl7.fhir.r4.model.Provenance +import org.junit.Assert.assertThrows +import org.junit.Test +import java.io.File +import java.time.Instant + +/** Checks this producer boundary against the shared positive active and retraction corpus. */ +class GroveMobileExchangeCorpusTest { + @Test + fun `shared active and retraction examples satisfy the Kotlin event boundary`() { + val directory = System.getProperty(CORPUS_DIRECTORY_PROPERTY) + ?.takeIf(String::isNotBlank) + ?.let(::File) + ?: return + val manifest = Json.parseToJsonElement(directory.resolve("corpus.json").readText()).jsonObject + assertThat(manifest.getValue("version").jsonPrimitive.content) + .isEqualTo(HealthConnectContract.PACKAGE_VERSION) + + val active = parseBundle(directory.resolve("exchange-bundle.json")) + val activeSource = active.entry.mapNotNull { it.resource as? Observation }.single() + .identifier.single { it.hasGroveRole(GroveIdentifierRole.SOURCE_RECORD) } + eventBatch( + operation = HealthConnectExportOperation.ACTIVE, + bundle = active, + source = activeSource, + targets = emptySet(), + ) + + val retraction = parseBundle(directory.resolve("retraction-bundle.json")) + val lifecycle = retraction.entry.map { it.resource }.filterIsInstance().single() + val retractionSource = lifecycle.entity.single().what.identifier + val targets = lifecycle.target.map { target -> + val identifierRole = target.identifier.type.coding.single { + it.system == HealthConnectContract.GROVE_IDENTIFIER_ROLE + }.code + val targetRole = (target.extension.single { + it.url == HealthConnectContract.GROVE_RETRACTION_TARGET_ROLE + }.value as CodeType).value + HealthConnectRetractionTarget( + identifier = target.identifier.key(), + identifierRole = GroveIdentifierRole.entries.single { it.code == identifierRole }, + resourceType = target.type, + role = HealthConnectRetractionTargetRole.entries.single { it.code == targetRole }, + ) + }.toSet() + eventBatch( + operation = HealthConnectExportOperation.RETRACTION, + bundle = retraction, + source = retractionSource, + targets = targets, + ) + } + + @Test + fun `every exact structured mutation fails the Kotlin event boundary`() { + val directory = System.getProperty(CORPUS_DIRECTORY_PROPERTY) + ?.takeIf(String::isNotBlank) + ?.let(::File) + ?: return + val manifest = Json.parseToJsonElement(directory.resolve("corpus.json").readText()).jsonObject + val cases = manifest.getValue("cases").jsonArray.map { it.jsonObject } + assertThat(cases).hasSize(EXPECTED_MUTATION_COUNT) + assertThat(cases.map { it.getValue("id").jsonPrimitive.content }) + .containsExactlyElementsIn(EXPECTED_MUTATION_IDS) + + val bases = manifest.getValue("bases").jsonArray.associate { base -> + val value = base.jsonObject + value.getValue("id").jsonPrimitive.content to value.getValue("path").jsonPrimitive.content + } + cases.forEach { case -> + val baseId = case.getValue("base").jsonPrimitive.content + val baseElement = Json.parseToJsonElement(directory.resolve(bases.getValue(baseId)).readText()) + val baseBundle = JsonParser().parse(baseElement.toString()) as Bundle + val operation = if (baseId == "mobile-retraction") { + HealthConnectExportOperation.RETRACTION + } else { + HealthConnectExportOperation.ACTIVE + } + val source = sourceIdentifier(baseBundle, operation) + val targets = if (operation == HealthConnectExportOperation.RETRACTION) { + retractionTargets(baseBundle) + } else { + emptySet() + } + val mutated = case.getValue("patch").jsonArray.fold(baseElement) { document, operationPatch -> + document.applyPatch(operationPatch.jsonObject) + } + + assertThrows(Exception::class.java) { + val bundle = JsonParser().parse(mutated.toString()) as Bundle + eventBatch(operation, bundle, source, targets) + } + } + } + + private fun sourceIdentifier( + bundle: Bundle, + operation: HealthConnectExportOperation, + ): Identifier = when (operation) { + HealthConnectExportOperation.ACTIVE -> bundle.entry + .mapNotNull { it.resource as? Observation } + .first() + .identifier + .single { it.hasGroveRole(GroveIdentifierRole.SOURCE_RECORD) } + .copy() + HealthConnectExportOperation.RETRACTION -> bundle.entry + .map { it.resource } + .filterIsInstance() + .single() + .entity + .single() + .what + .identifier + .copy() + } + + private fun retractionTargets(bundle: Bundle): Set = + bundle.entry.map { it.resource }.filterIsInstance().single().target.map { target -> + val identifierRole = target.identifier.type.coding.single { + it.system == HealthConnectContract.GROVE_IDENTIFIER_ROLE + }.code + val targetRole = (target.extension.single { + it.url == HealthConnectContract.GROVE_RETRACTION_TARGET_ROLE + }.value as CodeType).value + HealthConnectRetractionTarget( + identifier = target.identifier.key(), + identifierRole = GroveIdentifierRole.entries.single { it.code == identifierRole }, + resourceType = target.type, + role = HealthConnectRetractionTargetRole.entries.single { it.code == targetRole }, + ) + }.toSet() + + private fun JsonElement.applyPatch(patch: JsonObject): JsonElement { + val operation = patch.getValue("op").jsonPrimitive.content + val tokens = patch.getValue("path").jsonPrimitive.content + .split('/') + .drop(1) + .map { it.replace("~1", "/").replace("~0", "~") } + return patched(tokens, operation, patch["value"]) + } + + private fun JsonElement.patched( + tokens: List, + operation: String, + value: JsonElement?, + ): JsonElement { + require(tokens.isNotEmpty()) { "The shared corpus does not patch the JSON document root." } + val head = tokens.first() + val tail = tokens.drop(1) + return when (this) { + is JsonObject -> patchedObject(head, tail, operation, value) + is JsonArray -> patchedArray(head, tail, operation, value) + else -> error("Corpus JSON Pointer traverses a primitive at /${tokens.joinToString("/")}.") + } + } + + private fun JsonObject.patchedObject( + key: String, + remaining: List, + operation: String, + value: JsonElement?, + ): JsonObject = JsonObject(toMutableMap().apply { + if (remaining.isNotEmpty()) { + put(key, getValue(key).patched(remaining, operation, value)) + return@apply + } + when (operation) { + "add", "replace" -> put(key, requireNotNull(value)) + "remove" -> requireNotNull(remove(key)) + else -> error("Unsupported corpus patch operation $operation") + } + }) + + private fun JsonArray.patchedArray( + token: String, + remaining: List, + operation: String, + value: JsonElement?, + ): JsonArray = JsonArray(toMutableList().apply { + if (remaining.isNotEmpty()) { + val index = token.toInt() + set(index, get(index).patched(remaining, operation, value)) + return@apply + } + when (operation) { + "add" -> if (token == "-") { + add(requireNotNull(value)) + } else { + add(token.toInt(), requireNotNull(value)) + } + "replace" -> set(token.toInt(), requireNotNull(value)) + "remove" -> removeAt(token.toInt()) + else -> error("Unsupported corpus patch operation $operation") + } + }) + + private fun eventBatch( + operation: HealthConnectExportOperation, + bundle: Bundle, + source: org.hl7.fhir.r4.model.Identifier, + targets: Set, + ): HealthConnectExportBatch { + val bundleJson = HealthConnectWireFormat.bundleJson(bundle) + return HealthConnectExportBatch( + eventSequence = EventSequence(bundle.identifier.value.substringAfterLast(':')), + operation = operation, + sourceRecordIdentifier = source, + sourceVersion = Instant.parse(bundle.timestampElement.valueAsString), + bundle = bundle, + bundleJson = bundleJson, + payloadSha256 = HealthConnectWireFormat.sha256(bundleJson), + retractedTargets = targets, + ) + } + + private fun parseBundle(file: File): Bundle = JsonParser().parse(file.readText()) as Bundle + + private companion object { + const val CORPUS_DIRECTORY_PROPERTY = "grove.mobile-exchange.corpus-directory" + const val EXPECTED_MUTATION_COUNT = 31 + val EXPECTED_MUTATION_IDS = setOf( + "missing-entry-node-key", + "non-deterministic-full-url", + "unresolved-internal-reference", + "wrong-heart-rate-unit", + "non-canonical-event-identity", + "tampered-entry-node-digest", + "missing-source-output-identity", + "identity-system-changes-role", + "missing-transform-provenance", + "retraction-literal-target", + "retraction-unknown-target-role", + "retraction-clear-target-identity", + "retraction-copied-clinical-resource", + "ambiguous-active-lifecycle-coding", + "contradictory-retraction-lifecycle-coding", + "unprofiled-active-observation", + "wrong-subject-target-type", + "false-reference-declared-type", + "transform-literal-source-entity", + "retraction-role-target-type-mismatch", + "retraction-additional-source-entity", + "mixed-literal-logical-patient-reference", + "untyped-logical-patient-reference", + "unadmitted-condition-resource", + "adapter-only-output-without-adapter-profile", + "contained-resource-prohibited", + "unadmitted-device-metric-resource", + "unprofiled-active-document-reference", + "unprofiled-active-device", + "unprofiled-active-provenance", + "disconnected-supporting-patient", + ) + } +} diff --git a/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectExportCoordinatorAcknowledgementTest.kt b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectExportCoordinatorAcknowledgementTest.kt new file mode 100644 index 00000000..59938df4 --- /dev/null +++ b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectExportCoordinatorAcknowledgementTest.kt @@ -0,0 +1,318 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.hl7.fhir.r4.model.DocumentReference +import org.hl7.fhir.r4.model.InstantType +import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.ResearchStudy +import org.junit.Test + +class HealthConnectExportCoordinatorAcknowledgementTest : HealthConnectExportCoordinatorTestSupport() { + @Test + fun `negative acknowledgement retains the prior journal state for retry`() = runTest { + val journal = InMemoryJournal() + val sink = RecordingSink() + val coordinator = HealthConnectExportCoordinator(converter, journal, sink) + coordinator.upsert(stepRecord("step-record"), conversionTime) + sink.failNext = true + + val failure = runCatching { + coordinator.delete("StepsRecord", "step-record", conversionTime.plusSeconds(1)) + }.exceptionOrNull() + + assertThat(failure).isInstanceOf(IllegalStateException::class.java) + assertThat(requireNotNull(journal.entry("StepsRecord", "step-record")).state) + .isEqualTo(HealthConnectExportState.ACTIVE) + + coordinator.delete("StepsRecord", "step-record", conversionTime.plusSeconds(1)) + assertThat(requireNotNull(journal.entry("StepsRecord", "step-record")).state) + .isEqualTo(HealthConnectExportState.INVALIDATED) + } + + @Test + fun `incomplete acknowledgement retains the exact pending upsert`() = runTest { + val journal = InMemoryJournal() + val sink = RecordingSink().apply { omitNextAcknowledgement = true } + val coordinator = HealthConnectExportCoordinator(converter, journal, sink) + val record = stepRecord("step-record") + + val failure = runCatching { coordinator.upsert(record, conversionTime) }.exceptionOrNull() + + assertThat(failure).isInstanceOf(IllegalArgumentException::class.java) + assertThat(journal.entry("StepsRecord", "step-record")).isNull() + val pending = requireNotNull(journal.pending("StepsRecord", "step-record")) + + coordinator.upsert(record, conversionTime.plusSeconds(20)) + + assertThat(sink.batches.last().eventSequence).isEqualTo(pending.eventSequence) + assertThat(requireNotNull(journal.entry("StepsRecord", "step-record")).destinationReferences) + .isNotEmpty() + } + + @Test + fun `journal snapshots destination references returned by a mutable sink`() = runTest { + val journal = InMemoryJournal() + val mutableReferences = mutableMapOf() + val coordinator = HealthConnectExportCoordinator( + converter, + journal, + HealthConnectExportSink { batch -> + batch.bundle.groveOutputIdentifiers().forEach { output -> + mutableReferences[output.key()] = "Resource/server-id" + } + HealthConnectExportAcknowledgement(mutableReferences) + }, + ) + + coordinator.upsert(stepRecord("step-record"), conversionTime) + mutableReferences.clear() + + assertThat(requireNotNull(journal.entry("StepsRecord", "step-record")).destinationReferences) + .isNotEmpty() + } + + @Test + fun `sink acknowledgement snapshots its mutable destination map`() { + val mutableReferences = mutableMapOf( + FhirIdentifierKey(TEST_EVENT_SYSTEM, "source-output") to "Observation/server-id", + ) + val acknowledgement = HealthConnectExportAcknowledgement(mutableReferences) + + mutableReferences.clear() + val returned = acknowledgement.destinationReferences.toMutableMap() + returned.clear() + + assertThat(acknowledgement.destinationReferences).containsExactly( + FhirIdentifierKey(TEST_EVENT_SYSTEM, "source-output"), + "Observation/server-id", + ) + } + + @Test + fun `export batch rejects a Bundle that differs from its authoritative JSON`() { + val conversion = converter.convert(stepRecord("step-record"), conversionTime) + val bundleJson = HealthConnectWireFormat.bundleJson(conversion.bundle) + val changedBundle = conversion.bundle.apply { + timestampElement = org.hl7.fhir.r4.model.InstantType(conversionTime.plusSeconds(1).toString()) + } + + val failure = runCatching { + HealthConnectExportBatch( + eventSequence = EventSequence("1"), + operation = HealthConnectExportOperation.ACTIVE, + sourceRecordIdentifier = conversion.sourceRecordIdentifier, + sourceVersion = conversion.sourceLastModified, + bundle = changedBundle, + bundleJson = bundleJson, + payloadSha256 = HealthConnectWireFormat.sha256(bundleJson), + ) + }.exceptionOrNull() + + assertThat(failure).isInstanceOf(IllegalArgumentException::class.java) + } + + @Test + fun `export batch snapshots mutable HAPI inputs and returns defensive copies`() { + val conversion = converter.convert( + stepRecord("immutable-batch"), + conversionTime, + EventSequence("1"), + ) + val sourceRecordIdentifier = conversion.sourceRecordIdentifier + val bundle = conversion.bundle + val expectedSourceValue = sourceRecordIdentifier.value + val bundleJson = HealthConnectWireFormat.bundleJson(bundle) + val payloadSha256 = HealthConnectWireFormat.sha256(bundleJson) + val batch = HealthConnectExportBatch( + eventSequence = EventSequence("1"), + operation = HealthConnectExportOperation.ACTIVE, + sourceRecordIdentifier = sourceRecordIdentifier, + sourceVersion = conversion.sourceLastModified, + bundle = bundle, + bundleJson = bundleJson, + payloadSha256 = payloadSha256, + ) + + sourceRecordIdentifier.value = "mutated-constructor-input" + bundle.timestampElement = InstantType(conversionTime.plusSeconds(1).toString()) + batch.sourceRecordIdentifier.value = "mutated-getter-copy" + batch.bundle.timestampElement = InstantType(conversionTime.plusSeconds(2).toString()) + + assertThat(batch.sourceRecordIdentifier.value).isEqualTo(expectedSourceValue) + assertThat(HealthConnectWireFormat.bundleJson(batch.bundle)).isEqualTo(bundleJson) + assertThat(batch.bundleJson).isEqualTo(bundleJson) + assertThat(batch.payloadSha256).isEqualTo(payloadSha256) + } + + @Test + fun `journal debug representations redact native Health Connect ids`() { + val rawId = "raw-secret-health-connect-id" + val lease = HealthConnectSourceTransitionLease( + repositoryScopeKey = synchronizationScope.repositoryScopeKey, + recordType = "StepsRecord", + healthConnectId = rawId, + fence = HealthConnectJournalFence("1"), + ) + val unmatchedDeletion = HealthConnectUnmatchedDeletion( + repositoryScopeKey = synchronizationScope.repositoryScopeKey, + projectionScopeKey = synchronizationScope.projectionScopeKey, + recordType = "StepsRecord", + healthConnectId = rawId, + observedAt = conversionTime, + ) + val rejectedRecord = HealthConnectRejectedRecord( + repositoryScopeKey = synchronizationScope.repositoryScopeKey, + projectionScopeKey = synchronizationScope.projectionScopeKey, + recordType = "StepsRecord", + healthConnectId = rawId, + sourceLastModified = conversionTime, + observedAt = conversionTime.plusSeconds(1), + reason = "conversion failed", + ) + + listOf(lease, unmatchedDeletion, rejectedRecord).forEach { value -> + assertThat(value.toString()).doesNotContain(rawId) + assertThat(value.toString()).contains("") + } + } + + @Test + fun `retraction targets enforce the catalog role type and Identifier role table`() { + val identifier = FhirIdentifierKey(TEST_EVENT_SYSTEM, "document-output") + + val admitted = HealthConnectRetractionTarget( + identifier, + GroveIdentifierRole.SOURCE_OUTPUT, + "DocumentReference", + HealthConnectRetractionTargetRole.SOURCE_ARTIFACT, + ) + val wrongIdentifierRole = runCatching { + HealthConnectRetractionTarget( + identifier, + GroveIdentifierRole.SOURCE_ARTIFACT, + "DocumentReference", + HealthConnectRetractionTargetRole.SOURCE_ARTIFACT, + ) + }.exceptionOrNull() + val wrongResourceType = runCatching { + HealthConnectRetractionTarget( + identifier, + GroveIdentifierRole.SOURCE_OUTPUT, + "Observation", + HealthConnectRetractionTargetRole.SOURCE_ARTIFACT, + ) + }.exceptionOrNull() + + assertThat(admitted.identifierRole).isEqualTo(GroveIdentifierRole.SOURCE_OUTPUT) + assertThat(wrongIdentifierRole).isInstanceOf(IllegalArgumentException::class.java) + assertThat(wrongResourceType).isInstanceOf(IllegalArgumentException::class.java) + } + + @Test + fun `DocumentReference deletion retracts its source-output identity as a source artifact`() = runTest { + val prior = documentJournalEntry("document-to-delete") + val document = prior.bundle.entry.mapNotNull { it.resource as? DocumentReference }.single() + val expectedIdentifier = document.typedGroveIdentifiers("DocumentReference test output") + .getValue(GroveIdentifierRole.SOURCE_OUTPUT) + .key() + val journal = InMemoryJournal(startingSequence = 2).apply { storeLocal(prior) } + val sink = RecordingSink() + val coordinator = HealthConnectExportCoordinator(converter, journal, sink) + + coordinator.delete("StepsRecord", "document-to-delete", conversionTime.plusSeconds(1)) + + val target = sink.batches.single().retractedTargets.single { + it.role == HealthConnectRetractionTargetRole.SOURCE_ARTIFACT + } + assertThat(target.identifier).isEqualTo(expectedIdentifier) + assertThat(target.identifierRole).isEqualTo(GroveIdentifierRole.SOURCE_OUTPUT) + assertThat(target.resourceType).isEqualTo("DocumentReference") + } + + @Test + fun `active DocumentReference rejects source-artifact as its selected entry key`() { + val prior = documentJournalEntry("wrong-document-entry-key") + val bundle = prior.bundle + val documentEntry = bundle.entry.single { it.resource is DocumentReference } + val document = documentEntry.resource as DocumentReference + val sourceArtifact = document.typedGroveIdentifiers("DocumentReference test output") + .getValue(GroveIdentifierRole.SOURCE_ARTIFACT) + val oldFullUrl = documentEntry.fullUrl + val newFullUrl = GroveExchangeIdentity.fullUrl(sourceArtifact) + (documentEntry.extension.single { + it.url == GroveExchangeIdentity.ENTRY_IDENTIFIER_EXTENSION + }).value = sourceArtifact.copy() + documentEntry.fullUrl = newFullUrl + bundle.entry.flatMap { it.resource.groveReferenceNodes() } + .filter { it.reference == oldFullUrl } + .forEach { it.reference = newFullUrl } + val bundleJson = HealthConnectWireFormat.bundleJson(bundle) + + val failure = runCatching { + HealthConnectExportBatch( + eventSequence = EventSequence("1"), + operation = HealthConnectExportOperation.ACTIVE, + sourceRecordIdentifier = prior.sourceRecordIdentifier, + sourceVersion = prior.sourceLastModified, + bundle = bundle, + bundleJson = bundleJson, + payloadSha256 = HealthConnectWireFormat.sha256(bundleJson), + ) + }.exceptionOrNull() + + assertThat(failure).isInstanceOf(IllegalArgumentException::class.java) + } + + @Test + fun `ResearchStudy protocol rejects a resolved non-PlanDefinition target`() { + val bundle = converter.convert( + stepRecord("research-protocol"), + conversionTime, + EventSequence("1"), + ).bundle.copy() + val researchStudy = bundle.entry.mapNotNull { it.resource as? ResearchStudy }.single() + val patientEntry = bundle.entry.single { it.resource is Patient } + researchStudy.addProtocol( + org.hl7.fhir.r4.model.Reference().apply { + type = "Patient" + reference = patientEntry.fullUrl + }, + ) + + val failure = runCatching { bundle.requireGroveReferencePolicy() }.exceptionOrNull() + + assertThat(failure).isInstanceOf(IllegalArgumentException::class.java) + } + + @Test + fun `ResearchStudy protocol admits only a complete identifier-only PlanDefinition`() { + val validBundle = converter.convert( + stepRecord("logical-research-protocol"), + conversionTime, + EventSequence("1"), + ).bundle.copy() + validBundle.entry.mapNotNull { it.resource as? ResearchStudy }.single().addProtocol( + org.hl7.fhir.r4.model.Reference().apply { + type = "PlanDefinition" + identifier = identifier("https://study.example.org/fhir/protocol", "protocol-1") + }, + ) + val invalidBundle = validBundle.copy().apply { + entry.mapNotNull { it.resource as? ResearchStudy }.single().protocol.single().type = "ResearchStudy" + } + + val validFailure = runCatching { validBundle.requireGroveReferencePolicy() }.exceptionOrNull() + val invalidFailure = runCatching { invalidBundle.requireGroveReferencePolicy() }.exceptionOrNull() + + assertThat(validFailure).isNull() + assertThat(invalidFailure).isInstanceOf(IllegalArgumentException::class.java) + } +} diff --git a/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectExportCoordinatorReconciliationTest.kt b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectExportCoordinatorReconciliationTest.kt new file mode 100644 index 00000000..cfd24012 --- /dev/null +++ b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectExportCoordinatorReconciliationTest.kt @@ -0,0 +1,158 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.grovealliance.health.RecordType +import org.hl7.fhir.r4.formats.IParser +import org.hl7.fhir.r4.formats.JsonParser +import org.junit.Test +import java.io.File +import java.time.Instant + +class HealthConnectExportCoordinatorReconciliationTest : HealthConnectExportCoordinatorTestSupport() { + @Test + fun `expired-token full read reconciles absence and tolerates deletion replay`() = runTest { + val journal = InMemoryJournal() + val sink = RecordingSink() + val coordinator = HealthConnectExportCoordinator(converter, journal, sink) + coordinator.upsert(stepRecord("retained"), conversionTime) + coordinator.upsert(stepRecord("removed"), conversionTime) + val constraint = HealthConnectSynchronizationConstraint( + coordinator = coordinator, + fullReader = HealthConnectFullReader { listOf(stepRecord("retained")) }, + now = { conversionTime.plusSeconds(30) }, + ) + + constraint.onFullyResyncRequired(RecordType.steps) + + assertThat(requireNotNull(journal.entry("StepsRecord", "retained")).state) + .isEqualTo(HealthConnectExportState.ACTIVE) + assertThat(requireNotNull(journal.entry("StepsRecord", "removed")).state) + .isEqualTo(HealthConnectExportState.INVALIDATED) + val batchCount = sink.batches.size + constraint.handleDeletedRecords(setOf("removed"), RecordType.steps) + assertThat(sink.batches).hasSize(batchCount) + } + + @Test + fun `an upsert leaving the collection filter invalidates its prior output`() = runTest { + val journal = InMemoryJournal() + val sink = RecordingSink() + val coordinator = HealthConnectExportCoordinator(converter, journal, sink) + coordinator.upsert(stepRecord("filtered-record"), conversionTime) + val constraint = HealthConnectSynchronizationConstraint( + coordinator = coordinator, + fullReader = HealthConnectFullReader { emptyList() }, + now = { conversionTime.plusSeconds(1) }, + ) + + constraint.handleExcludedRecords(setOf("filtered-record"), RecordType.steps) + + assertThat(requireNotNull(journal.entry("StepsRecord", "filtered-record")).state) + .isEqualTo(HealthConnectExportState.INVALIDATED) + assertThat(sink.batches.last().operation).isEqualTo(HealthConnectExportOperation.RETRACTION) + } + + @Test + @Suppress("LongMethod") + fun `emits deterministic complete conformance fixtures`() = runTest { + val exportDirectory = File(requireNotNull(System.getProperty("grove.conformance.export"))) + exportDirectory.mkdirs() + exportDirectory.listFiles()?.forEach { it.delete() } + val wireExportDirectory = File(requireNotNull(System.getProperty("grove.wire.export"))) + wireExportDirectory.mkdirs() + wireExportDirectory.listFiles()?.forEach { it.delete() } + val conversions = completeConformanceRecords().map { (name, record) -> + name to converter.convert(record, conversionTime) + } + val parser = JsonParser().setOutputStyle(IParser.OutputStyle.PRETTY) + + conversions.forEach { (name, conversion) -> + File(exportDirectory, "health-connect-$name-bundle.json") + .writeText(parser.composeString(conversion.bundle) + "\n") + conversion.observations.forEachIndexed { index, observation -> + File(exportDirectory, "health-connect-$name-observation-${index + 1}.json") + .writeText(parser.composeString(observation) + "\n") + } + File(exportDirectory, "health-connect-$name-provenance.json") + .writeText(parser.composeString(requireNotNull(conversion.provenance)) + "\n") + } + + semanticVectorRecords().forEach { vector -> + val conversion = converter.convert(vector.record, semanticConversionTime) + val observation = conversion.observations.single { candidate -> + candidate.meta.profile.any { it.value == vector.profile } + } + File(exportDirectory, "health-connect-semantic-${vector.id}-observation.json") + .writeText(parser.composeString(observation) + "\n") + } + + val journal = InMemoryJournal() + val sink = RecordingSink() + val coordinator = HealthConnectExportCoordinator(converter, journal, sink) + coordinator.upsert(heartRateRecord(twoHeartRateSamples()), conversionTime) + File(wireExportDirectory, "health-connect-heart-rate-upsert-bundle.json") + .writeText(sink.batches.last().bundleJson) + assertThat(sink.batches.last().payloadSha256) + .isEqualTo("ff3472f3e322b249a045ce1e39fe19a97d843342990f0c47aef249bbc88e4feb") + coordinator.upsert( + heartRateRecord( + samples = twoHeartRateSamples().take(1), + lastModified = Instant.parse("2026-08-19T17:30:02Z"), + ), + conversionTime.plusSeconds(1), + ) + File(exportDirectory, "health-connect-heart-rate-update-bundle.json") + .writeText(parser.composeString(sink.batches.last().bundle) + "\n") + File(wireExportDirectory, "health-connect-heart-rate-update-bundle.json") + .writeText(sink.batches.last().bundleJson) + assertThat(sink.batches.last().payloadSha256) + .isEqualTo("20fe8abbfd9c5954eea5382e2323b5f942f894dd5187c17d073c393ca590ad50") + + coordinator.upsert( + heartRateRecord( + samples = emptyList(), + lastModified = Instant.parse("2026-08-19T17:30:03Z"), + ), + conversionTime.plusSeconds(2), + ) + val zeroOutputRetraction = sink.batches.last() + File(wireExportDirectory, "health-connect-heart-rate-zero-output-retraction-bundle.json") + .writeText(zeroOutputRetraction.bundleJson) + assertThat(zeroOutputRetraction.operation).isEqualTo(HealthConnectExportOperation.RETRACTION) + assertThat(zeroOutputRetraction.eventSequence.value).isEqualTo("4") + assertThat(zeroOutputRetraction.wireSourceVersion).isEqualTo("1787160602000000000") + assertThat(zeroOutputRetraction.bundle.observations()).isEmpty() + assertThat(zeroOutputRetraction.bundle.entry.map { it.resource.fhirType() }) + .containsExactly("Provenance") + assertThat(zeroOutputRetraction.payloadSha256) + .isEqualTo("95daabfdc2d020296d90130217489248a854caf8b7349fd4d08f6804fbeadc85") + + coordinator.upsert(stepRecord("fixture-deletion"), conversionTime) + coordinator.delete("StepsRecord", "fixture-deletion", conversionTime.plusSeconds(2)) + File(exportDirectory, "health-connect-step-deletion-bundle.json") + .writeText(parser.composeString(sink.batches.last().bundle) + "\n") + File(wireExportDirectory, "health-connect-step-deletion-bundle.json") + .writeText(sink.batches.last().bundleJson) + assertThat(sink.batches.last().payloadSha256) + .isEqualTo("0d2c0cee731e631a21f1761beddfd2391417f906cbdf32fcdb618fc9cae8aae8") + + assertThat( + exportDirectory.listFiles()?.map { it.name } + ?.filter { it.startsWith("health-connect-semantic-") } + ?.sorted() + .orEmpty(), + ).containsExactlyElementsIn( + semanticVectorRecords().map { "health-connect-semantic-${it.id}-observation.json" }.sorted(), + ).inOrder() + assertThat(exportDirectory.listFiles()?.map { it.name }.orEmpty()).hasSize(75) + assertThat(wireExportDirectory.listFiles()?.map { it.name }.orEmpty()).hasSize(4) + } +} diff --git a/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectExportCoordinatorReplayTest.kt b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectExportCoordinatorReplayTest.kt new file mode 100644 index 00000000..c1249854 --- /dev/null +++ b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectExportCoordinatorReplayTest.kt @@ -0,0 +1,354 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import androidx.health.connect.client.records.StepsRecord +import androidx.health.connect.client.records.WeightRecord +import androidx.health.connect.client.records.metadata.Metadata +import androidx.health.connect.client.units.Mass +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.hl7.fhir.r4.model.InstantType +import org.hl7.fhir.r4.model.Observation +import org.junit.Test +import java.time.Instant +import java.time.ZoneOffset + +class HealthConnectExportCoordinatorReplayTest : HealthConnectExportCoordinatorTestSupport() { + @Test + fun `equal-version replay is an acknowledged no-op`() = runTest { + val journal = InMemoryJournal() + val sink = RecordingSink() + val coordinator = HealthConnectExportCoordinator(converter, journal, sink) + val record = heartRateRecord(twoHeartRateSamples()) + + coordinator.upsert(record, conversionTime) + coordinator.upsert(record, conversionTime.plusSeconds(1)) + + assertThat(sink.batches).hasSize(1) + assertThat(journal.entries("HeartRateRecord")).hasSize(1) + } + + @Test + fun `event sequence accepts a changed payload even when the source timestamp decreases`() = runTest { + val journal = InMemoryJournal() + val sink = RecordingSink() + val coordinator = HealthConnectExportCoordinator(converter, journal, sink) + val newer = heartRateRecord( + twoHeartRateSamples(), + lastModified = Instant.parse("2026-08-19T17:30:02Z"), + ) + val changedWithOlderTimestamp = heartRateRecord( + twoHeartRateSamples().take(1), + lastModified = Instant.parse("2026-08-19T17:30:01Z"), + ) + + coordinator.reconcile("HeartRateRecord", { conversionTime }) { listOf(newer) } + coordinator.upsert(changedWithOlderTimestamp, conversionTime.plusSeconds(1)) + + assertThat(sink.batches).hasSize(3) + assertThat(sink.batches.map { it.operation }).containsExactly( + HealthConnectExportOperation.ACTIVE, + HealthConnectExportOperation.RETRACTION, + HealthConnectExportOperation.ACTIVE, + ).inOrder() + assertThat(sink.batches[2].eventSequence).isGreaterThan(sink.batches[0].eventSequence) + assertThat(requireNotNull(journal.entry("HeartRateRecord", "heart-record")).sourceLastModified) + .isEqualTo(Instant.parse("2026-08-19T17:30:01Z")) + } + + @Test + fun `event sequence accepts a changed payload at the same source timestamp`() = runTest { + val journal = InMemoryJournal() + val sink = RecordingSink() + val coordinator = HealthConnectExportCoordinator(converter, journal, sink) + + coordinator.upsert(stepRecord("same-timestamp", count = 1042), conversionTime) + coordinator.upsert(stepRecord("same-timestamp", count = 2042), conversionTime.plusSeconds(1)) + + assertThat(sink.batches).hasSize(3) + assertThat(sink.batches.map { it.wireSourceVersion }.distinct()).hasSize(1) + assertThat(sink.batches.map { it.operation }).containsExactly( + HealthConnectExportOperation.ACTIVE, + HealthConnectExportOperation.RETRACTION, + HealthConnectExportOperation.ACTIVE, + ).inOrder() + assertThat(sink.batches[2].eventSequence).isGreaterThan(sink.batches[0].eventSequence) + } + + @Test + fun `pending outbox replays the exact acknowledged payload after journal failure`() = runTest { + val journal = InMemoryJournal().apply { failCompleteNext = true } + val sink = RecordingSink() + val coordinator = HealthConnectExportCoordinator(converter, journal, sink) + val record = stepRecord("step-record") + + val failure = runCatching { coordinator.upsert(record, conversionTime) }.exceptionOrNull() + assertThat(failure).isInstanceOf(IllegalStateException::class.java) + + coordinator.upsert(record, conversionTime.plusSeconds(30)) + + assertThat(sink.batches).hasSize(2) + assertThat(sink.batches[1].eventSequence).isEqualTo(sink.batches[0].eventSequence) + assertThat(sink.batches[1].bundleJson).isEqualTo(sink.batches[0].bundleJson) + assertThat(sink.batches[1].payloadSha256).isEqualTo(sink.batches[0].payloadSha256) + assertThat(sink.batches[1].bundle.equalsDeep(sink.batches[0].bundle)).isTrue() + assertThat(sink.batches[1].sourceVersion).isEqualTo(sink.batches[0].sourceVersion) + assertThat(sink.batches[1].wireSourceVersion).isEqualTo("1787160601000000000") + assertThat(sink.batches[1].wireOperation).isEqualTo("active") + assertThat(requireNotNull(journal.entry("StepsRecord", "step-record")).destinationReferences) + .isNotEmpty() + assertThat(requireNotNull(journal.entry("StepsRecord", "step-record")).lastEventSequence) + .isEqualTo(sink.batches.last().eventSequence) + } + + @Test + fun `a second coordinator resumes the exact event after stale-fence loss before replacement`() = runTest { + val journal = InMemoryJournal().apply { loseSourceLeaseBeforeNextComplete = true } + val sink = RecordingSink() + val firstCoordinator = HealthConnectExportCoordinator(converter, journal, sink) + val secondCoordinator = HealthConnectExportCoordinator(converter, journal, sink) + val initial = stepRecord("fenced-retry", count = 1042) + val replacement = stepRecord("fenced-retry", count = 2042) + + val failure = runCatching { firstCoordinator.upsert(initial, conversionTime) }.exceptionOrNull() + val staged = requireNotNull(journal.pending("StepsRecord", "fenced-retry")) + secondCoordinator.upsert(replacement, conversionTime.plusSeconds(1)) + + assertThat(failure).isInstanceOf(IllegalStateException::class.java) + assertThat(sink.batches.map { it.eventSequence.value }).containsExactly("1", "1", "2", "3").inOrder() + assertThat(sink.batches[1].bundleJson).isEqualTo(staged.bundleJson) + assertThat(sink.batches[1].payloadSha256).isEqualTo(staged.payloadSha256) + assertThat(sink.batches.map { it.operation }).containsExactly( + HealthConnectExportOperation.ACTIVE, + HealthConnectExportOperation.ACTIVE, + HealthConnectExportOperation.RETRACTION, + HealthConnectExportOperation.ACTIVE, + ).inOrder() + assertThat(journal.pending("StepsRecord", "fenced-retry")).isNull() + assertThat(requireNotNull(journal.entry("StepsRecord", "fenced-retry")).lastEventSequence?.value) + .isEqualTo("3") + } + + @Test + fun `two coordinators serialize a same-source race into one ordered lifecycle`() = runTest { + val journal = InMemoryJournal() + val sink = RecordingSink() + val firstCoordinator = HealthConnectExportCoordinator(converter, journal, sink) + val secondCoordinator = HealthConnectExportCoordinator(converter, journal, sink) + val first = stepRecord("same-source-race", count = 1042) + val second = StepsRecord( + startTime = Instant.parse("2026-08-19T17:30:00Z"), + startZoneOffset = ZoneOffset.ofHours(-7), + endTime = Instant.parse("2026-08-19T17:31:00Z"), + endZoneOffset = ZoneOffset.ofHours(-7), + count = 2042, + metadata = metadata( + Metadata.autoRecorded(watch), + "same-source-race", + Instant.parse("2026-08-19T17:30:02Z"), + ), + ) + + coroutineScope { + listOf( + async { firstCoordinator.upsert(first, conversionTime) }, + async { secondCoordinator.upsert(second, conversionTime.plusSeconds(1)) }, + ).awaitAll() + } + + assertThat(sink.batches.map { it.eventSequence.value }).containsExactly("1", "2", "3").inOrder() + assertThat(sink.batches.map { it.operation }).containsExactly( + HealthConnectExportOperation.ACTIVE, + HealthConnectExportOperation.RETRACTION, + HealthConnectExportOperation.ACTIVE, + ).inOrder() + val finalEntry = requireNotNull(journal.entry("StepsRecord", "same-source-race")) + assertThat(finalEntry.sourceLastModified).isEqualTo(sink.batches.last().sourceVersion) + assertThat(finalEntry.lastEventSequence?.value).isEqualTo("3") + assertThat(journal.pending("StepsRecord", "same-source-race")).isNull() + } + + @Test + fun `reconciliation lease fences an ordinary upsert until the absence snapshot completes`() = runTest { + val journal = InMemoryJournal() + val sink = RecordingSink() + val reconciliationCoordinator = HealthConnectExportCoordinator(converter, journal, sink) + val changesCoordinator = HealthConnectExportCoordinator(converter, journal, sink) + reconciliationCoordinator.upsert(stepRecord("absent-during-reconcile"), conversionTime) + val enteredSink = CompletableDeferred() + val releaseSink = CompletableDeferred() + sink.pauseBeforeNextApply = enteredSink to releaseSink + + val reconciliation = async { + reconciliationCoordinator.reconcile("StepsRecord", { conversionTime.plusSeconds(1) }) { emptyList() } + } + enteredSink.await() + val concurrentUpsert = async { + changesCoordinator.upsert(stepRecord("arrived-during-reconcile"), conversionTime.plusSeconds(2)) + } + runCurrent() + + assertThat(concurrentUpsert.isCompleted).isFalse() + releaseSink.complete(Unit) + reconciliation.await() + concurrentUpsert.await() + + assertThat(sink.batches.map { it.eventSequence.value }).containsExactly("1", "2", "3").inOrder() + assertThat(requireNotNull(journal.entry("StepsRecord", "absent-during-reconcile")).state) + .isEqualTo(HealthConnectExportState.INVALIDATED) + assertThat(requireNotNull(journal.entry("StepsRecord", "arrived-during-reconcile")).state) + .isEqualTo(HealthConnectExportState.ACTIVE) + } + + @Test + fun `complete read executes inside the reconciliation fence`() = runTest { + val journal = InMemoryJournal() + val sink = RecordingSink() + val reconciliationCoordinator = HealthConnectExportCoordinator(converter, journal, sink) + val changesCoordinator = HealthConnectExportCoordinator(converter, journal, sink) + val enteredRead = CompletableDeferred() + val releaseRead = CompletableDeferred() + + val reconciliation = async { + reconciliationCoordinator.reconcile("StepsRecord", { conversionTime }) { + enteredRead.complete(Unit) + releaseRead.await() + emptyList() + } + } + enteredRead.await() + val concurrentUpsert = async { + changesCoordinator.upsert(stepRecord("arrived-during-read"), conversionTime.plusSeconds(1)) + } + runCurrent() + + assertThat(concurrentUpsert.isCompleted).isFalse() + releaseRead.complete(Unit) + reconciliation.await() + concurrentUpsert.await() + + assertThat(sink.batches.map { it.eventSequence.value }).containsExactly("1") + assertThat(requireNotNull(journal.entry("StepsRecord", "arrived-during-read")).state) + .isEqualTo(HealthConnectExportState.ACTIVE) + } + + @Test + fun `pending and journal getters cannot mutate the durable exact-event snapshot`() = runTest { + val journal = InMemoryJournal().apply { failCompleteNext = true } + val sink = RecordingSink() + val coordinator = HealthConnectExportCoordinator(converter, journal, sink) + runCatching { coordinator.upsert(stepRecord("immutable-pending"), conversionTime) } + val pending = requireNotNull(journal.pending("StepsRecord", "immutable-pending")) + val exactJson = pending.bundleJson + + pending.bundle.timestampElement = InstantType(conversionTime.plusSeconds(90).toString()) + pending.sourceRecordIdentifier.value = "mutated-source" + pending.nextEntry.bundle.id = "mutated-next-entry" + coordinator.upsert(stepRecord("immutable-pending"), conversionTime.plusSeconds(1)) + + assertThat(sink.batches.last().bundleJson).isEqualTo(exactJson) + assertThat(requireNotNull(journal.entry("StepsRecord", "immutable-pending")).bundle.id) + .isNotEqualTo("mutated-next-entry") + } + + @Test + fun `expired baseline drains an orphaned first upsert then retracts its absent source`() = runTest { + val journal = InMemoryJournal().apply { failCompleteNext = true } + val sink = RecordingSink() + val coordinator = HealthConnectExportCoordinator(converter, journal, sink) + + runCatching { coordinator.upsert(stepRecord("removed-before-recovery"), conversionTime) } + coordinator.reconcile("StepsRecord", { conversionTime.plusSeconds(30) }) { emptyList() } + + assertThat(sink.batches).hasSize(3) + assertThat(sink.batches[1].eventSequence).isEqualTo(sink.batches[0].eventSequence) + assertThat(sink.batches[1].bundleJson).isEqualTo(sink.batches[0].bundleJson) + assertThat(sink.batches[2].operation).isEqualTo(HealthConnectExportOperation.RETRACTION) + assertThat(sink.batches[2].bundle.observations()).isEmpty() + assertThat(sink.batches[2].bundle.entry.map { it.resource.fhirType() }) + .containsExactly("Provenance") + assertThat(requireNotNull(journal.entry("StepsRecord", "removed-before-recovery")).state) + .isEqualTo(HealthConnectExportState.INVALIDATED) + } + + @Test + fun `hydrated outbox rejects a Bundle that differs from its exact JSON`() = runTest { + val journal = InMemoryJournal().apply { failCompleteNext = true } + val coordinator = HealthConnectExportCoordinator(converter, journal, RecordingSink()) + runCatching { coordinator.upsert(stepRecord("corrupt-pending"), conversionTime) } + val pending = requireNotNull(journal.pending("StepsRecord", "corrupt-pending")) + + val failure = runCatching { + pending.copy(bundle = pending.bundle.copy().apply { id = "different-bundle" }) + }.exceptionOrNull() + + assertThat(failure).isInstanceOf(IllegalArgumentException::class.java) + } + + @Test + fun `hydrated journal entry rejects a missing selected output identity`() { + val record = stepRecord("corrupt-journal-entry") + val conversion = converter.convert(record, conversionTime, EventSequence("1")) + val corruptedBundle = conversion.bundle.copy().apply { + entry.single { it.resource is Observation }.extension.removeAll { + it.url == GroveExchangeIdentity.ENTRY_IDENTIFIER_EXTENSION + } + } + + val failure = runCatching { + HealthConnectExportJournalEntry( + repositoryScopeKey = synchronizationScope.repositoryScopeKey, + projectionScopeKey = synchronizationScope.projectionScopeKey, + recordType = conversion.sourceRecordType, + healthConnectId = record.metadata.id, + dataOriginPackage = record.metadata.dataOrigin.packageName, + sourceLastModified = conversion.sourceLastModified, + conversionContractVersion = conversion.conversionContractVersion, + sourceRecordIdentifier = conversion.sourceRecordIdentifier, + observations = conversion.observations, + bundle = corruptedBundle, + destinationReferences = emptyMap(), + lastEventSequence = EventSequence("1"), + ) + }.exceptionOrNull() + + assertThat(failure).isInstanceOf(IllegalArgumentException::class.java) + } + + @Test + fun `wire source version retains Health Connect nanosecond precision`() { + assertThat( + HealthConnectWireFormat.sourceVersion(Instant.parse("2026-08-19T17:30:01.123456789Z")), + ).isEqualTo("1787160601123456789") + } + + @Test + fun `FHIR-incompatible source time is durably rejected without a wire event`() = runTest { + val journal = InMemoryJournal() + val sink = RecordingSink() + val coordinator = HealthConnectExportCoordinator(converter, journal, sink) + val record = WeightRecord( + time = Instant.parse("2026-08-19T15:15:00Z"), + zoneOffset = ZoneOffset.ofHours(18), + weight = Mass.kilograms(68.4), + metadata = metadata(Metadata.manualEntry(), "invalid-fhir-offset"), + ) + + coordinator.upsert(record, conversionTime) + + assertThat(sink.batches).isEmpty() + assertThat(journal.pending("WeightRecord", "invalid-fhir-offset")).isNull() + assertThat(journal.rejectedRecords).hasSize(1) + } +} diff --git a/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectExportCoordinatorRetractionTest.kt b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectExportCoordinatorRetractionTest.kt new file mode 100644 index 00000000..344e3be3 --- /dev/null +++ b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectExportCoordinatorRetractionTest.kt @@ -0,0 +1,401 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import androidx.health.connect.client.records.BloodGlucoseRecord +import androidx.health.connect.client.records.MealType +import androidx.health.connect.client.records.metadata.Device +import androidx.health.connect.client.records.metadata.Metadata +import androidx.health.connect.client.units.BloodGlucose +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.hl7.fhir.r4.model.Provenance +import org.junit.Test +import java.time.Instant +import java.time.ZoneOffset + +class HealthConnectExportCoordinatorRetractionTest : HealthConnectExportCoordinatorTestSupport() { + @Test + fun `heart-rate update invalidates samples removed from the output set`() = runTest { + val journal = InMemoryJournal() + val sink = RecordingSink() + val coordinator = HealthConnectExportCoordinator(converter, journal, sink) + coordinator.upsert(heartRateRecord(twoHeartRateSamples()), conversionTime) + + coordinator.upsert( + heartRateRecord( + samples = twoHeartRateSamples().take(1), + lastModified = Instant.parse("2026-08-19T17:30:02Z"), + ), + conversionTime.plusSeconds(1), + ) + + val retraction = sink.batches[sink.batches.lastIndex - 1] + val update = sink.batches.last() + assertThat(retraction.retractedTargets).hasSize(4) + assertThat(retraction.retractedTargets.count { + it.role == HealthConnectRetractionTargetRole.DEVICE_SNAPSHOT + }).isEqualTo(2) + assertThat(retraction.bundle.observations()).isEmpty() + assertThat(update.retractedTargets).isEmpty() + val current = requireNotNull(journal.entry("HeartRateRecord", "heart-record")) + assertThat(current.observations).hasSize(1) + assertThat(current.state).isEqualTo(HealthConnectExportState.ACTIVE) + } + + @Test + fun `heart-rate update retracts prior outputs with a current assembler snapshot`() = runTest { + val oldWatch = Device(Device.TYPE_WATCH, "Example Device Company", "Old Study Watch") + val newWatch = Device(Device.TYPE_WATCH, "Example Device Company", "New Study Watch") + val contextAwareConverter = HealthConnectConverter( + fhirContext { source -> + if (source.model == oldWatch.model) { + "old-study-watch" + } else { + "new-study-watch" + } + }, + synchronizationScope, + ) + val journal = InMemoryJournal() + val sink = RecordingSink() + val coordinator = HealthConnectExportCoordinator(contextAwareConverter, journal, sink) + coordinator.upsert(heartRateRecord(twoHeartRateSamples(), device = oldWatch), conversionTime) + + coordinator.upsert( + heartRateRecord( + samples = twoHeartRateSamples().take(1), + lastModified = Instant.parse("2026-08-19T17:30:02Z"), + device = newWatch, + ), + conversionTime.plusSeconds(1), + ) + + val retraction = sink.batches[sink.batches.lastIndex - 1].bundle + val update = sink.batches.last().bundle + assertThat(retraction.entry.map { it.resource.fhirType() }).containsExactly("Provenance") + val retractionProvenance = retraction.entry.single().resource as Provenance + assertThat(retractionProvenance.agent.single().who.identifier.value) + .isEqualTo( + HealthConnectIdentity.deviceSnapshot( + testIdentityKey(), + HealthConnectIdentity.exchange(TEST_EVENT_SYSTEM, TEST_PRODUCER_INSTANCE, EventSequence("2")), + "application", + "edu.stanford.myheartcounts.fhir", + ).value, + ) + assertThat( + retractionProvenance.agent.single().who.identifier + .hasGroveRole(GroveIdentifierRole.DEVICE_SNAPSHOT), + ).isTrue() + val newRecorderSnapshot = HealthConnectIdentity.deviceSnapshot( + testIdentityKey(), + HealthConnectIdentity.exchange(TEST_EVENT_SYSTEM, TEST_PRODUCER_INSTANCE, EventSequence("3")), + "recording-device", + "new-study-watch", + ) + assertThat(update.entry.map { it.fullUrl }) + .contains(GroveExchangeIdentity.fullUrl(newRecorderSnapshot)) + assertThat(retraction.observations()).isEmpty() + } + + @Test + fun `deletion is acknowledged before a durable invalidation marker and replay is safe`() = runTest { + val journal = InMemoryJournal() + val sink = RecordingSink() + val coordinator = HealthConnectExportCoordinator(converter, journal, sink) + coordinator.upsert(stepRecord("step-record"), conversionTime) + + coordinator.delete("StepsRecord", "step-record", conversionTime.plusSeconds(1)) + + val deletion = sink.batches.last() + assertThat(deletion.retractedTargets).hasSize(3) + assertThat(deletion.retractedTargets.count { + it.role == HealthConnectRetractionTargetRole.DEVICE_SNAPSHOT + }).isEqualTo(2) + assertThat(deletion.operation).isEqualTo(HealthConnectExportOperation.RETRACTION) + assertThat(deletion.wireOperation).isEqualTo("retraction") + assertThat(deletion.sourceVersion).isEqualTo(Instant.parse("2026-08-19T17:30:01Z")) + assertThat(deletion.bundle.observations()).isEmpty() + val lifecycle = deletion.bundle.entry.mapNotNull { it.resource as? Provenance }.single() + assertThat(lifecycle.meta.profile.map { it.value }) + .containsExactly(HealthConnectContract.MOBILE_RETRACTION_PROVENANCE_PROFILE) + assertThat(lifecycle.activity.coding.single().code).isEqualTo("source-record-retracted") + assertThat(requireNotNull(journal.entry("StepsRecord", "step-record")).state) + .isEqualTo(HealthConnectExportState.INVALIDATED) + + val acknowledgedBatchCount = sink.batches.size + coordinator.delete("StepsRecord", "step-record", conversionTime.plusSeconds(2)) + assertThat(sink.batches).hasSize(acknowledgedBatchCount) + + coordinator.upsert(stepRecord("step-record"), conversionTime.plusSeconds(3)) + assertThat(sink.batches).hasSize(acknowledgedBatchCount + 1) + assertThat(sink.batches.last().operation).isEqualTo(HealthConnectExportOperation.ACTIVE) + assertThat(requireNotNull(journal.entry("StepsRecord", "step-record")).state) + .isEqualTo(HealthConnectExportState.ACTIVE) + } + + @Test + fun `glucose deletion retracts its specimen output with an exact typed target`() = runTest { + val journal = InMemoryJournal() + val sink = RecordingSink() + val coordinator = HealthConnectExportCoordinator(converter, journal, sink) + val record = BloodGlucoseRecord( + time = Instant.parse("2026-08-19T16:00:00Z"), + zoneOffset = ZoneOffset.UTC, + metadata = metadata(Metadata.autoRecorded(watch), "glucose-to-delete"), + level = BloodGlucose.milligramsPerDeciliter(95.5), + specimenSource = BloodGlucoseRecord.SPECIMEN_SOURCE_CAPILLARY_BLOOD, + mealType = MealType.MEAL_TYPE_UNKNOWN, + relationToMeal = BloodGlucoseRecord.RELATION_TO_MEAL_UNKNOWN, + ) + coordinator.upsert(record, conversionTime) + assertThat( + requireNotNull(journal.entry("BloodGlucoseRecord", "glucose-to-delete")) + .destinationReferences, + ).hasSize(2) + + coordinator.delete("BloodGlucoseRecord", "glucose-to-delete", conversionTime.plusSeconds(1)) + + val retraction = sink.batches.last() + val specimenTarget = retraction.retractedTargets.single { + it.role == HealthConnectRetractionTargetRole.SPECIMEN + } + assertThat(specimenTarget.resourceType).isEqualTo("Specimen") + assertThat(specimenTarget.identifierRole).isEqualTo(GroveIdentifierRole.SOURCE_OUTPUT) + val provenance = retraction.bundle.entry.mapNotNull { it.resource as? Provenance }.single() + val provenanceTarget = provenance.target.single { + it.getExtensionByUrl(HealthConnectContract.GROVE_RETRACTION_TARGET_ROLE) + .value.primitiveValue() == HealthConnectRetractionTargetRole.SPECIMEN.code + } + assertThat(provenanceTarget.type).isEqualTo("Specimen") + assertThat(provenanceTarget.identifier.system).isEqualTo(specimenTarget.identifier.system) + assertThat(provenanceTarget.identifier.value).isEqualTo(specimenTarget.identifier.value) + assertThat(provenanceTarget.identifier.hasGroveRole(GroveIdentifierRole.SOURCE_OUTPUT)).isTrue() + } + + @Test + fun `missing deletion journal state is durably quarantined`() = runTest { + val journal = InMemoryJournal() + val coordinator = HealthConnectExportCoordinator(converter, journal, RecordingSink()) + + coordinator.delete("StepsRecord", "unknown", conversionTime) + coordinator.delete("StepsRecord", "unknown", conversionTime.plusSeconds(1)) + + assertThat(journal.unmatchedDeletions).containsExactly( + HealthConnectUnmatchedDeletion( + synchronizationScope.repositoryScopeKey, + synchronizationScope.projectionScopeKey, + "StepsRecord", + "unknown", + conversionTime, + ), + ) + } + + @Test + fun `first-sight zero-output record is durably active without a wire event`() = runTest { + val journal = InMemoryJournal() + val sink = RecordingSink() + val coordinator = HealthConnectExportCoordinator(converter, journal, sink) + val empty = heartRateRecord(emptyList()) + + coordinator.upsert(empty, conversionTime) + + assertThat(sink.batches).isEmpty() + assertThat(journal.rejectedRecords).isEmpty() + val entry = requireNotNull(journal.entry("HeartRateRecord", "heart-record")) + assertThat(entry.state).isEqualTo(HealthConnectExportState.ACTIVE) + assertThat(entry.observations).isEmpty() + } + + @Test + fun `zero-output update emits a separate retraction and retains local zero-output state`() = runTest { + val journal = InMemoryJournal() + val sink = RecordingSink() + val coordinator = HealthConnectExportCoordinator(converter, journal, sink) + coordinator.upsert(heartRateRecord(twoHeartRateSamples()), conversionTime) + + coordinator.upsert(heartRateRecord(emptyList()), conversionTime.plusSeconds(1)) + + assertThat(sink.batches).hasSize(2) + assertThat(sink.batches.last().operation).isEqualTo(HealthConnectExportOperation.RETRACTION) + assertThat(sink.batches.last().bundle.observations()).isEmpty() + assertThat(sink.batches.last().bundle.entry.map { it.resource.fhirType() }) + .containsExactly("Provenance") + assertThat(requireNotNull(journal.entry("HeartRateRecord", "heart-record")).state) + .isEqualTo(HealthConnectExportState.ACTIVE) + assertThat(requireNotNull(journal.entry("HeartRateRecord", "heart-record")).observations).isEmpty() + assertThat(journal.rejectedRecords).isEmpty() + } + + @Test + fun `zero-output update refreshes local source timestamp without a wire event`() = runTest { + val journal = InMemoryJournal() + val sink = RecordingSink() + val coordinator = HealthConnectExportCoordinator(converter, journal, sink) + coordinator.upsert(heartRateRecord(emptyList()), conversionTime) + + coordinator.upsert( + heartRateRecord(emptyList(), lastModified = Instant.parse("2026-08-19T17:30:02Z")), + conversionTime.plusSeconds(1), + ) + + assertThat(sink.batches).isEmpty() + assertThat(requireNotNull(journal.entry("HeartRateRecord", "heart-record")).sourceLastModified) + .isEqualTo(Instant.parse("2026-08-19T17:30:02Z")) + assertThat(requireNotNull(journal.entry("HeartRateRecord", "heart-record")).lastEventSequence).isNull() + } + + @Test + fun `a new projection baseline can reactivate an equal-version invalidation marker`() = runTest { + val journal = InMemoryJournal() + val sink = RecordingSink() + val firstCoordinator = HealthConnectExportCoordinator(converter, journal, sink) + val record = stepRecord("reactivated") + firstCoordinator.upsert(record, conversionTime) + firstCoordinator.delete("StepsRecord", "reactivated", conversionTime.plusSeconds(1)) + val nextScope = testSynchronizationScope( + repositoryScope = EXAMPLE_REPOSITORY_SCOPE, + configurationFingerprint = "expanded-filter-v2", + ) + val nextCoordinator = HealthConnectExportCoordinator( + HealthConnectConverter(fhirContext(), nextScope), + journal, + sink, + ) + + nextCoordinator.reconcile("StepsRecord", { conversionTime.plusSeconds(2) }) { listOf(record) } + + assertThat(sink.batches.last().operation).isEqualTo(HealthConnectExportOperation.ACTIVE) + assertThat(requireNotNull(journal.entry("StepsRecord", "reactivated")).state) + .isEqualTo(HealthConnectExportState.ACTIVE) + assertThat(requireNotNull(journal.entry("StepsRecord", "reactivated")).projectionScopeKey) + .isEqualTo(nextScope.projectionScopeKey) + } + + @Test + fun `adapter contract version changes projection scope and baseline re-encodes unchanged source`() = runTest { + val journal = InMemoryJournal() + val sink = RecordingSink() + val record = stepRecord("contract-upgrade") + val initialCoordinator = HealthConnectExportCoordinator(converter, journal, sink) + initialCoordinator.reconcile("StepsRecord", { conversionTime }) { listOf(record) } + val upgradedScope = testSynchronizationScope( + repositoryScope = EXAMPLE_REPOSITORY_SCOPE, + configurationFingerprint = "all-supported-records-v1", + conversionContractVersion = "health-connect-r4-v2-test", + ) + val upgradedCoordinator = HealthConnectExportCoordinator( + HealthConnectConverter(fhirContext(), upgradedScope), + journal, + sink, + ) + + upgradedCoordinator.reconcile("StepsRecord", { conversionTime.plusSeconds(1) }) { listOf(record) } + + assertThat(upgradedScope.repositoryScopeKey).isEqualTo(synchronizationScope.repositoryScopeKey) + assertThat(upgradedScope.projectionScopeKey).isNotEqualTo(synchronizationScope.projectionScopeKey) + assertThat(sink.batches).hasSize(3) + assertThat(sink.batches.map { it.eventSequence.value }).containsExactly("1", "2", "3").inOrder() + assertThat(sink.batches.map { it.operation }).containsExactly( + HealthConnectExportOperation.ACTIVE, + HealthConnectExportOperation.RETRACTION, + HealthConnectExportOperation.ACTIVE, + ).inOrder() + val migrated = requireNotNull(journal.entry("StepsRecord", "contract-upgrade")) + assertThat(migrated.projectionScopeKey).isEqualTo(upgradedScope.projectionScopeKey) + assertThat(migrated.conversionContractVersion).isEqualTo("health-connect-r4-v2-test") + } + + @Test + @Suppress("LongMethod") + fun `contract migration retires an old source identity before publishing the new identity`() = runTest { + val record = stepRecord("identity-upgrade") + val oldScope = testSynchronizationScope( + repositoryScope = "86f286c0-ec67-40d9-901d-264f2e1c627e", + configurationFingerprint = "all-supported-records-v1", + ) + val oldConversion = HealthConnectConverter(fhirContext(), oldScope) + .convert(record, conversionTime, EventSequence("99")) + val journal = InMemoryJournal(startingSequence = 100).apply { + storeLocal( + HealthConnectExportJournalEntry( + repositoryScopeKey = synchronizationScope.repositoryScopeKey, + projectionScopeKey = synchronizationScope.projectionScopeKey, + recordType = oldConversion.sourceRecordType, + healthConnectId = record.metadata.id, + dataOriginPackage = record.metadata.dataOrigin.packageName, + sourceLastModified = oldConversion.sourceLastModified, + conversionContractVersion = oldConversion.conversionContractVersion, + sourceRecordIdentifier = oldConversion.sourceRecordIdentifier.copy(), + observations = oldConversion.observations.map { it.copy() }, + bundle = oldConversion.bundle.copy(), + destinationReferences = oldConversion.outputIdentifiers.associate { + it.key() to "Resource/old-${it.value}" + }, + lastEventSequence = EventSequence("99"), + ), + ) + } + val sink = RecordingSink() + val upgradedScope = testSynchronizationScope( + repositoryScope = EXAMPLE_REPOSITORY_SCOPE, + configurationFingerprint = "all-supported-records-v1", + conversionContractVersion = "health-connect-r4-v2-test", + ) + val coordinator = HealthConnectExportCoordinator( + HealthConnectConverter(fhirContext(), upgradedScope), + journal, + sink, + ) + + coordinator.reconcile("StepsRecord", { conversionTime.plusSeconds(1) }) { listOf(record) } + + assertThat(sink.batches.map { it.operation }).containsExactly( + HealthConnectExportOperation.RETRACTION, + HealthConnectExportOperation.ACTIVE, + ).inOrder() + assertThat(sink.batches.map { it.eventSequence.value }).containsExactly("100", "101").inOrder() + assertThat(sink.batches.first().sourceRecordIdentifier.value) + .isEqualTo(oldConversion.sourceRecordIdentifier.value) + assertThat(sink.batches.first().bundle.observations()).isEmpty() + assertThat( + (sink.batches.first().bundle.entry.single().resource as Provenance) + .entity.single().what.identifier.value, + ).isEqualTo(oldConversion.sourceRecordIdentifier.value) + val migrated = requireNotNull(journal.entry("StepsRecord", "identity-upgrade")) + assertThat(migrated.sourceRecordIdentifier.value) + .isNotEqualTo(oldConversion.sourceRecordIdentifier.value) + assertThat(migrated.state).isEqualTo(HealthConnectExportState.ACTIVE) + } + + @Test + fun `projection A to B to A retracts and reactivates an unchanged source`() = runTest { + val journal = InMemoryJournal() + val sink = RecordingSink() + val record = stepRecord("projection-switch") + val firstA = HealthConnectExportCoordinator(converter, journal, sink) + val scopeB = testSynchronizationScope(EXAMPLE_REPOSITORY_SCOPE, "filter-b") + val projectionB = HealthConnectExportCoordinator(HealthConnectConverter(fhirContext(), scopeB), journal, sink) + val secondA = HealthConnectExportCoordinator(converter, journal, sink) + + firstA.reconcile("StepsRecord", { conversionTime }) { listOf(record) } + projectionB.reconcile("StepsRecord", { conversionTime.plusSeconds(1) }) { emptyList() } + secondA.reconcile("StepsRecord", { conversionTime.plusSeconds(2) }) { listOf(record) } + + assertThat(sink.batches.map { it.operation }).containsExactly( + HealthConnectExportOperation.ACTIVE, + HealthConnectExportOperation.RETRACTION, + HealthConnectExportOperation.ACTIVE, + ).inOrder() + val entry = requireNotNull(journal.entry("StepsRecord", "projection-switch")) + assertThat(entry.state).isEqualTo(HealthConnectExportState.ACTIVE) + assertThat(entry.projectionScopeKey).isEqualTo(synchronizationScope.projectionScopeKey) + } +} diff --git a/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectExportCoordinatorTestSupport.kt b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectExportCoordinatorTestSupport.kt new file mode 100644 index 00000000..26e9f363 --- /dev/null +++ b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectExportCoordinatorTestSupport.kt @@ -0,0 +1,900 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord +import androidx.health.connect.client.records.BasalBodyTemperatureRecord +import androidx.health.connect.client.records.BloodGlucoseRecord +import androidx.health.connect.client.records.BloodPressureRecord +import androidx.health.connect.client.records.BodyTemperatureMeasurementLocation +import androidx.health.connect.client.records.BodyTemperatureRecord +import androidx.health.connect.client.records.DistanceRecord +import androidx.health.connect.client.records.HeartRateRecord +import androidx.health.connect.client.records.HeightRecord +import androidx.health.connect.client.records.MealType +import androidx.health.connect.client.records.OxygenSaturationRecord +import androidx.health.connect.client.records.Record +import androidx.health.connect.client.records.RespiratoryRateRecord +import androidx.health.connect.client.records.SleepSessionRecord +import androidx.health.connect.client.records.StepsRecord +import androidx.health.connect.client.records.WeightRecord +import androidx.health.connect.client.records.metadata.DataOrigin +import androidx.health.connect.client.records.metadata.Device +import androidx.health.connect.client.records.metadata.Metadata +import androidx.health.connect.client.testing.populatedWithTestValues +import androidx.health.connect.client.units.BloodGlucose +import androidx.health.connect.client.units.Energy +import androidx.health.connect.client.units.Length +import androidx.health.connect.client.units.Mass +import androidx.health.connect.client.units.Percentage +import androidx.health.connect.client.units.Pressure +import androidx.health.connect.client.units.Temperature +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.hl7.fhir.r4.model.Attachment +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.CodeableConcept +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.DocumentReference +import org.hl7.fhir.r4.model.Enumerations +import org.hl7.fhir.r4.model.Identifier +import org.hl7.fhir.r4.model.InstantType +import org.hl7.fhir.r4.model.Observation +import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.Provenance +import org.hl7.fhir.r4.model.ResearchStudy +import java.math.BigInteger +import java.time.Instant +import java.time.ZoneOffset +import org.hl7.fhir.r4.model.Device as FhirDevice + +abstract class HealthConnectExportCoordinatorTestSupport { + protected data class SemanticVectorRecord( + val id: String, + val profile: String, + val record: Record, + ) + + protected val conversionTime = Instant.parse("2026-08-19T18:00:00Z") + protected val semanticConversionTime = Instant.parse("2026-08-21T18:00:00Z") + protected val watch = Device(Device.TYPE_WATCH, "Example Device Company", "Study Watch") + protected val synchronizationScope = testSynchronizationScope( + repositoryScope = EXAMPLE_REPOSITORY_SCOPE, + configurationFingerprint = "all-supported-records-v1", + ) + protected val converter = HealthConnectConverter(fhirContext(), synchronizationScope) + + @Suppress("LongMethod") + protected fun semanticVectorRecords(): List { + val offset = ZoneOffset.ofHours(-7) + val metadata = { id: String -> + metadata( + Metadata.autoRecorded(watch), + "semantic-$id", + Instant.parse("2026-08-20T17:30:01Z"), + ) + } + val sleep = SleepSessionRecord( + startTime = Instant.parse("2026-08-20T06:00:00Z"), + startZoneOffset = offset, + endTime = Instant.parse("2026-08-20T13:30:00Z"), + endZoneOffset = offset, + title = null, + notes = null, + stages = listOf( + SleepSessionRecord.Stage( + Instant.parse("2026-08-20T07:10:00Z"), + Instant.parse("2026-08-20T07:42:00Z"), + SleepSessionRecord.STAGE_TYPE_LIGHT, + ), + ), + metadata = metadata("sleep"), + ) + return listOf( + SemanticVectorRecord( + "active-energy", + HealthConnectContract.MOBILE_ACTIVE_ENERGY_PROFILE, + ActiveCaloriesBurnedRecord( + Instant.parse("2026-08-20T15:00:00Z"), + offset, + Instant.parse("2026-08-20T16:00:00Z"), + offset, + Energy.kilocalories(312.5), + metadata("active-energy"), + ), + ), + SemanticVectorRecord( + "basal-body-temperature", + HealthConnectContract.MOBILE_BASAL_BODY_TEMPERATURE_PROFILE, + BasalBodyTemperatureRecord( + Instant.parse("2026-08-20T13:45:00Z"), + offset, + metadata("basal-body-temperature"), + Temperature.celsius(36.52), + BodyTemperatureMeasurementLocation.MEASUREMENT_LOCATION_MOUTH, + ), + ), + SemanticVectorRecord( + "blood-pressure", + HealthConnectContract.MOBILE_BLOOD_PRESSURE_PROFILE, + BloodPressureRecord( + Instant.parse("2026-08-20T15:10:00Z"), + offset, + metadata("blood-pressure"), + Pressure.millimetersOfMercury(118.0), + Pressure.millimetersOfMercury(76.0), + BloodPressureRecord.BODY_POSITION_SITTING_DOWN, + BloodPressureRecord.MEASUREMENT_LOCATION_LEFT_UPPER_ARM, + ), + ), + SemanticVectorRecord( + "body-height", + HealthConnectContract.MOBILE_BODY_HEIGHT_PROFILE, + HeightRecord( + Instant.parse("2026-08-20T15:15:00Z"), + offset, + Length.meters(1.712), + metadata("body-height"), + ), + ), + SemanticVectorRecord( + "body-temperature", + HealthConnectContract.MOBILE_BODY_TEMPERATURE_PROFILE, + BodyTemperatureRecord( + Instant.parse("2026-08-20T15:20:00Z"), + offset, + metadata("body-temperature"), + Temperature.celsius(37.1), + BodyTemperatureMeasurementLocation.MEASUREMENT_LOCATION_EAR, + ), + ), + SemanticVectorRecord( + "body-weight", + HealthConnectContract.MOBILE_BODY_WEIGHT_PROFILE, + WeightRecord( + Instant.parse("2026-08-20T15:25:00Z"), + offset, + Mass.kilograms(68.4), + metadata("body-weight"), + ), + ), + SemanticVectorRecord( + "distance", + HealthConnectContract.MOBILE_DISTANCE_PROFILE, + DistanceRecord( + Instant.parse("2026-08-20T14:00:00Z"), + offset, + Instant.parse("2026-08-20T14:30:00Z"), + offset, + Length.meters(4820.5), + metadata("distance"), + ), + ), + SemanticVectorRecord( + "heart-rate", + HealthConnectContract.MOBILE_HEART_RATE_PROFILE, + HeartRateRecord( + startTime = Instant.parse("2026-08-20T15:29:00Z"), + startZoneOffset = offset, + endTime = Instant.parse("2026-08-20T15:31:00Z"), + endZoneOffset = offset, + samples = listOf( + HeartRateRecord.Sample(Instant.parse("2026-08-20T15:30:00.251Z"), 72), + ), + metadata = metadata("heart-rate"), + ), + ), + SemanticVectorRecord( + "oxygen-saturation", + HealthConnectContract.MOBILE_OXYGEN_SATURATION_PROFILE, + OxygenSaturationRecord( + Instant.parse("2026-08-20T15:35:00Z"), + offset, + Percentage(98.0), + metadata("oxygen-saturation"), + ), + ), + SemanticVectorRecord( + "respiratory-rate", + HealthConnectContract.MOBILE_RESPIRATORY_RATE_PROFILE, + RespiratoryRateRecord( + Instant.parse("2026-08-20T15:40:00Z"), + offset, + 15.0, + metadata("respiratory-rate"), + ), + ), + SemanticVectorRecord( + "sleep-duration", + HealthConnectContract.MOBILE_SLEEP_DURATION_PROFILE, + sleep, + ), + SemanticVectorRecord( + "sleep-stage", + HealthConnectContract.MOBILE_SLEEP_STAGE_PROFILE, + sleep, + ), + SemanticVectorRecord( + "step-count", + HealthConnectContract.MOBILE_STEP_COUNT_PROFILE, + StepsRecord( + Instant.parse("2026-08-20T15:00:00Z"), + offset, + Instant.parse("2026-08-20T16:00:00Z"), + offset, + 1042, + metadata("step-count"), + ), + ), + ) + } + + @Suppress("LongMethod") + protected fun completeConformanceRecords(): List> { + val start = Instant.parse("2026-08-19T08:00:00Z") + val instant = Instant.parse("2026-08-19T16:00:00Z") + val end = instant.plusSeconds(3_600) + val auto = { id: String -> metadata(Metadata.autoRecorded(watch), id) } + val glucose = { name: String, specimen: Int -> + name to BloodGlucoseRecord( + time = instant, + zoneOffset = ZoneOffset.UTC, + metadata = auto("fixture-$name"), + level = BloodGlucose.milligramsPerDeciliter(95.5), + specimenSource = specimen, + mealType = MealType.MEAL_TYPE_BREAKFAST, + relationToMeal = BloodGlucoseRecord.RELATION_TO_MEAL_BEFORE_MEAL, + ) + } + val stageTypes = listOf( + SleepSessionRecord.STAGE_TYPE_UNKNOWN, + SleepSessionRecord.STAGE_TYPE_AWAKE, + SleepSessionRecord.STAGE_TYPE_SLEEPING, + SleepSessionRecord.STAGE_TYPE_OUT_OF_BED, + SleepSessionRecord.STAGE_TYPE_LIGHT, + SleepSessionRecord.STAGE_TYPE_DEEP, + SleepSessionRecord.STAGE_TYPE_REM, + SleepSessionRecord.STAGE_TYPE_AWAKE_IN_BED, + ) + val sleepStages = stageTypes.mapIndexed { index, stage -> + SleepSessionRecord.Stage( + start.plusSeconds(index * 3_600L), + start.plusSeconds((index + 1) * 3_600L), + stage, + ) + } + return listOf( + "active-energy" to ActiveCaloriesBurnedRecord( + instant, + ZoneOffset.ofHours(-7), + end, + ZoneOffset.ofHours(-7), + Energy.kilocalories(412.5), + auto("fixture-active-energy"), + ), + "basal-body-temperature" to BasalBodyTemperatureRecord( + instant, + ZoneOffset.UTC, + auto("fixture-basal-body-temperature"), + Temperature.celsius(36.4), + BodyTemperatureMeasurementLocation.MEASUREMENT_LOCATION_MOUTH, + ), + glucose("glucose-whole-blood", BloodGlucoseRecord.SPECIMEN_SOURCE_WHOLE_BLOOD), + glucose("glucose-capillary", BloodGlucoseRecord.SPECIMEN_SOURCE_CAPILLARY_BLOOD), + glucose("glucose-plasma", BloodGlucoseRecord.SPECIMEN_SOURCE_PLASMA), + glucose("glucose-serum", BloodGlucoseRecord.SPECIMEN_SOURCE_SERUM), + glucose("glucose-interstitial", BloodGlucoseRecord.SPECIMEN_SOURCE_INTERSTITIAL_FLUID), + "blood-pressure" to BloodPressureRecord( + instant, + ZoneOffset.UTC, + auto("fixture-blood-pressure"), + Pressure.millimetersOfMercury(120.0), + Pressure.millimetersOfMercury(80.0), + BloodPressureRecord.BODY_POSITION_SITTING_DOWN, + BloodPressureRecord.MEASUREMENT_LOCATION_LEFT_UPPER_ARM, + ), + "body-temperature" to BodyTemperatureRecord( + instant, + ZoneOffset.UTC, + auto("fixture-body-temperature"), + Temperature.celsius(37.1), + BodyTemperatureMeasurementLocation.MEASUREMENT_LOCATION_EAR, + ), + "distance" to DistanceRecord( + instant, + ZoneOffset.UTC, + end, + ZoneOffset.UTC, + Length.kilometers(3.25), + auto("fixture-distance"), + ), + "heart-rate" to heartRateRecord(twoHeartRateSamples()), + "height" to HeightRecord( + instant, + ZoneOffset.UTC, + Length.meters(1.82), + auto("fixture-height"), + ), + "oxygen-saturation" to OxygenSaturationRecord( + instant, + ZoneOffset.UTC, + Percentage(98.2), + auto("fixture-oxygen-saturation"), + ), + "respiratory-rate" to RespiratoryRateRecord( + instant, + ZoneOffset.UTC, + 14.5, + auto("fixture-respiratory-rate"), + ), + "sleep" to SleepSessionRecord( + startTime = start, + startZoneOffset = ZoneOffset.ofHours(-7), + endTime = start.plusSeconds(8 * 3_600L), + endZoneOffset = ZoneOffset.ofHours(-7), + title = "Night sleep", + notes = "Participant-reported note", + stages = sleepStages, + metadata = auto("fixture-sleep"), + ), + "steps" to stepRecord("fixture-step"), + "weight" to weightRecord("fixture-weight"), + ) + } + + protected fun documentJournalEntry(healthConnectId: String): HealthConnectExportJournalEntry { + val record = stepRecord(healthConnectId) + val conversion = converter.convert(record, conversionTime, EventSequence("1")) + val sourceIdentifier = conversion.sourceRecordIdentifier + val bundle = conversion.bundle + val observationEntry = bundle.entry.single { it.resource is Observation } + val observation = observationEntry.resource as Observation + val outputIdentifier = observationIdentity(observation) + val artifactIdentifier = testIdentityKey().identifier( + GroveOpaqueIdentityKind.SOURCE_ARTIFACT, + "health-connect", + "StepsRecord", + synchronizationScope.repositoryScope.system, + synchronizationScope.repositoryScope.value, + healthConnectId, + "application/octet-stream", + "0", + ) + observationEntry.resource = DocumentReference().apply { + meta.addProfile(SENSOR_RECORDING_DOCUMENT_PROFILE) + addIdentifier(sourceIdentifier.copy()) + addIdentifier(outputIdentifier.copy()) + addIdentifier(artifactIdentifier) + status = Enumerations.DocumentReferenceStatus.CURRENT + subject = observation.subject.copy() + dateElement = InstantType(conversionTime.toString()) + addContent().attachment = Attachment() + .setContentType("application/octet-stream") + .setUrl("https://example.org/recording.bin") + } + bundle.entry.mapNotNull { it.resource as? Provenance }.single().target.single().type = + "DocumentReference" + bundle.entry.removeAll { entry -> + entry.resource is ResearchStudy || + (entry.resource as? FhirDevice)?.meta?.profile?.any { + it.value == HealthConnectContract.MOBILE_RECORDING_DEVICE_PROFILE + } == true + } + return HealthConnectExportJournalEntry( + repositoryScopeKey = synchronizationScope.repositoryScopeKey, + projectionScopeKey = synchronizationScope.projectionScopeKey, + recordType = conversion.sourceRecordType, + healthConnectId = healthConnectId, + dataOriginPackage = record.metadata.dataOrigin.packageName, + sourceLastModified = conversion.sourceLastModified, + conversionContractVersion = conversion.conversionContractVersion, + sourceRecordIdentifier = sourceIdentifier, + observations = emptyList(), + bundle = bundle, + destinationReferences = mapOf(outputIdentifier.key() to "DocumentReference/$healthConnectId"), + lastEventSequence = EventSequence("1"), + ) + } + + protected fun stepRecord(id: String, count: Long = 1042) = StepsRecord( + startTime = Instant.parse("2026-08-19T16:00:00Z"), + startZoneOffset = ZoneOffset.ofHours(-7), + endTime = Instant.parse("2026-08-19T17:00:00Z"), + endZoneOffset = ZoneOffset.ofHours(-7), + count = count, + metadata = metadata(Metadata.autoRecorded(watch), id), + ) + + protected fun weightRecord(id: String) = WeightRecord( + time = Instant.parse("2026-08-19T15:15:00Z"), + zoneOffset = ZoneOffset.ofHours(-7), + weight = Mass.kilograms(68.4), + metadata = metadata(Metadata.manualEntry(), id), + ) + + protected fun heartRateRecord( + samples: List, + lastModified: Instant = Instant.parse("2026-08-19T17:30:01Z"), + device: Device = watch, + ) = HeartRateRecord( + startTime = Instant.parse("2026-08-19T17:30:00Z"), + startZoneOffset = ZoneOffset.ofHours(-7), + endTime = Instant.parse("2026-08-19T17:31:00Z"), + endZoneOffset = ZoneOffset.ofHours(-7), + samples = samples, + metadata = metadata(Metadata.autoRecorded(device), "heart-record", lastModified), + ) + + protected fun twoHeartRateSamples() = listOf( + HeartRateRecord.Sample(Instant.parse("2026-08-19T17:30:15Z"), 72), + HeartRateRecord.Sample(Instant.parse("2026-08-19T17:30:45Z"), 75), + ) + + protected fun metadata( + metadata: Metadata, + id: String, + lastModified: Instant = Instant.parse("2026-08-19T17:30:01Z"), + ): Metadata = metadata.populatedWithTestValues( + id = id, + dataOrigin = DataOrigin("com.example.source"), + lastModifiedTime = lastModified, + ) + + protected class InMemoryJournal(startingSequence: Long = 1L) : HealthConnectExportJournal { + private val values = mutableMapOf, HealthConnectExportJournalEntry>() + private val pending = mutableMapOf, HealthConnectPendingExport>() + private var nextSequence = BigInteger.valueOf(startingSequence) + private var nextFence = BigInteger.ONE + private val unmatchedDeletionValues = mutableMapOf, HealthConnectUnmatchedDeletion>() + private val rejectedRecordValues = mutableMapOf, HealthConnectRejectedRecord>() + private val stateMutex = Mutex() + private val typeMutexes = mutableMapOf, Mutex>() + private val sourceMutexes = mutableMapOf, Mutex>() + private val activeReconciliationFences = mutableMapOf, HealthConnectJournalFence>() + private val activeSourceFences = mutableMapOf, HealthConnectJournalFence>() + val unmatchedDeletions: List + get() = unmatchedDeletionValues.values.toList() + val rejectedRecords: List + get() = rejectedRecordValues.values.toList() + var failCompleteNext = false + var loseSourceLeaseBeforeNextComplete = false + + init { + require(startingSequence > 0) { "The first event sequence must be positive." } + } + + suspend fun entry(recordType: String, healthConnectId: String) = + stateMutex.withLock { + values.values.singleOrNull { + it.recordType == recordType && it.healthConnectId == healthConnectId + }?.copy() + } + + suspend fun entries(recordType: String) = stateMutex.withLock { + values.values.filter { it.recordType == recordType }.map { it.copy() } + } + + suspend fun pending(recordType: String, healthConnectId: String) = + stateMutex.withLock { + pending.values.singleOrNull { + it.recordType == recordType && it.healthConnectId == healthConnectId + }?.copy() + } + + suspend fun storeLocal(entry: HealthConnectExportJournalEntry) = stateMutex.withLock { + values[entry.sourceKey()] = entry.copy() + } + + override suspend fun withSourceTransition( + repositoryScopeKey: ScopeKey, + recordType: String, + healthConnectId: String, + reconciliationLease: HealthConnectReconciliationLease?, + block: suspend (HealthConnectSourceTransitionLease) -> T, + ): T { + val typeKey = repositoryScopeKey to recordType + val sourceKey = Triple(repositoryScopeKey, recordType, healthConnectId) + val locks = stateMutex.withLock { + typeMutexes.getOrPut(typeKey) { Mutex() } to sourceMutexes.getOrPut(sourceKey) { Mutex() } + } + return if (reconciliationLease == null) { + withSuspendingLock(locks.first) { + runSourceTransition(sourceKey, null, locks.second, block) + } + } else { + stateMutex.withLock { + requireReconciliationLeaseLocked(reconciliationLease, typeKey) + } + runSourceTransition(sourceKey, reconciliationLease, locks.second, block) + } + } + + override suspend fun withReconciliationLease( + repositoryScopeKey: ScopeKey, + recordType: String, + block: suspend (HealthConnectReconciliationLease) -> T, + ): T { + val typeKey = repositoryScopeKey to recordType + val mutex = stateMutex.withLock { typeMutexes.getOrPut(typeKey) { Mutex() } } + return withSuspendingLock(mutex) { + val lease = stateMutex.withLock { + HealthConnectReconciliationLease( + repositoryScopeKey, + recordType, + allocateFenceLocked(), + ).also { activeReconciliationFences[typeKey] = it.fence } + } + try { + block(lease) + } finally { + stateMutex.withLock { + if (activeReconciliationFences[typeKey] == lease.fence) { + activeReconciliationFences.remove(typeKey) + } + } + } + } + } + + override suspend fun entry(lease: HealthConnectSourceTransitionLease) = stateMutex.withLock { + requireSourceLeaseLocked(lease) + values[lease.sourceKey()]?.copy() + } + + override suspend fun entries(lease: HealthConnectReconciliationLease) = stateMutex.withLock { + requireReconciliationLeaseLocked(lease, lease.typeKey()) + values.values.filter { + it.repositoryScopeKey == lease.repositoryScopeKey && it.recordType == lease.recordType + }.map { it.copy() } + } + + override suspend fun pending(lease: HealthConnectSourceTransitionLease) = stateMutex.withLock { + requireSourceLeaseLocked(lease) + pending[lease.sourceKey()]?.copy() + } + + override suspend fun pendingForType(lease: HealthConnectReconciliationLease) = stateMutex.withLock { + requireReconciliationLeaseLocked(lease, lease.typeKey()) + pending.values.filter { + it.repositoryScopeKey == lease.repositoryScopeKey && it.recordType == lease.recordType + }.map { it.copy() } + } + + override suspend fun stage( + lease: HealthConnectSourceTransitionLease, + expectedRevision: HealthConnectJournalRevision?, + buildDraft: (eventSequence: EventSequence) -> HealthConnectPendingExportDraft, + ): HealthConnectPendingExport = stateMutex.withLock { + requireSourceLeaseLocked(lease) + val key = lease.sourceKey() + pending[key]?.let { return@withLock it.copy() } + check(values[key]?.revision == expectedRevision) { "The journal base revision changed before staging." } + val eventSequence = EventSequence(nextSequence.toString()) + val draft = buildDraft(eventSequence) + check(draft.repositoryScopeKey == lease.repositoryScopeKey) + check(draft.recordType == lease.recordType) + check(draft.healthConnectId == lease.healthConnectId) + val stored = HealthConnectPendingExport( + eventSequence = eventSequence, + baseRevision = expectedRevision, + repositoryScopeKey = draft.repositoryScopeKey, + projectionScopeKey = draft.projectionScopeKey, + operation = draft.operation, + recordType = draft.recordType, + healthConnectId = draft.healthConnectId, + sourceRecordIdentifier = draft.sourceRecordIdentifier.copy(), + sourceVersion = draft.sourceVersion, + bundle = draft.bundle.copy(), + bundleJson = draft.bundleJson, + payloadSha256 = draft.payloadSha256, + retractedTargets = draft.retractedTargets, + nextEntry = draft.nextEntry, + ) + nextSequence += BigInteger.ONE + pending[key] = stored + stored.copy() + } + + override suspend fun complete( + lease: HealthConnectSourceTransitionLease, + pending: HealthConnectPendingExport, + entry: HealthConnectExportJournalEntry, + ) = stateMutex.withLock { + requireSourceLeaseLocked(lease) + val key = lease.sourceKey() + check(pending.sourceKey() == key && entry.sourceKey() == key) + val expectedEntry = pending.acknowledgedEntry(entry.destinationReferences) + check(expectedEntry.revision == entry.revision) { + "Completion must be derived from the exact validated pending payload." + } + if (loseSourceLeaseBeforeNextComplete) { + loseSourceLeaseBeforeNextComplete = false + activeSourceFences[key] = allocateFenceLocked() + error("The source-transition lease was lost before completion.") + } + if (failCompleteNext) { + failCompleteNext = false + error("Journal transaction did not commit.") + } + val storedPending = this.pending[key] + if (storedPending == null) { + check(values[key]?.revision == entry.revision) { + "Only an exact-event, exact-revision repeated completion is idempotent." + } + return@withLock + } + check(storedPending.sameExactEvent(pending)) { "Completion did not name the exact staged event." } + check(values[key]?.revision == storedPending.baseRevision) { + "The journal base revision changed before completion." + } + values[key] = entry + this.pending.remove(key) + } + + override suspend fun storeLocal( + lease: HealthConnectSourceTransitionLease, + expectedRevision: HealthConnectJournalRevision?, + entry: HealthConnectExportJournalEntry, + ) = stateMutex.withLock { + requireSourceLeaseLocked(lease) + val key = lease.sourceKey() + check(entry.sourceKey() == key) + check(entry.outputIdentifiers.isEmpty()) + check(pending[key] == null) + check(values[key]?.revision == expectedRevision) { "The journal base revision changed before local storage." } + values[key] = entry + } + + override suspend fun recordUnmatchedDeletion( + lease: HealthConnectSourceTransitionLease, + deletion: HealthConnectUnmatchedDeletion, + ) = stateMutex.withLock { + requireSourceLeaseLocked(lease) + check(deletion.sourceKey() == lease.sourceKey()) + unmatchedDeletionValues.putIfAbsent( + Triple(deletion.repositoryScopeKey, deletion.recordType, deletion.healthConnectId), + deletion, + ) + Unit + } + + override suspend fun recordRejectedRecord( + lease: HealthConnectSourceTransitionLease, + rejected: HealthConnectRejectedRecord, + ) = stateMutex.withLock { + requireSourceLeaseLocked(lease) + check(rejected.sourceKey() == lease.sourceKey()) + rejectedRecordValues[Triple(rejected.repositoryScopeKey, rejected.recordType, rejected.healthConnectId)] = rejected + } + + private suspend fun runSourceTransition( + sourceKey: Triple, + reconciliationLease: HealthConnectReconciliationLease?, + mutex: Mutex, + block: suspend (HealthConnectSourceTransitionLease) -> T, + ): T = withSuspendingLock(mutex) { + val lease = stateMutex.withLock { + reconciliationLease?.let { requireReconciliationLeaseLocked(it, sourceKey.first to sourceKey.second) } + HealthConnectSourceTransitionLease( + sourceKey.first, + sourceKey.second, + sourceKey.third, + allocateFenceLocked(), + reconciliationLease?.fence, + ).also { activeSourceFences[sourceKey] = it.fence } + } + try { + block(lease) + } finally { + stateMutex.withLock { + if (activeSourceFences[sourceKey] == lease.fence) activeSourceFences.remove(sourceKey) + } + } + } + + private suspend fun withSuspendingLock(mutex: Mutex, block: suspend () -> T): T { + mutex.lock() + return try { + block() + } finally { + mutex.unlock() + } + } + + private fun requireSourceLeaseLocked(lease: HealthConnectSourceTransitionLease) { + check(activeSourceFences[lease.sourceKey()] == lease.fence) { + "The source-transition fence is stale or no longer owned." + } + lease.reconciliationFence?.let { fence -> + check(activeReconciliationFences[lease.typeKey()] == fence) { + "The parent reconciliation fence is stale or no longer owned." + } + } + } + + private fun requireReconciliationLeaseLocked( + lease: HealthConnectReconciliationLease, + expectedTypeKey: Pair, + ) { + check(lease.typeKey() == expectedTypeKey) + check(activeReconciliationFences[expectedTypeKey] == lease.fence) { + "The reconciliation fence is stale or no longer owned." + } + } + + private fun allocateFenceLocked(): HealthConnectJournalFence = + HealthConnectJournalFence(nextFence.toString()).also { nextFence += BigInteger.ONE } + + private fun HealthConnectSourceTransitionLease.sourceKey() = + Triple(repositoryScopeKey, recordType, healthConnectId) + + private fun HealthConnectSourceTransitionLease.typeKey() = repositoryScopeKey to recordType + + private fun HealthConnectReconciliationLease.typeKey() = repositoryScopeKey to recordType + + private fun HealthConnectPendingExport.sourceKey() = + Triple(repositoryScopeKey, recordType, healthConnectId) + + private fun HealthConnectExportJournalEntry.sourceKey() = + Triple(repositoryScopeKey, recordType, healthConnectId) + + private fun HealthConnectUnmatchedDeletion.sourceKey() = + Triple(repositoryScopeKey, recordType, healthConnectId) + + private fun HealthConnectRejectedRecord.sourceKey() = + Triple(repositoryScopeKey, recordType, healthConnectId) + + private fun HealthConnectPendingExport.sameExactEvent(other: HealthConnectPendingExport): Boolean = + eventSequence == other.eventSequence && + baseRevision == other.baseRevision && + repositoryScopeKey == other.repositoryScopeKey && + projectionScopeKey == other.projectionScopeKey && + operation == other.operation && + recordType == other.recordType && + healthConnectId == other.healthConnectId && + sourceVersion == other.sourceVersion && + bundleJson == other.bundleJson && + payloadSha256 == other.payloadSha256 && + retractedTargets == other.retractedTargets && + nextEntry.revision == other.nextEntry.revision + } + + protected class RecordingSink : HealthConnectExportSink { + val batches = mutableListOf() + private val destinationReferences = mutableMapOf() + private var nextDestinationId = 1 + var failNext = false + var failOnAttempt: Int? = null + var omitNextAcknowledgement = false + var pauseBeforeNextApply: Pair, CompletableDeferred>? = null + private var attempts = 0 + + override suspend fun apply(batch: HealthConnectExportBatch): HealthConnectExportAcknowledgement { + pauseBeforeNextApply?.let { (entered, release) -> + pauseBeforeNextApply = null + entered.complete(Unit) + release.await() + } + attempts++ + if (failNext || failOnAttempt == attempts) { + failNext = false + failOnAttempt = null + error("Sink did not durably apply the batch.") + } + batches += batch + if (omitNextAcknowledgement) { + omitNextAcknowledgement = false + return HealthConnectExportAcknowledgement(emptyMap()) + } + return HealthConnectExportAcknowledgement( + batch.bundle.groveOutputIdentifiers() + .associate { output -> + output.key() to destinationReferences.getOrPut(output.key()) { + "Resource/${nextDestinationId++}" + } + }, + ) + } + + fun observationKeys(index: Int) = batches[index].bundle.entry + .mapNotNull { it.resource as? Observation } + .map { observation -> + val identifier = observationIdentity(observation) + "${identifier.system}|${identifier.value}" + } + } + + protected fun fhirContext( + recordingIdentifierValue: (Device) -> String = { "study-watch" }, + ): HealthConnectConversionContext { + val subjectIdentifier = contextIdentifier("participant-001") + val researchStudyIdentifier = contextIdentifier("my-heart-counts") + return HealthConnectConversionContext( + eventIdentifierSystem = TEST_EVENT_SYSTEM, + entryNodeIdentifierSystem = TEST_ENTRY_NODE_SYSTEM, + userAuthoredTextPolicy = HealthConnectUserAuthoredTextPolicy.RETAIN, + subject = HealthConnectPatientSubject.Bundled( + HealthConnectBundleResource( + subjectIdentifier, + Patient().apply { addIdentifier(subjectIdentifier.copy()) }, + ), + ), + assembler = application( + "My Heart Counts Android FHIR Converter", + "edu.stanford.myheartcounts.fhir", + "1.0.0", + ), + researchStudies = listOf( + HealthConnectBundleResource( + researchStudyIdentifier, + ResearchStudy().apply { + addIdentifier(researchStudyIdentifier.copy()) + status = ResearchStudy.ResearchStudyStatus.ACTIVE + }, + ), + ), + recordingDevice = { source -> + HealthConnectRecordingDeviceResource( + stablePerUnitToken = recordingIdentifierValue(source), + resource = FhirDevice().apply { + meta.addProfile(HealthConnectContract.MOBILE_RECORDING_DEVICE_PROFILE) + manufacturer = source.manufacturer + modelNumber = source.model + }, + ) + }, + ) + } + + protected fun application( + name: String, + packageName: String, + version: String? = null, + ): HealthConnectBundleResource { + val entryIdentifier = identifier(HealthConnectContract.ANDROID_PACKAGE_IDENTIFIER, packageName) + return HealthConnectBundleResource( + entryIdentifier, + FhirDevice().apply { + meta.addProfile(HealthConnectContract.MOBILE_APPLICATION_DEVICE_PROFILE) + addIdentifier(entryIdentifier.copy()) + addDeviceName().setName(name).setType(FhirDevice.DeviceNameType.USERFRIENDLYNAME) + version?.let { + addVersion() + .setType( + CodeableConcept( + Coding( + HealthConnectContract.MDC, + HealthConnectContract.APPLICATION_SOFTWARE_VERSION, + "MDC_ID_PROD_SPEC_SW", + ), + ), + ) + .setValue(it) + } + }, + ) + } + + protected fun identifier(system: String, value: String): Identifier = + Identifier().setSystem(system).setValue(value) + + protected fun contextIdentifier(value: String): Identifier = identifier(TEST_CONTEXT_IDENTIFIER_SYSTEM, value) + + protected fun Bundle.observations(): List = entry.mapNotNull { it.resource as? Observation } + + protected fun HealthConnectConverter.convert(record: androidx.health.connect.client.records.Record, at: Instant) = + convert(record, at, EventSequence("1")) + + companion object { + const val EXAMPLE_REPOSITORY_SCOPE = "1f5c58aa-6ec6-4e79-a682-829a9debd3f5" + const val TEST_CONTEXT_IDENTIFIER_SYSTEM = "urn:uuid:8d3fd52b-efda-5f3d-b83d-50f0a70b44aa" + const val SENSOR_RECORDING_DOCUMENT_PROFILE = + "https://grovealliance.org/fhir/sensor/StructureDefinition/grove-sensor-recording-document" + } +} diff --git a/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectFhirSupportCatalogTest.kt b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectFhirSupportCatalogTest.kt new file mode 100644 index 00000000..22700fbb --- /dev/null +++ b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectFhirSupportCatalogTest.kt @@ -0,0 +1,155 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import androidx.health.connect.client.records.CyclingPedalingCadenceRecord +import androidx.health.connect.client.records.ExerciseLap +import androidx.health.connect.client.records.ExerciseSegment +import androidx.health.connect.client.records.HeartRateRecord +import androidx.health.connect.client.records.PowerRecord +import androidx.health.connect.client.records.SkinTemperatureRecord +import androidx.health.connect.client.records.SleepSessionRecord +import androidx.health.connect.client.records.SpeedRecord +import androidx.health.connect.client.records.StepsCadenceRecord +import androidx.health.connect.client.records.metadata.Metadata +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import org.grovealliance.health.RecordType +import org.junit.Test +import java.io.File +import java.lang.reflect.Modifier + +class HealthConnectFhirSupportCatalogTest { + @Test + fun `generates every Health Connect Quantity value domain from the reviewed catalog`() { + assertThat(HealthConnectContract.quantityValueDomains.keys).containsExactly( + "body-fat-percentage", + "flights-climbed", + "oxygen-saturation", + "step-count", + "wheelchair-push-count", + ) + } + + @Test + fun `classifies the complete AndroidX 1_1 source inventory exactly once`() { + assertThat(HealthConnectCatalog.allRecordTypeIdentifiers) + .containsExactlyElementsIn(RecordType.all.map { it.identifier }) + assertThat(HealthConnectCatalog.allRecordTypeIdentifiers).hasSize(41) + assertThat(HealthConnectCatalog.supportedRecordTypeIdentifiers).hasSize(40) + assertThat(HealthConnectCatalog.deferredRecordTypeIdentifiers).hasSize(1) + assertThat( + HealthConnectCatalog.supportedRecordTypeIdentifiers.intersect( + HealthConnectCatalog.deferredRecordTypeIdentifiers, + ), + ).isEmpty() + } + + @Test + fun `every public field of every supported AndroidX record has a reviewed disposition`() { + val supported = RecordType.all.filter { + it.identifier in HealthConnectCatalog.supportedRecordTypeIdentifiers + } + supported.forEach { recordType -> + assertWithMessage("${recordType.identifier} public source fields") + .that(HealthConnectFieldDispositions.records.getValue(recordType.identifier).keys) + .containsExactlyElementsIn(publicFields(recordType.type.java)) + } + assertWithMessage("Metadata public source fields") + .that(HealthConnectFieldDispositions.metadata.keys) + .containsExactlyElementsIn(publicFields(Metadata::class.java)) + nestedSourceTypes.forEach { (name, sourceType) -> + assertWithMessage("$name public source fields") + .that(HealthConnectFieldDispositions.nested.getValue(name).keys) + .containsExactlyElementsIn(publicFields(sourceType)) + } + assertThat(HealthConnectFieldDispositions.nested.keys) + .containsExactlyElementsIn(nestedSourceTypes.keys) + } + + @Test + fun `exports the exact implementation capability inventory`() { + val destination = File(checkNotNull(System.getProperty("grove.capability.export"))) + destination.parentFile?.let { parent -> + check(parent.isDirectory || parent.mkdirs()) { "Cannot create capability export directory: $parent" } + } + destination.writeText( + capabilityManifest( + all = HealthConnectCatalog.allRecordTypeIdentifiers, + supported = HealthConnectCatalog.supportedRecordTypeIdentifiers, + deferred = HealthConnectCatalog.deferredRecordTypeIdentifiers, + ), + ) + } + + private fun capabilityManifest( + all: Set, + supported: Set, + deferred: Set, + ): String = buildString { + append("{\n") + append(" \"schemaVersion\": 1,\n") + append(" \"sourcePackage\": \"androidx.health.connect:connect-client\",\n") + append(" \"sourceVersion\": \"") + append(checkNotNull(System.getProperty("grove.health-connect.version"))) + append("\",\n") + append(" \"sourceTypeExtension\": \"") + append(HealthConnectContract.HEALTH_CONNECT_RECORD_TYPE_EXTENSION) + append("\",\n") + append(" \"fieldDispositionSourceVersion\": \"") + append(HealthConnectFieldDispositions.SOURCE_VERSION) + append("\",\n") + appendArray("allRecordTypes", all) + append(",\n") + appendArray("supportedRecordTypes", supported) + append(",\n") + appendArray("deferredRecordTypes", deferred) + append("\n}\n") + } + + private fun StringBuilder.appendArray(label: String, values: Set) { + append(" \"") + append(label) + append("\": [") + values.sorted().forEachIndexed { index, value -> + if (index > 0) append(", ") + append('"') + append(value) + append('"') + } + append(']') + } + + private fun publicFields(type: Class<*>): Set = type.declaredMethods + .asSequence() + .filter { method -> + Modifier.isPublic(method.modifiers) && + !Modifier.isStatic(method.modifiers) && + method.parameterCount == 0 && + method.name.matches(PUBLIC_GETTER) + } + .map { method -> + method.name.removePrefix("get").replaceFirstChar(Char::lowercaseChar) + } + .toSet() + + private companion object { + val PUBLIC_GETTER = Regex("get[A-Z][A-Za-z0-9]*") + val nestedSourceTypes = mapOf( + "HeartRateRecord.Sample" to HeartRateRecord.Sample::class.java, + "CyclingPedalingCadenceRecord.Sample" to CyclingPedalingCadenceRecord.Sample::class.java, + "PowerRecord.Sample" to PowerRecord.Sample::class.java, + "SpeedRecord.Sample" to SpeedRecord.Sample::class.java, + "StepsCadenceRecord.Sample" to StepsCadenceRecord.Sample::class.java, + "SleepSessionRecord.Stage" to SleepSessionRecord.Stage::class.java, + "SkinTemperatureRecord.Delta" to SkinTemperatureRecord.Delta::class.java, + "ExerciseSegment" to ExerciseSegment::class.java, + "ExerciseLap" to ExerciseLap::class.java, + ) + } +} diff --git a/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectIdentityTest.kt b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectIdentityTest.kt new file mode 100644 index 00000000..0467c95f --- /dev/null +++ b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectIdentityTest.kt @@ -0,0 +1,133 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import com.google.common.truth.Truth.assertThat +import org.junit.Assert.assertThrows +import org.junit.Test +import java.time.Instant + +class HealthConnectIdentityTest { + private val key = testIdentityKey() + private val source = HealthConnectIdentity.record( + key, + REPOSITORY_SCOPE_KEY, + "HeartRateRecord", + "record-heart-001", + ) + + @Test + fun `source identity uses its deployment key epoch system and typed opaque value`() { + val vector = HealthConnectIdentity.record( + key, + REPOSITORY_SCOPE_KEY, + "RestingHeartRateRecord", + "record|東京", + ) + assertThat(vector.identifier.system).isEqualTo( + "$TEST_OPAQUE_IDENTITY_SYSTEM_FAMILY/source-record/test-key/1", + ) + assertThat(vector.identifier.value).startsWith("v2:test-key:1:") + assertThat(vector.identifier.hasGroveRole(GroveIdentifierRole.SOURCE_RECORD)).isTrue() + } + + @Test + fun `source identity debug representation redacts native and derived identifiers`() { + val debugRepresentation = source.toString() + + assertThat(debugRepresentation).doesNotContain("record-heart-001") + assertThat(debugRepresentation).doesNotContain(source.identifier.value) + assertThat(debugRepresentation).contains("nativeRecordId=") + assertThat(debugRepresentation).contains("identifier=") + } + + @Test + fun `length framing accepts separators and supplementary Unicode without collisions`() { + val value = HealthConnectIdentity.record( + key, + REPOSITORY_SCOPE_KEY, + "HeartRateRecord", + "record|heart-😀", + ) + assertThat(value.identifier.value).isNotEqualTo(source.identifier.value) + } + + @Test + fun `rejects only unpaired Unicode surrogates`() { + listOf("\ud800", "\udc00", "prefix\ud800suffix").forEach { invalid -> + assertThrows(IllegalArgumentException::class.java) { + HealthConnectIdentity.record(key, REPOSITORY_SCOPE_KEY, "HeartRateRecord", invalid) + } + } + } + + @Test + fun `recording Device identity requires a per-unit token`() { + val subject = FhirIdentifierKey("https://example.org/patients", "participant-1") + val mine = HealthConnectIdentity.recordingDevice(key, subject, "unit-42") + val again = HealthConnectIdentity.recordingDevice(key, subject, "unit-42") + val yours = HealthConnectIdentity.recordingDevice(key, subject, "unit-43") + assertThat(mine.value).isEqualTo(again.value) + assertThat(mine.value).isNotEqualTo(yours.value) + assertThat(mine.hasGroveRole(GroveIdentifierRole.RECORDING_DEVICE)).isTrue() + assertThrows(IllegalArgumentException::class.java) { + HealthConnectIdentity.recordingDevice(key, subject, "") + } + } + + @Test + fun `sample slot replays identically while a duplicate uses another occurrence`() { + val time = Instant.parse("2026-08-20T17:30:15Z") + val first = HealthConnectIdentity.heartRateSampleOutput(key, source, time, 0) + val duplicate = HealthConnectIdentity.heartRateSampleOutput(key, source, time, 1) + val replayedSlot = HealthConnectIdentity.heartRateSampleOutput(key, source, time, 0) + assertThat(replayedSlot.value).isEqualTo(first.value) + assertThat(duplicate.value).isNotEqualTo(first.value) + assertThat(first.hasGroveRole(GroveIdentifierRole.SOURCE_OUTPUT)).isTrue() + } + + @Test + fun `event and entry-node identities use their typed v2 lexical forms`() { + val event = HealthConnectIdentity.exchange(TEST_EVENT_SYSTEM, TEST_PRODUCER_INSTANCE, EventSequence("42")) + assertThat(event.value).isEqualTo("e2:$TEST_PRODUCER_INSTANCE:42") + assertThat(event.hasGroveRole(GroveIdentifierRole.EVENT)).isTrue() + + val node = HealthConnectIdentity.conversionNode(TEST_ENTRY_NODE_SYSTEM, event) + assertThat(node.value).matches("n2:conversion-provenance:0:[A-Za-z0-9_-]{43}") + assertThat(node.hasGroveRole(GroveIdentifierRole.ENTRY_NODE)).isTrue() + assertThat(GroveExchangeIdentity.fullUrl(node)).startsWith("urn:uuid:") + } + + @Test + fun `fails closed on values outside the frozen grammar`() { + assertThrows(IllegalArgumentException::class.java) { + GroveHmacIdentityKey( + TEST_OPAQUE_IDENTITY_SYSTEM_FAMILY, + "test-key", + "1", + ByteArray(32) { index -> index.toByte() }, + ) + } + assertThrows(IllegalArgumentException::class.java) { + HealthConnectIdentity.exchange(TEST_EVENT_SYSTEM, TEST_PRODUCER_INSTANCE.uppercase(), EventSequence("1")) + } + assertThrows(IllegalArgumentException::class.java) { + HealthConnectIdentity.record(key, REPOSITORY_SCOPE_KEY, "FutureRecord", "record") + } + assertThrows(IllegalArgumentException::class.java) { + HealthConnectIdentity.specimenOutput(key, source, "SPECIMEN_SOURCE_TEARS") + } + } + + private companion object { + val REPOSITORY_SCOPE_KEY = FhirIdentifierKey( + "urn:uuid:1f5c58aa-6ec6-4e79-a682-829a9debd3f5", + "default", + ) + } +} diff --git a/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectNativeIdentifierDisclosureTest.kt b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectNativeIdentifierDisclosureTest.kt new file mode 100644 index 00000000..ec72be11 --- /dev/null +++ b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectNativeIdentifierDisclosureTest.kt @@ -0,0 +1,368 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import androidx.health.connect.client.records.BloodGlucoseRecord +import androidx.health.connect.client.records.ExerciseLap +import androidx.health.connect.client.records.ExerciseSegment +import androidx.health.connect.client.records.ExerciseSessionRecord +import androidx.health.connect.client.records.HeartRateRecord +import androidx.health.connect.client.records.MealType +import androidx.health.connect.client.records.NutritionRecord +import androidx.health.connect.client.records.SleepSessionRecord +import androidx.health.connect.client.records.StepsRecord +import androidx.health.connect.client.records.metadata.DataOrigin +import androidx.health.connect.client.records.metadata.Metadata +import androidx.health.connect.client.testing.populatedWithTestValues +import androidx.health.connect.client.units.BloodGlucose +import androidx.health.connect.client.units.Energy +import androidx.health.connect.client.units.Length +import androidx.health.connect.client.units.Mass +import com.google.common.truth.Truth.assertThat +import org.hl7.fhir.r4.model.CodeableConcept +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.Device +import org.hl7.fhir.r4.model.Identifier +import org.hl7.fhir.r4.model.Observation +import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.Provenance +import org.hl7.fhir.r4.model.ResearchStudy +import org.hl7.fhir.r4.model.Resource +import org.hl7.fhir.r4.model.Specimen +import org.junit.Assert.assertThrows +import org.junit.Test +import java.time.Instant +import java.time.ZoneOffset +import androidx.health.connect.client.records.metadata.Device as HealthConnectDevice + +class HealthConnectNativeIdentifierDisclosureTest { + @Test + fun `disclosure is omitted by default and emitted exactly on a one-to-one primary`() { + val withoutDisclosure = converter().convert(steps("native-step-42"), CONVERTED_AT, EventSequence("1")) + assertThat(withoutDisclosure.bundle.allIdentifiers().none { it.system == NATIVE_SYSTEM }).isTrue() + + val withDisclosure = converter(disclosure()).convert( + steps("native-step-42"), + CONVERTED_AT, + EventSequence("1"), + ) + val carryingResources = withDisclosure.bundle.entry.map { it.resource }.filter { resource -> + resource.directIdentifiers().any { + it.system == NATIVE_SYSTEM && it.value == "native-step-42" + } + } + assertThat(carryingResources).hasSize(1) + val observation = carryingResources.single() as Observation + val native = observation.identifier.single { it.system == NATIVE_SYSTEM } + assertThat(native.value).isEqualTo("native-step-42") + assertThat(native.type.text).isEqualTo("Health Connect repository record id") + assertThat(native.type.coding.single().system).isEqualTo(NATIVE_TYPE_SYSTEM) + assertThat(native.type.coding.single().code).isEqualTo("health-connect-record-id") + assertThat(native.type.coding.single().display).isEqualTo("Health Connect record id") + assertThat(native.hasGroveRole(GroveIdentifierRole.SOURCE_RECORD)).isFalse() + assertThat(native.hasGroveRole(GroveIdentifierRole.SOURCE_OUTPUT)).isFalse() + } + + @Test + fun `disclosure never propagates to samples stages segments laps nutrients specimens or support`() { + val converter = converter(disclosure()) + val heartRate = converter.convert(heartRate(), CONVERTED_AT, EventSequence("1")) + assertThat(heartRate.observations).hasSize(2) + assertThat(heartRate.bundle.countNativeIdentifiers()).isEqualTo(0) + + val sleep = converter.convert(sleep(), CONVERTED_AT, EventSequence("2")) + val sleepSummary = sleep.observations.single { + it.meta.profile.any { profile -> + profile.value == HealthConnectContract.MOBILE_SLEEP_DURATION_PROFILE + } + } + val stages = sleep.observations.filter { + it.meta.profile.any { profile -> + profile.value == HealthConnectContract.MOBILE_SLEEP_STAGE_PROFILE + } + } + assertThat(sleepSummary.identifier.count { it.system == NATIVE_SYSTEM }).isEqualTo(1) + assertThat(stages).hasSize(2) + assertThat(stages.all { stage -> stage.identifier.none { it.system == NATIVE_SYSTEM } }).isTrue() + assertThat(sleep.bundle.countNativeIdentifiers()).isEqualTo(1) + + val workout = converter.convert(workout(), CONVERTED_AT, EventSequence("3")) + val workoutSummary = workout.observations.single { + it.meta.profile.any { profile -> profile.value == HealthConnectContract.MOBILE_WORKOUT_PROFILE } + } + val workoutChildren = workout.observations.filter { + it.meta.profile.any { profile -> + profile.value == HealthConnectContract.MOBILE_WORKOUT_SEGMENT_PROFILE + } + } + assertThat(workoutSummary.identifier.count { it.system == NATIVE_SYSTEM }).isEqualTo(1) + assertThat(workoutChildren).hasSize(2) + assertThat(workoutChildren.all { child -> child.identifier.none { it.system == NATIVE_SYSTEM } }).isTrue() + assertThat(workout.bundle.countNativeIdentifiers()).isEqualTo(1) + + val nutrition = converter.convert(nutrition(), CONVERTED_AT, EventSequence("4")) + assertThat(nutrition.observations).hasSize(3) + assertThat(nutrition.bundle.countNativeIdentifiers()).isEqualTo(0) + + val glucose = converter.convert(glucose(), CONVERTED_AT, EventSequence("5")) + assertThat(glucose.observations.single().identifier.count { it.system == NATIVE_SYSTEM }).isEqualTo(1) + val specimen = glucose.bundle.entry.single { it.resource is Specimen }.resource as Specimen + assertThat(specimen.identifier.none { it.system == NATIVE_SYSTEM }).isTrue() + assertThat(glucose.bundle.countNativeIdentifiers()).isEqualTo(1) + } + + @Test + fun `configuration rejects relative and Grove role systems and invalid type fields`() { + assertThrows(IllegalArgumentException::class.java) { + HealthConnectNativeIdentifierDisclosure("repository-records") + } + assertThrows(IllegalArgumentException::class.java) { + HealthConnectNativeIdentifierDisclosure(HealthConnectContract.GROVE_IDENTIFIER_ROLE) + } + assertThrows(IllegalArgumentException::class.java) { + HealthConnectNativeIdentifierType() + } + assertThrows(IllegalArgumentException::class.java) { + HealthConnectNativeIdentifierTypeCoding( + HealthConnectContract.GROVE_IDENTIFIER_ROLE, + "source-record", + ) + } + assertThrows(IllegalArgumentException::class.java) { + HealthConnectNativeIdentifierTypeCoding(NATIVE_TYPE_SYSTEM, " ") + } + listOf(" leading", "trailing ", "two spaces", "tab\tcode", "control\u0001code").forEach { code -> + assertThrows(IllegalArgumentException::class.java) { + HealthConnectNativeIdentifierTypeCoding(NATIVE_TYPE_SYSTEM, code) + } + } + } + + @Test + fun `conversion rejects Grove event entry and opaque systems as native namespaces`() { + val forbiddenSystems = listOf( + TEST_EVENT_SYSTEM, + TEST_ENTRY_NODE_SYSTEM, + ) + GroveOpaqueIdentityKind.entries.map(testIdentityKey()::identifierSystem) + + forbiddenSystems.forEachIndexed { index, system -> + val forbidden = HealthConnectNativeIdentifierDisclosure(system) + assertThrows(IllegalArgumentException::class.java) { + converter(forbidden).convert( + steps("reserved-system-$index"), + CONVERTED_AT, + EventSequence((index + 1).toString()), + ) + } + } + } + + @Test + fun `type input snapshots a caller-owned mutable coding list`() { + val mutableCodings = mutableListOf( + HealthConnectNativeIdentifierTypeCoding( + NATIVE_TYPE_SYSTEM, + "health-connect-record-id", + ), + ) + val type = HealthConnectNativeIdentifierType(mutableCodings) + mutableCodings.clear() + + val converted = converter( + HealthConnectNativeIdentifierDisclosure(NATIVE_SYSTEM, type), + ).convert(steps("snapshot-id"), CONVERTED_AT, EventSequence("1")) + val native = converted.observations.single().identifier.single { it.system == NATIVE_SYSTEM } + assertThat(native.type.coding.single().code).isEqualTo("health-connect-record-id") + } + + private fun disclosure(): HealthConnectNativeIdentifierDisclosure = + HealthConnectNativeIdentifierDisclosure( + system = NATIVE_SYSTEM, + type = HealthConnectNativeIdentifierType( + codings = listOf( + HealthConnectNativeIdentifierTypeCoding( + system = NATIVE_TYPE_SYSTEM, + code = "health-connect-record-id", + display = "Health Connect record id", + ), + ), + text = "Health Connect repository record id", + ), + ) + + private fun converter( + disclosure: HealthConnectNativeIdentifierDisclosure? = null, + ): HealthConnectConverter = HealthConnectConverter( + context = HealthConnectConversionContext( + subject = HealthConnectPatientSubject.Logical( + Identifier().setSystem(PATIENT_SYSTEM).setValue("participant-7"), + ), + assembler = application(), + eventIdentifierSystem = TEST_EVENT_SYSTEM, + entryNodeIdentifierSystem = TEST_ENTRY_NODE_SYSTEM, + userAuthoredTextPolicy = HealthConnectUserAuthoredTextPolicy.OMIT, + nativeIdentifierDisclosure = disclosure, + ), + synchronizationScope = testSynchronizationScope( + repositoryScope = TEST_PRODUCER_INSTANCE, + configurationFingerprint = "native-identifier-tests-v1", + ), + ) + + private fun application(): HealthConnectBundleResource { + val identifier = Identifier() + .setSystem(HealthConnectContract.ANDROID_PACKAGE_IDENTIFIER) + .setValue("org.example.grove.fhir") + return HealthConnectBundleResource( + identifier, + Device().apply { + meta.addProfile(HealthConnectContract.MOBILE_APPLICATION_DEVICE_PROFILE) + addIdentifier(identifier.copy()) + addDeviceName().setName("Test converter").setType(Device.DeviceNameType.USERFRIENDLYNAME) + addVersion() + .setType( + CodeableConcept( + Coding( + HealthConnectContract.MDC, + HealthConnectContract.APPLICATION_SOFTWARE_VERSION, + "MDC_ID_PROD_SPEC_SW", + ), + ), + ) + .setValue("1.0.0") + }, + ) + } + + private fun metadata(id: String): Metadata = Metadata.autoRecorded( + HealthConnectDevice(type = HealthConnectDevice.TYPE_PHONE), + ).populatedWithTestValues( + id = id, + dataOrigin = DataOrigin("org.example.source"), + lastModifiedTime = SOURCE_VERSION, + ) + + private fun steps(id: String): StepsRecord = StepsRecord( + startTime = START, + startZoneOffset = ZoneOffset.UTC, + endTime = START.plusSeconds(3_600), + endZoneOffset = ZoneOffset.UTC, + count = 42, + metadata = metadata(id), + ) + + private fun heartRate(): HeartRateRecord = HeartRateRecord( + startTime = START, + startZoneOffset = ZoneOffset.UTC, + endTime = START.plusSeconds(60), + endZoneOffset = ZoneOffset.UTC, + samples = listOf( + HeartRateRecord.Sample(START.plusSeconds(15), 72), + HeartRateRecord.Sample(START.plusSeconds(45), 75), + ), + metadata = metadata("native-heart-rate"), + ) + + private fun sleep(): SleepSessionRecord = SleepSessionRecord( + startTime = START, + startZoneOffset = ZoneOffset.UTC, + endTime = START.plusSeconds(7_200), + endZoneOffset = ZoneOffset.UTC, + title = null, + notes = null, + stages = listOf( + SleepSessionRecord.Stage(START, START.plusSeconds(3_600), SleepSessionRecord.STAGE_TYPE_SLEEPING), + SleepSessionRecord.Stage( + START.plusSeconds(3_600), + START.plusSeconds(7_200), + SleepSessionRecord.STAGE_TYPE_AWAKE, + ), + ), + metadata = metadata("native-sleep"), + ) + + @Suppress("LongParameterList") + private fun workout(): ExerciseSessionRecord = ExerciseSessionRecord::class.java + .getConstructor( + Instant::class.java, + ZoneOffset::class.java, + Instant::class.java, + ZoneOffset::class.java, + Metadata::class.java, + Int::class.javaPrimitiveType, + String::class.java, + String::class.java, + List::class.java, + List::class.java, + ) + .newInstance( + START, + ZoneOffset.UTC, + START.plusSeconds(3_600), + ZoneOffset.UTC, + metadata("native-workout"), + ExerciseSessionRecord.EXERCISE_TYPE_RUNNING, + null, + null, + listOf( + ExerciseSegment( + START, + START.plusSeconds(1_800), + ExerciseSegment.EXERCISE_SEGMENT_TYPE_RUNNING, + 0, + ), + ), + listOf(ExerciseLap(START, START.plusSeconds(1_800), Length.meters(400.0))), + ) + + private fun nutrition(): NutritionRecord = NutritionRecord( + startTime = START, + startZoneOffset = ZoneOffset.UTC, + endTime = START.plusSeconds(1_800), + endZoneOffset = ZoneOffset.UTC, + metadata = metadata("native-nutrition"), + energy = Energy.kilocalories(650.0), + protein = Mass.grams(32.5), + vitaminC = Mass.milligrams(90.0), + ) + + private fun glucose(): BloodGlucoseRecord = BloodGlucoseRecord( + time = START, + zoneOffset = ZoneOffset.UTC, + metadata = metadata("native-glucose"), + level = BloodGlucose.milligramsPerDeciliter(95.5), + specimenSource = BloodGlucoseRecord.SPECIMEN_SOURCE_CAPILLARY_BLOOD, + mealType = MealType.MEAL_TYPE_UNKNOWN, + relationToMeal = BloodGlucoseRecord.RELATION_TO_MEAL_UNKNOWN, + ) + + private fun org.hl7.fhir.r4.model.Bundle.allIdentifiers(): List = + entry.flatMap { it.resource.directIdentifiers() } + + private fun org.hl7.fhir.r4.model.Bundle.countNativeIdentifiers(): Int = + allIdentifiers().count { it.system == NATIVE_SYSTEM } + + private fun Resource.directIdentifiers(): List = when (this) { + is Observation -> identifier + is Specimen -> identifier + is Device -> identifier + is Patient -> identifier + is ResearchStudy -> identifier + is Provenance -> emptyList() + else -> emptyList() + } + + private companion object { + const val NATIVE_SYSTEM = "https://example.org/repositories/device-7/health-connect-records" + const val NATIVE_TYPE_SYSTEM = "https://example.org/fhir/CodeSystem/source-identifier-type" + const val PATIENT_SYSTEM = "https://example.org/fhir/identifiers/patient-pseudonyms" + val START: Instant = Instant.parse("2026-08-19T08:00:00Z") + val SOURCE_VERSION: Instant = Instant.parse("2026-08-19T17:30:01Z") + val CONVERTED_AT: Instant = Instant.parse("2026-08-19T17:30:02Z") + } +} diff --git a/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectR4ConverterClinicalContextTest.kt b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectR4ConverterClinicalContextTest.kt new file mode 100644 index 00000000..f206c84b --- /dev/null +++ b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectR4ConverterClinicalContextTest.kt @@ -0,0 +1,468 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import androidx.health.connect.client.feature.ExperimentalMindfulnessSessionApi +import androidx.health.connect.client.records.BloodGlucoseRecord +import androidx.health.connect.client.records.BloodPressureRecord +import androidx.health.connect.client.records.CervicalMucusRecord +import androidx.health.connect.client.records.IntermenstrualBleedingRecord +import androidx.health.connect.client.records.MealType +import androidx.health.connect.client.records.MenstruationFlowRecord +import androidx.health.connect.client.records.MenstruationPeriodRecord +import androidx.health.connect.client.records.MindfulnessSessionRecord +import androidx.health.connect.client.records.NutritionRecord +import androidx.health.connect.client.records.OvulationTestRecord +import androidx.health.connect.client.records.SexualActivityRecord +import androidx.health.connect.client.records.metadata.Metadata +import androidx.health.connect.client.units.BloodGlucose +import androidx.health.connect.client.units.Energy +import androidx.health.connect.client.units.Mass +import androidx.health.connect.client.units.Pressure +import com.google.common.truth.Truth.assertThat +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.Specimen +import org.junit.Assert.assertThrows +import org.junit.Test +import java.math.BigDecimal +import java.time.Instant +import java.time.ZoneOffset + +@OptIn(ExperimentalMindfulnessSessionApi::class) +class HealthConnectR4ConverterClinicalContextTest : HealthConnectR4ConverterTestSupport() { + @Test + fun `fans a nutrition record out into one observation per present nutrient`() { + val start = Instant.parse("2026-08-19T12:00:00Z") + val result = converter.convert( + NutritionRecord( + startTime = start, + startZoneOffset = ZoneOffset.ofHours(-7), + endTime = start.plusSeconds(1_800), + endZoneOffset = ZoneOffset.ofHours(-7), + metadata = metadata(Metadata.manualEntry(), id = "nutrition-record"), + energy = Energy.kilocalories(650.0), + protein = Mass.grams(32.5), + transFat = Mass.grams(0.5), + vitaminC = Mass.milligrams(90.0), + ), + convertedAt, + ) + + assertThat(result.observations).hasSize(4) + assertThat(result.observationIdentifiers.map { it.value }.distinct()).hasSize(4) + val byCode = result.observations.associateBy { it.code.codingFirstRep.code } + assertThat(byCode.keys).containsExactly("9052-2", "9080-3", "dietary-fat-trans", "dietary-vitamin-c") + val energy = byCode.getValue("9052-2") + assertThat(energy.code.codingFirstRep.system).isEqualTo(HealthConnectContract.LOINC) + assertThat(energy.meta.profile.map { it.value }).containsExactly( + HealthConnectContract.mobileDietaryProfiles.getValue("dietary-energy"), + HealthConnectContract.HEALTH_CONNECT_OBSERVATION_PROFILE, + ).inOrder() + assertThat(energy.valueQuantity.code).isEqualTo("kcal") + assertThat(energy.valueQuantity.value).isEqualTo(BigDecimal("650.0")) + val transFat = byCode.getValue("dietary-fat-trans") + assertThat(transFat.code.codingFirstRep.system) + .isEqualTo(HealthConnectContract.HEALTH_CONNECT_MEASUREMENT) + assertThat(transFat.meta.profile.map { it.value }) + .containsExactly(HealthConnectContract.HEALTH_CONNECT_DIETARY_FAT_TRANS_PROFILE) + assertThat(transFat.valueQuantity.code).isEqualTo("g") + val vitaminC = byCode.getValue("dietary-vitamin-c") + assertThat(vitaminC.code.codingFirstRep.system) + .isEqualTo(HealthConnectContract.GROVE_MOBILE_MEASUREMENT) + assertThat(vitaminC.valueQuantity.code).isEqualTo("mg") + assertThat(vitaminC.valueQuantity.value).isEqualTo(BigDecimal("90.0")) + result.observations.forEach { observation -> + assertThat(observation.hasCategory()).isFalse() + assertThat(observation.effectivePeriod.startElement.valueAsString) + .isEqualTo("2026-08-19T05:00:00-07:00") + assertThat(observation.effectivePeriod.endElement.valueAsString) + .isEqualTo("2026-08-19T05:30:00-07:00") + } + } + + @Test + fun `maps an all-absent nutrition record to a local zero-output conversion`() { + val start = Instant.parse("2026-08-19T12:00:00Z") + val result = converter.convert( + NutritionRecord( + startTime = start, + startZoneOffset = ZoneOffset.UTC, + endTime = start.plusSeconds(1_800), + endZoneOffset = ZoneOffset.UTC, + metadata = metadata(Metadata.manualEntry(), id = "empty-nutrition"), + ), + convertedAt, + ) + + assertThat(result.observations).isEmpty() + assertThat(result.provenance).isNull() + } + + @Test + fun `absorbs coded reproductive enums while retaining the exact Health Connect constants`() { + val instant = Instant.parse("2026-08-19T07:00:00Z") + val sourceMetadata = metadata(Metadata.manualEntry(), id = "coded-records") + val flow = converter.convert( + MenstruationFlowRecord( + time = instant, + zoneOffset = ZoneOffset.UTC, + metadata = sourceMetadata, + flow = MenstruationFlowRecord.FLOW_LIGHT, + ), + convertedAt, + ).observations.single() + val ovulation = converter.convert( + OvulationTestRecord(instant, ZoneOffset.UTC, OvulationTestRecord.RESULT_HIGH, sourceMetadata), + convertedAt, + ).observations.single() + val sexualActivity = converter.convert( + SexualActivityRecord( + time = instant, + zoneOffset = ZoneOffset.UTC, + metadata = sourceMetadata, + protectionUsed = SexualActivityRecord.PROTECTION_USED_UNKNOWN, + ), + convertedAt, + ).observations.single() + + assertThat(flow.meta.profile.map { it.value }).containsExactly( + HealthConnectContract.MOBILE_MENSTRUATION_FLOW_PROFILE, + HealthConnectContract.HEALTH_CONNECT_OBSERVATION_PROFILE, + ).inOrder() + assertThat(flow.code.codingFirstRep.code).isEqualTo("menstruation-flow") + assertThat(flow.hasCategory()).isFalse() + assertThat(flow.hasEffectiveDateTimeType()).isTrue() + assertThat(flow.valueCodeableConcept.coding.map { it.system to it.code }).containsExactly( + HealthConnectContract.GROVE_MENSTRUATION_FLOW to "light", + HealthConnectContract.HEALTH_CONNECT_MENSTRUATION_FLOW to "FLOW_LIGHT", + ).inOrder() + assertThat(ovulation.code.codingFirstRep.code).isEqualTo("ovulation-test-result") + assertThat(ovulation.valueCodeableConcept.coding.map { it.system to it.code }).containsExactly( + HealthConnectContract.GROVE_OVULATION_TEST_RESULT to "high-fertility", + HealthConnectContract.HEALTH_CONNECT_OVULATION_TEST_RESULT to "RESULT_HIGH", + ).inOrder() + assertThat(sexualActivity.code.codingFirstRep.code).isEqualTo("sexual-activity") + assertThat(sexualActivity.valueCodeableConcept.coding.map { it.system to it.code }).containsExactly( + HealthConnectContract.GROVE_SEXUAL_ACTIVITY to "unknown", + HealthConnectContract.HEALTH_CONNECT_SEXUAL_ACTIVITY_PROTECTION to "PROTECTION_USED_UNKNOWN", + ).inOrder() + } + + @Test + fun `maps cervical mucus quality with its optional sensation component`() { + val instant = Instant.parse("2026-08-19T07:00:00Z") + val withSensation = converter.convert( + CervicalMucusRecord( + time = instant, + zoneOffset = ZoneOffset.UTC, + metadata = metadata(Metadata.manualEntry(), id = "cervical-mucus"), + appearance = CervicalMucusRecord.APPEARANCE_EGG_WHITE, + sensation = CervicalMucusRecord.SENSATION_MEDIUM, + ), + convertedAt, + ).observations.single() + val withoutSensation = converter.convert( + CervicalMucusRecord( + time = instant, + zoneOffset = ZoneOffset.UTC, + metadata = metadata(Metadata.manualEntry(), id = "cervical-mucus-no-sensation"), + appearance = CervicalMucusRecord.APPEARANCE_DRY, + sensation = CervicalMucusRecord.SENSATION_UNKNOWN, + ), + convertedAt, + ).observations.single() + + assertThat(withSensation.code.codingFirstRep.code).isEqualTo("cervical-mucus-quality") + assertThat(withSensation.valueCodeableConcept.coding.map { it.system to it.code }).containsExactly( + HealthConnectContract.GROVE_CERVICAL_MUCUS_QUALITY to "egg-white", + HealthConnectContract.HEALTH_CONNECT_CERVICAL_MUCUS_APPEARANCE to "APPEARANCE_EGG_WHITE", + ).inOrder() + val component = withSensation.component.single() + assertThat(component.code.codingFirstRep.system) + .isEqualTo(HealthConnectContract.GROVE_MOBILE_MEASUREMENT) + assertThat(component.code.codingFirstRep.code).isEqualTo("cervical-mucus-sensation") + assertThat(component.valueCodeableConcept.coding.map { it.system to it.code }).containsExactly( + HealthConnectContract.GROVE_CERVICAL_MUCUS_SENSATION to "medium", + HealthConnectContract.HEALTH_CONNECT_CERVICAL_MUCUS_SENSATION to "SENSATION_MEDIUM", + ).inOrder() + assertThat(withoutSensation.valueCodeableConcept.codingFirstRep.code).isEqualTo("dry") + assertThat(withoutSensation.hasComponent()).isFalse() + } + + @Test + fun `marks intermenstrual bleeding and menstruation periods as present`() { + val instant = Instant.parse("2026-08-19T07:00:00Z") + val bleeding = converter.convert( + IntermenstrualBleedingRecord( + instant, + ZoneOffset.UTC, + metadata(Metadata.manualEntry(), id = "intermenstrual-bleeding"), + ), + convertedAt, + ).observations.single() + val period = converter.convert( + MenstruationPeriodRecord( + startTime = instant, + startZoneOffset = ZoneOffset.UTC, + endTime = instant.plusSeconds(4 * 24 * 60 * 60), + endZoneOffset = ZoneOffset.UTC, + metadata = metadata(Metadata.manualEntry(), id = "menstruation-period"), + ), + convertedAt, + ).observations.single() + + assertThat(bleeding.code.codingFirstRep.code).isEqualTo("intermenstrual-bleeding") + assertThat(bleeding.hasEffectiveDateTimeType()).isTrue() + assertThat(bleeding.valueCodeableConcept.coding.map { it.system to it.code }).containsExactly( + HealthConnectContract.GROVE_INTERMENSTRUAL_BLEEDING to "present", + ) + assertThat(period.meta.profile.map { it.value }) + .containsExactly(HealthConnectContract.HEALTH_CONNECT_MENSTRUATION_PERIOD_PROFILE) + assertThat(period.code.codingFirstRep.system) + .isEqualTo(HealthConnectContract.HEALTH_CONNECT_MEASUREMENT) + assertThat(period.code.codingFirstRep.code).isEqualTo("menstruation-period") + assertThat(period.effectivePeriod.hasStart()).isTrue() + assertThat(period.effectivePeriod.hasEnd()).isTrue() + assertThat(period.valueCodeableConcept.coding.map { it.system to it.code }).containsExactly( + HealthConnectContract.HEALTH_CONNECT_MENSTRUATION_PERIOD to "present", + ) + } + + @Test + fun `normalizes a mindfulness session to its duration in minutes`() { + val start = Instant.parse("2026-08-19T07:00:00Z") + val observation = converter.convert( + MindfulnessSessionRecord( + startTime = start, + startZoneOffset = ZoneOffset.ofHours(-7), + endTime = start.plusSeconds(30 * 60), + endZoneOffset = ZoneOffset.ofHours(-7), + metadata = metadata(Metadata.manualEntry(), id = "mindfulness-session"), + mindfulnessSessionType = MindfulnessSessionRecord.MINDFULNESS_SESSION_TYPE_MEDITATION, + title = "Morning practice", + notes = "Participant note", + ), + convertedAt, + ).observations.single() + + assertThat(observation.meta.profile.map { it.value }).containsExactly( + HealthConnectContract.MOBILE_MINDFULNESS_SESSION_PROFILE, + HealthConnectContract.HEALTH_CONNECT_OBSERVATION_PROFILE, + ).inOrder() + assertThat(observation.code.codingFirstRep.code).isEqualTo("mindfulness-session-duration") + assertThat(observation.valueQuantity.value).isEqualTo(BigDecimal("30")) + assertThat(observation.valueQuantity.code).isEqualTo("min") + assertThat(observation.effectivePeriod.startElement.valueAsString) + .isEqualTo("2026-08-19T00:00:00-07:00") + assertThat(observation.effectivePeriod.endElement.valueAsString) + .isEqualTo("2026-08-19T00:30:00-07:00") + val sourceType = observation.method.coding.single() + assertThat(sourceType.system).isEqualTo(HealthConnectContract.HEALTH_CONNECT_MINDFULNESS_SESSION_TYPE) + assertThat(sourceType.code).isEqualTo("MINDFULNESS_SESSION_TYPE_MEDITATION") + assertThat( + observation.hasExtension( + "https://grovealliance.org/fhir/health-connect/StructureDefinition/" + + "health-connect-mindfulness-session-type", + ), + ).isFalse() + assertThat( + observation.getExtensionByUrl(HealthConnectContract.HEALTH_CONNECT_SESSION_TITLE) + .value.primitiveValue(), + ).isEqualTo("Morning practice") + assertThat(observation.note.single().text).isEqualTo("Participant note") + } + + @Test + fun `mindfulness text minimization omits title and notes but retains method`() { + val start = Instant.parse("2026-08-19T07:00:00Z") + val minimizingConverter = HealthConnectConverter( + fhirContext.copy(userAuthoredTextPolicy = HealthConnectUserAuthoredTextPolicy.OMIT), + synchronizationScope, + ) + val observation = minimizingConverter.convert( + MindfulnessSessionRecord( + startTime = start, + startZoneOffset = ZoneOffset.UTC, + endTime = start.plusSeconds(300), + endZoneOffset = ZoneOffset.UTC, + metadata = metadata(Metadata.manualEntry(), id = "mindfulness-minimized"), + mindfulnessSessionType = MindfulnessSessionRecord.MINDFULNESS_SESSION_TYPE_BREATHING, + title = "Private title", + notes = "Private note", + ), + convertedAt, + EventSequence("1"), + ).observations.single() + + assertThat(observation.hasExtension(HealthConnectContract.HEALTH_CONNECT_SESSION_TITLE)).isFalse() + assertThat(observation.note).isEmpty() + assertThat(observation.method.coding.single().code) + .isEqualTo("MINDFULNESS_SESSION_TYPE_BREATHING") + } + + @Test + fun `rejects an unsupported mindfulness method instead of emitting an ungoverned code`() { + val start = Instant.parse("2026-08-19T07:00:00Z") + val record = MindfulnessSessionRecord( + startTime = start, + startZoneOffset = ZoneOffset.UTC, + endTime = start.plusSeconds(300), + endZoneOffset = ZoneOffset.UTC, + metadata = metadata(Metadata.manualEntry(), id = "mindfulness-unknown-method"), + mindfulnessSessionType = Int.MAX_VALUE, + title = null, + notes = null, + ) + + assertThrows(InvalidHealthConnectRecord::class.java) { + converter.convert(record, convertedAt) + } + } + + @Test + fun `retains exact Health Connect meal and blood-pressure context in standard elements`() { + val instant = Instant.parse("2026-08-19T16:00:00Z") + val sourceMetadata = metadata(Metadata.autoRecorded(device), id = "context-values") + val glucose = converter.convert( + BloodGlucoseRecord( + time = instant, + zoneOffset = ZoneOffset.UTC, + metadata = sourceMetadata, + level = BloodGlucose.milligramsPerDeciliter(95.5), + specimenSource = BloodGlucoseRecord.SPECIMEN_SOURCE_WHOLE_BLOOD, + mealType = MealType.MEAL_TYPE_BREAKFAST, + relationToMeal = BloodGlucoseRecord.RELATION_TO_MEAL_BEFORE_MEAL, + ), + convertedAt, + ).observations.single() + val mealContext = requireNotNull( + glucose.getExtensionByUrl(HealthConnectContract.HEALTH_CONNECT_GLUCOSE_MEAL_CONTEXT), + ) + val relation = mealContext.getExtensionByUrl("relationToMeal").value as Coding + val meal = mealContext.getExtensionByUrl("mealType").value as Coding + + assertThat(relation.system).isEqualTo(HealthConnectContract.HEALTH_CONNECT_RELATION_TO_MEAL) + assertThat(relation.code).isEqualTo("RELATION_TO_MEAL_BEFORE_MEAL") + assertThat(meal.system).isEqualTo(HealthConnectContract.HEALTH_CONNECT_MEAL_TYPE) + assertThat(meal.code).isEqualTo("MEAL_TYPE_BREAKFAST") + + val bloodPressure = converter.convert( + BloodPressureRecord( + time = instant, + zoneOffset = ZoneOffset.UTC, + metadata = sourceMetadata, + systolic = Pressure.millimetersOfMercury(120.0), + diastolic = Pressure.millimetersOfMercury(80.0), + bodyPosition = BloodPressureRecord.BODY_POSITION_SITTING_DOWN, + measurementLocation = BloodPressureRecord.MEASUREMENT_LOCATION_LEFT_UPPER_ARM, + ), + convertedAt, + ).observations.single() + val bodyPosition = bloodPressure + .getExtensionByUrl(HealthConnectContract.OBSERVATION_BODY_POSITION) + .value as org.hl7.fhir.r4.model.CodeableConcept + + assertThat(bodyPosition.codingFirstRep.code).isEqualTo("33586001") + assertThat(bloodPressure.bodySite.codingFirstRep.code).isEqualTo("368208006") + } + + @Test + @Suppress("LongMethod") + fun `selects glucose semantics from the explicit specimen source and emits the specimen graph`() { + val cases = listOf( + Triple( + BloodGlucoseRecord.SPECIMEN_SOURCE_WHOLE_BLOOD, + "2339-0" to "258580003", + HealthConnectContract.HEALTH_CONNECT_WHOLE_BLOOD_GLUCOSE_PROFILE, + ), + Triple( + BloodGlucoseRecord.SPECIMEN_SOURCE_CAPILLARY_BLOOD, + "32016-8" to "122554006", + HealthConnectContract.HEALTH_CONNECT_CAPILLARY_BLOOD_GLUCOSE_PROFILE, + ), + Triple( + BloodGlucoseRecord.SPECIMEN_SOURCE_PLASMA, + "2345-7" to "119361006", + HealthConnectContract.HEALTH_CONNECT_SERUM_PLASMA_GLUCOSE_PROFILE, + ), + Triple( + BloodGlucoseRecord.SPECIMEN_SOURCE_SERUM, + "2345-7" to "119364003", + HealthConnectContract.HEALTH_CONNECT_SERUM_PLASMA_GLUCOSE_PROFILE, + ), + Triple( + BloodGlucoseRecord.SPECIMEN_SOURCE_INTERSTITIAL_FLUID, + "99504-3" to "258479004", + HealthConnectContract.HEALTH_CONNECT_INTERSTITIAL_GLUCOSE_PROFILE, + ), + ) + + cases.forEachIndexed { index, (specimenSource, codes, profile) -> + val (loinc, specimenCode) = codes + val result = converter.convert( + bloodGlucoseRecord(specimenSource, id = "glucose-$index"), + convertedAt, + ) + val observation = result.observations.single() + val specimenEntry = result.bundle.entry.single { it.resource is Specimen } + val specimen = specimenEntry.resource as Specimen + assertThat(specimen.meta.profile.map { it.value }).containsExactly( + HealthConnectContract.HEALTH_CONNECT_SPECIMEN_PROFILE, + ).inOrder() + assertThat(specimen.identifier).hasSize(2) + val specimenSourceIdentifier = specimen.identifier.single { + it.hasGroveRole(GroveIdentifierRole.SOURCE_RECORD) + } + val specimenOutputIdentifier = specimen.identifier.single { + it.hasGroveRole(GroveIdentifierRole.SOURCE_OUTPUT) + } + assertThat(specimenSourceIdentifier.system).isEqualTo(result.sourceRecordIdentifier.system) + assertThat(specimenSourceIdentifier.value).isEqualTo(result.sourceRecordIdentifier.value) + assertThat(specimenEntry.fullUrl).isEqualTo( + GroveExchangeIdentity.fullUrl(specimenOutputIdentifier), + ) + val specimenTarget = result.provenance?.target?.single { it.type == "Specimen" } + assertThat(specimenTarget?.reference).isEqualTo(specimenEntry.fullUrl) + assertThat(specimenTarget?.identifier?.system).isEqualTo(specimenOutputIdentifier.system) + assertThat(specimenTarget?.identifier?.value).isEqualTo(specimenOutputIdentifier.value) + + assertThat(observation.meta.profile.map { it.value }).containsExactly( + profile, + ).inOrder() + assertThat( + observation.getExtensionsByUrl( + HealthConnectContract.HEALTH_CONNECT_RECORD_TYPE_EXTENSION, + ).single().value.primitiveValue(), + ).isEqualTo("BloodGlucoseRecord") + assertThat(observation.code.codingFirstRep.code).isEqualTo(loinc) + assertThat(observation.valueQuantity.code).isEqualTo("mg/dL") + assertThat(observation.specimen.reference).isEqualTo(specimenEntry.fullUrl) + assertThat( + observation.hasExtension(HealthConnectContract.HEALTH_CONNECT_GLUCOSE_MEAL_CONTEXT), + ).isFalse() + assertThat(specimen.type.codingFirstRep.system).isEqualTo(HealthConnectContract.SNOMED_CT) + assertThat(specimen.type.codingFirstRep.code).isEqualTo(specimenCode) + assertThat(specimen.subject.reference).isEqualTo(observation.subject.reference) + } + } + + @Test + fun `fails closed for glucose without admitted specimen semantics`() { + listOf( + BloodGlucoseRecord.SPECIMEN_SOURCE_UNKNOWN, + BloodGlucoseRecord.SPECIMEN_SOURCE_TEARS, + ).forEachIndexed { index, specimenSource -> + assertThrows(InvalidHealthConnectRecord::class.java) { + converter.convert( + bloodGlucoseRecord(specimenSource, id = "unsupported-glucose-$index"), + convertedAt, + ) + } + } + } +} diff --git a/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectR4ConverterGraphIdentityTest.kt b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectR4ConverterGraphIdentityTest.kt new file mode 100644 index 00000000..dcb77295 --- /dev/null +++ b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectR4ConverterGraphIdentityTest.kt @@ -0,0 +1,429 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import androidx.health.connect.client.feature.ExperimentalMindfulnessSessionApi +import androidx.health.connect.client.records.ExerciseSessionRecord +import androidx.health.connect.client.records.HeartRateRecord +import androidx.health.connect.client.records.StepsRecord +import androidx.health.connect.client.records.WeightRecord +import androidx.health.connect.client.records.metadata.DataOrigin +import androidx.health.connect.client.records.metadata.Metadata +import androidx.health.connect.client.testing.populatedWithTestValues +import androidx.health.connect.client.units.Mass +import com.google.common.truth.Truth.assertThat +import org.hl7.fhir.r4.model.Observation +import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.Provenance +import org.hl7.fhir.r4.model.Reference +import org.junit.Assert.assertThrows +import org.junit.Test +import java.time.Instant +import java.time.ZoneOffset +import java.util.UUID +import org.hl7.fhir.r4.model.Device as FhirDevice + +@OptIn(ExperimentalMindfulnessSessionApi::class) +class HealthConnectR4ConverterGraphIdentityTest : HealthConnectR4ConverterTestSupport() { + @Test + fun `does not impose an arbitrary transport cap on heart-rate samples`() { + val start = Instant.parse("2026-08-19T16:00:00Z") + val samples = List(201) { index -> + HeartRateRecord.Sample( + time = start.plusSeconds(index.toLong()), + beatsPerMinute = 60L + index % 20, + ) + } + val record = HeartRateRecord( + startTime = start, + startZoneOffset = null, + endTime = start.plusSeconds(201), + endZoneOffset = null, + samples = samples, + metadata = metadata(Metadata.autoRecorded(device), id = "large-heart-rate-series"), + ) + + val conversion = converter.convert(record, convertedAt) + + assertThat(conversion.observations).hasSize(201) + assertThat(conversion.observationIdentifiers.map { it.value }.distinct()).hasSize(201) + } + + @Test + fun `heart-rate output identity is stable across replay and sample order`() { + val start = Instant.parse("2026-08-19T17:30:00Z") + val first = heartRateRecord( + start = start, + samples = listOf( + HeartRateRecord.Sample(start.plusSeconds(15), 72), + HeartRateRecord.Sample(start.plusSeconds(45), 75), + ), + ) + val reordered = heartRateRecord( + start = start, + samples = first.samples.reversed(), + ) + + val firstIdentifiers = converter.convert(first, convertedAt).observationIdentifiers.map { it.value } + val replayedIdentifiers = converter.convert(reordered, convertedAt).observationIdentifiers.map { it.value } + + assertThat(replayedIdentifiers).containsExactlyElementsIn(firstIdentifiers).inOrder() + } + + @Test + fun `business identities drive RFC UUIDv5 references without producer resource ids`() { + val record = heartRateRecord( + start = Instant.parse("2026-08-19T17:30:00Z"), + samples = listOf(HeartRateRecord.Sample(Instant.parse("2026-08-19T17:30:15Z"), 72)), + ) + + val first = converter.convert(record, convertedAt, EventSequence("41")) + val replay = converter.convert(record, convertedAt, EventSequence("41")) + val nextEvent = converter.convert(record, convertedAt, EventSequence("42")) + val firstConversion = entryIdentifier(first.bundle, "Provenance") + val replayConversion = entryIdentifier(replay.bundle, "Provenance") + val nextConversion = entryIdentifier(nextEvent.bundle, "Provenance") + val referenceUuids = first.bundle.entry.map { entry -> + UUID.fromString(requireNotNull(entry.fullUrl).removePrefix("urn:uuid:")) + } + + assertThat(referenceUuids.map(UUID::version).distinct()).containsExactly(5) + assertThat(referenceUuids.map(UUID::variant).distinct()).containsExactly(2) + assertThat(first.bundle.id).isNull() + assertThat(first.provenance!!.id).isNull() + assertThat(first.observations.single().id).isNull() + assertThat(replay.bundle.identifier.value).isEqualTo(first.bundle.identifier.value) + assertThat(replayConversion.value).isEqualTo(firstConversion.value) + assertThat(nextEvent.bundle.identifier.value).isNotEqualTo(first.bundle.identifier.value) + assertThat(nextConversion.value).isNotEqualTo(firstConversion.value) + assertThat(nextEvent.observationIdentifiers.single().value) + .isEqualTo(first.observationIdentifiers.single().value) + assertThat(first.bundle.identifier.value) + .isEqualTo("e2:$TEST_PRODUCER_INSTANCE:41") + assertThat(firstConversion.value) + .matches("n2:conversion-provenance:0:[A-Za-z0-9_-]{43}") + } + + @Test + fun `supporting resources cannot inject an Observation`() { + assertThrows(IllegalArgumentException::class.java) { + fhirContext.copy( + supportingResources = listOf( + HealthConnectBundleResource( + identifier(TEST_CONTEXT_IDENTIFIER_SYSTEM, "forbidden-observation"), + Observation(), + ), + ), + ) + } + } + + @Test + fun `supporting resources cannot inject conversion Provenance`() { + assertThrows(IllegalArgumentException::class.java) { + fhirContext.copy( + supportingResources = listOf( + HealthConnectBundleResource( + identifier(TEST_CONTEXT_IDENTIFIER_SYSTEM, "forbidden-provenance"), + Provenance(), + ), + ), + ) + } + } + + @Test + fun `conversion rejects a Bundle whose Provenance differs from its exposed result`() { + val valid = converter.convert(stepRecord(), convertedAt) + val mismatchedBundle = valid.bundle.apply { + (entry.single { it.resource is Provenance }.resource as Provenance).target.clear() + } + + assertThrows(IllegalArgumentException::class.java) { + HealthConnectConversion( + conversionContractVersion = valid.conversionContractVersion, + sourceRecordIdentifier = valid.sourceRecordIdentifier, + sourceRecordType = valid.sourceRecordType, + sourceLastModified = valid.sourceLastModified, + observations = valid.observations, + provenance = valid.provenance, + bundle = mismatchedBundle, + ) + } + } + + @Test + fun `active boundary requires the Health Connect envelope for record-type lineage`() { + val valid = converter.convert(stepRecord(), convertedAt) + assertThat(activeBatch(valid, valid.bundle).operation) + .isEqualTo(HealthConnectExportOperation.ACTIVE) + + val mutated = valid.bundle.apply { + val observation = entry.single { it.resource is Observation }.resource as Observation + observation.meta.profile.removeIf { + it.value == HealthConnectContract.HEALTH_CONNECT_OBSERVATION_PROFILE + } + } + + assertThrows(IllegalArgumentException::class.java) { + activeBatch(valid, mutated) + } + } + + @Test + fun `conversion rejects an unresolved internal UUID reference even when snapshots agree`() { + val valid = converter.convert(stepRecord(), convertedAt) + val unresolved = "urn:uuid:00000000-0000-5000-8000-000000000000" + val observations = valid.observations.onEach { it.subject.reference = unresolved } + val bundle = valid.bundle.apply { + (entry.single { it.resource is Observation }.resource as Observation).subject.reference = unresolved + } + + assertThrows(IllegalArgumentException::class.java) { + HealthConnectConversion( + conversionContractVersion = valid.conversionContractVersion, + sourceRecordIdentifier = valid.sourceRecordIdentifier, + sourceRecordType = valid.sourceRecordType, + sourceLastModified = valid.sourceLastModified, + observations = observations, + provenance = valid.provenance, + bundle = bundle, + ) + } + } + + @Test + fun `all contained resources are prohibited even when their local id is valid`() { + val bundle = converter.convert(stepRecord(), convertedAt).bundle + val observation = bundle.entry.single { it.resource is Observation }.resource as Observation + observation.addContained(Patient().apply { id = "valid-local-id" }) + + assertThrows(IllegalArgumentException::class.java) { + bundle.requireGroveReferencePolicy() + } + } + + @Test + fun `Health Connect DataOrigin cannot become a literal event Device node`() { + val valid = converter.convert(stepRecord(), convertedAt) + val assemblerUrl = valid.bundle.entry.single { entry -> + entry.resource is FhirDevice && + (entry.resource as FhirDevice).meta.profile.map { it.value } == + listOf(HealthConnectContract.MOBILE_APPLICATION_DEVICE_PROFILE) + }.fullUrl + val mutated = valid.bundle.apply { + val provenance = entry.single { it.resource is Provenance }.resource as Provenance + provenance.entity.single().agent.single().who.reference = assemblerUrl + } + + assertThrows(IllegalArgumentException::class.java) { + activeBatch(valid, mutated) + } + } + + @Test + fun `Health Connect DataOrigin requires the exact Android package NamingSystem`() { + val valid = converter.convert(stepRecord(), convertedAt) + val mutated = valid.bundle.apply { + val provenance = entry.single { it.resource is Provenance }.resource as Provenance + provenance.entity.single().agent.single().who.identifier.system = + "https://example.invalid/fhir/NamingSystem/application" + } + + assertThrows(IllegalArgumentException::class.java) { + activeBatch(valid, mutated) + } + } + + @Test + fun `Health Connect DataOrigin requires exactly one enterer participation coding`() { + val valid = converter.convert(stepRecord(), convertedAt) + val mutated = valid.bundle.apply { + val provenance = entry.single { it.resource is Provenance }.resource as Provenance + provenance.entity.single().agent.single().type.coding.single().code = "assembler" + } + + assertThrows(IllegalArgumentException::class.java) { + activeBatch(valid, mutated) + } + } + + @Test + fun `conversion rejects unresolved external and malformed governed Patient references`() { + val invalidators = listOf<(Reference) -> Unit>( + { subject -> subject.reference = "https://external.example/Patient/42" }, + { subject -> subject.identifier = identifier(TEST_CONTEXT_IDENTIFIER_SYSTEM, "mixed-subject") }, + { subject -> + subject.reference = null + subject.type = null + subject.identifier = identifier(TEST_CONTEXT_IDENTIFIER_SYSTEM, "untyped-subject") + }, + ) + + invalidators.forEach { invalidate -> + val valid = converter.convert(stepRecord(), convertedAt) + val observations = valid.observations.onEach { invalidate(it.subject) } + val bundle = valid.bundle.apply { + invalidate((entry.single { it.resource is Observation }.resource as Observation).subject) + } + assertThrows(IllegalArgumentException::class.java) { + HealthConnectConversion( + conversionContractVersion = valid.conversionContractVersion, + sourceRecordIdentifier = valid.sourceRecordIdentifier, + sourceRecordType = valid.sourceRecordType, + sourceLastModified = valid.sourceLastModified, + observations = observations, + provenance = valid.provenance, + bundle = bundle, + ) + } + } + } + + @Test + fun `a non-ASCII record id is accepted but never disclosed on the wire`() { + val result = converter.convert( + StepsRecord( + startTime = Instant.parse("2026-08-19T16:00:00Z"), + startZoneOffset = ZoneOffset.UTC, + endTime = Instant.parse("2026-08-19T17:00:00Z"), + endZoneOffset = ZoneOffset.UTC, + count = 1, + metadata = metadata(Metadata.autoRecorded(device), id = "héal记录"), + ), + convertedAt, + ) + + assertThat(result.observationIdentifiers.single().value) + .matches("v2:test-key:1:[A-Za-z0-9_-]{43}") + assertThat(HealthConnectWireFormat.bundleJson(result.bundle)).doesNotContain("héal记录") + } + + @Test + fun `malformed source UTF-16 is a typed rejection instead of an uncaught identity failure`() { + val sourceTime = Instant.parse("2026-08-19T15:15:00Z") + val malformedMetadata = listOf( + metadata(Metadata.manualEntry(), id = "\uD800"), + Metadata.manualEntry().populatedWithTestValues( + id = "invalid-package", + dataOrigin = DataOrigin("\uDC00"), + lastModifiedTime = Instant.parse("2026-08-19T17:30:01Z"), + ), + Metadata.activelyRecorded( + device = device, + clientRecordId = "\uD800", + clientRecordVersion = 1, + ).populatedWithTestValues( + id = "invalid-client-record", + dataOrigin = DataOrigin("com.example.source"), + lastModifiedTime = Instant.parse("2026-08-19T17:30:01Z"), + ), + ) + + malformedMetadata.forEachIndexed { index, metadata -> + val outcome = converter.convertOutcome( + WeightRecord( + time = sourceTime, + zoneOffset = ZoneOffset.UTC, + weight = Mass.kilograms(68.4), + metadata = metadata, + ), + convertedAt, + EventSequence((index + 1).toString()), + ) + + assertThat(outcome).isInstanceOf(HealthConnectConversionOutcome.Rejected::class.java) + assertThat((outcome as HealthConnectConversionOutcome.Rejected).reason).contains("unpaired") + } + } + + @Test + fun `malformed retained source text is a typed rejection`() { + val outcome = converter.convertOutcome( + exerciseSession( + exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_RUNNING, + id = "invalid-title", + title = "\uD800", + ), + convertedAt, + EventSequence("1"), + ) + + assertThat(outcome).isInstanceOf(HealthConnectConversionOutcome.Rejected::class.java) + assertThat((outcome as HealthConnectConversionOutcome.Rejected).reason) + .contains("ExerciseSessionRecord.title contains an unpaired high surrogate") + } + + @Test + fun `gives same-instant heart-rate samples distinct occurrence-keyed identities`() { + val start = Instant.parse("2026-08-19T17:30:00Z") + val record = heartRateRecord( + start = start, + samples = listOf( + HeartRateRecord.Sample(start.plusSeconds(15), 72), + HeartRateRecord.Sample(start.plusSeconds(15), 75), + HeartRateRecord.Sample(start.plusSeconds(15), 72), + ), + ) + + val observations = converter.convert(record, convertedAt).observations + + val identities = observations.map { outputIdentifier(it).value } + assertThat(identities).containsNoDuplicates() + assertThat(identities).containsExactlyElementsIn( + converter.convert(record, convertedAt).observations.map { outputIdentifier(it).value }, + ).inOrder() + assertThat(identities.all { it.matches(Regex("v2:test-key:1:[A-Za-z0-9_-]{43}")) }).isTrue() + assertThat(observations.map { it.effectiveDateTimeType.valueAsString }.distinct()) + .containsExactly("2026-08-19T17:30:15Z") + } + + @Test + fun `source-list occurrence allocation preserves exact platform order`() { + val sourceOrder = listOf( + "same:first", + "other:only", + "same:corrected", + "same:last", + ) + + val identified = assignSourceListOccurrences(sourceOrder) { it.substringBefore(':') } + + assertThat(identified.map { it.first }).containsExactlyElementsIn(sourceOrder).inOrder() + assertThat(identified.map { it.second }).containsExactly(0, 0, 1, 2).inOrder() + } + + @Test + fun `same-instant sample correction retains each source-list occurrence identity`() { + val start = Instant.parse("2026-08-19T17:30:00Z") + val sampleTime = start.plusSeconds(15) + val original = heartRateRecord( + start = start, + samples = listOf( + HeartRateRecord.Sample(sampleTime, 80), + HeartRateRecord.Sample(sampleTime, 70), + ), + ) + val corrected = heartRateRecord( + start = start, + samples = listOf( + HeartRateRecord.Sample(sampleTime, 60), + HeartRateRecord.Sample(sampleTime, 70), + ), + ) + + val originalOutputs = converter.convert(original, convertedAt).observations + val correctedOutputs = converter.convert(corrected, convertedAt).observations + + assertThat(correctedOutputs.map { outputIdentifier(it).value }) + .containsExactlyElementsIn(originalOutputs.map { outputIdentifier(it).value }) + .inOrder() + assertThat(originalOutputs.map { it.valueQuantity.value.toLong() }).containsExactly(80L, 70L).inOrder() + assertThat(correctedOutputs.map { it.valueQuantity.value.toLong() }).containsExactly(60L, 70L).inOrder() + } +} diff --git a/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectR4ConverterIdentityContextTest.kt b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectR4ConverterIdentityContextTest.kt new file mode 100644 index 00000000..ead9634d --- /dev/null +++ b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectR4ConverterIdentityContextTest.kt @@ -0,0 +1,281 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import androidx.health.connect.client.feature.ExperimentalMindfulnessSessionApi +import androidx.health.connect.client.records.BloodGlucoseRecord +import androidx.health.connect.client.records.StepsRecord +import androidx.health.connect.client.records.WeightRecord +import androidx.health.connect.client.records.metadata.Metadata +import androidx.health.connect.client.units.Mass +import com.google.common.truth.Truth.assertThat +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.Specimen +import org.junit.Assert.assertThrows +import org.junit.Test +import java.time.Instant +import java.time.ZoneOffset +import org.hl7.fhir.r4.model.Device as FhirDevice + +@OptIn(ExperimentalMindfulnessSessionApi::class) +class HealthConnectR4ConverterIdentityContextTest : HealthConnectR4ConverterTestSupport() { + @Test + fun `repository scope generator returns a complete deployment scope pair`() { + val generated = HealthConnectSynchronizationScope.generateRepositoryScope() + + assertThat(generated.system) + .matches("urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}") + assertThat(generated.value).isEqualTo("default") + assertThrows(IllegalArgumentException::class.java) { + FhirIdentifierKey("relative", "default") + } + } + + @Test + fun `recording-device stable and event identities are opaque and the snapshot is the entry key`() { + val admitted = HealthConnectRecordingDeviceResource( + stablePerUnitToken = "watch-unit-token-42", + resource = FhirDevice().apply { + meta.addProfile(HealthConnectContract.MOBILE_RECORDING_DEVICE_PROFILE) + }, + ) + val governedConverter = HealthConnectConverter( + fhirContext.copy(recordingDevice = { admitted }), + synchronizationScope, + ) + val sourceRecord = StepsRecord( + startTime = Instant.parse("2026-08-19T16:00:00Z"), + startZoneOffset = ZoneOffset.UTC, + endTime = Instant.parse("2026-08-19T17:00:00Z"), + endZoneOffset = ZoneOffset.UTC, + count = 1, + metadata = metadata(Metadata.autoRecorded(device)), + ) + val result = governedConverter.convert(sourceRecord, convertedAt) + val wire = HealthConnectWireFormat.bundleJson(result.bundle) + assertThat(wire).doesNotContain("watch-unit-token-42") + val recorderEntry = result.bundle.entry.single { entry -> + entry.resource.meta.profile.any { + it.value == HealthConnectContract.MOBILE_RECORDING_DEVICE_PROFILE + } + } + val recorder = recorderEntry.resource as FhirDevice + assertThat(recorder.identifier).hasSize(2) + assertThat(recorder.identifier.single { it.hasGroveRole(GroveIdentifierRole.RECORDING_DEVICE) }.value) + .startsWith("v2:test-key:1:") + val snapshot = recorder.identifier.single { it.hasGroveRole(GroveIdentifierRole.DEVICE_SNAPSHOT) } + assertThat(recorderEntry.fullUrl).isEqualTo(GroveExchangeIdentity.fullUrl(snapshot)) + + admitted.resource.serialNumber = "late-mutation" + assertThrows(IllegalArgumentException::class.java) { + governedConverter.convert(sourceRecord, convertedAt) + } + } + + @Test + fun `recording-device serial number and caller identifiers are rejected`() { + val hardwareIdentifier = identifier("https://hardware.example.org/devices", "watch-42") + + assertThrows(IllegalArgumentException::class.java) { + HealthConnectRecordingDeviceResource( + stablePerUnitToken = "watch-unit-token-42", + resource = FhirDevice().apply { + addIdentifier(hardwareIdentifier.copy()) + serialNumber = "globally-linkable-serial" + }, + ) + } + } + + @Test + fun `recording Device is omitted when deployment has no governed stable unit evidence`() { + val noRecorder = HealthConnectConverter( + fhirContext.copy(recordingDevice = { null }), + synchronizationScope, + ).convert(stepRecord(), convertedAt) + + val devices = noRecorder.bundle.entry.map { it.resource }.filterIsInstance() + assertThat(devices.none { device -> + device.meta.profile.any { it.value == HealthConnectContract.MOBILE_RECORDING_DEVICE_PROFILE } + }).isTrue() + assertThat(noRecorder.observations.single().hasDevice()).isFalse() + } + + @Test + fun `host and applications are separate event snapshots linked by Device parent`() { + val hostToken = "governed-host-token" + val hostIdentifier = HealthConnectIdentity.deviceSnapshot( + testIdentityKey(), + conversionEventIdentifier, + "host", + hostToken, + ) + val hostTemplate = HealthConnectHostDeviceResource( + sourceDeviceToken = hostToken, + resource = FhirDevice().apply { + meta.addProfile(HealthConnectContract.MOBILE_HOST_DEVICE_PROFILE) + manufacturer = "Example Host Company" + modelNumber = "Phone One" + addVersion() + .setType( + org.hl7.fhir.r4.model.CodeableConcept( + Coding( + HealthConnectContract.GROVE_APPLICATION_VERSION_TYPE, + "os-version", + "Operating system version", + ), + ), + ) + .setValue("20.1") + }, + ) + + val result = HealthConnectConverter( + fhirContext.copy(assemblerHost = hostTemplate), + synchronizationScope, + ).convert(stepRecord(), convertedAt) + val hostEntry = result.bundle.entry.single { + it.resource.meta.profile.any { profile -> + profile.value == HealthConnectContract.MOBILE_HOST_DEVICE_PROFILE + } + } + assertThat(hostEntry.fullUrl).isEqualTo(GroveExchangeIdentity.fullUrl(hostIdentifier)) + assertThat((hostEntry.resource as FhirDevice).identifier).hasSize(1) + val applications = result.bundle.entry.map { it.resource }.filterIsInstance() + .filter { device -> + device.meta.profile.any { it.value == HealthConnectContract.MOBILE_APPLICATION_DEVICE_PROFILE } + } + assertThat(applications).hasSize(1) + assertThat(applications.map { it.parent.reference }.distinct()) + .containsExactly(hostEntry.fullUrl) + assertThat(HealthConnectWireFormat.bundleJson(result.bundle)).doesNotContain(hostToken) + } + + @Test + fun `context entry identity mutation after construction fails closed`() { + (fhirContext.subject as HealthConnectPatientSubject.Bundled) + .patient.entryIdentifier.value = "mutated-patient-entry" + + assertThrows(IllegalArgumentException::class.java) { + converter.convert(stepRecord(), convertedAt) + } + } + + @Test + fun `identifier-only logical Patient stays typed and does not fabricate a Bundle node`() { + val logicalIdentifier = identifier( + "https://deployment.example/fhir/NamingSystem/patient-pseudonym", + "participant-42", + ) + val logicalContext = fhirContext.copy( + subject = HealthConnectPatientSubject.Logical(logicalIdentifier), + ) + val result = HealthConnectConverter(logicalContext, synchronizationScope).convert( + bloodGlucoseRecord(BloodGlucoseRecord.SPECIMEN_SOURCE_CAPILLARY_BLOOD, "logical-subject"), + convertedAt, + ) + val observationSubject = result.observations.single().subject + val specimenSubject = result.bundle.entry.map { it.resource }.filterIsInstance().single().subject + + assertThat(result.bundle.entry.none { it.resource is Patient }).isTrue() + listOf(observationSubject, specimenSubject).forEach { subject -> + assertThat(subject.hasReference()).isFalse() + assertThat(subject.type).isEqualTo("Patient") + assertThat(subject.identifier.system).isEqualTo(logicalIdentifier.system) + assertThat(subject.identifier.value).isEqualTo(logicalIdentifier.value) + } + assertThat(HealthConnectWireFormat.bundleJson(result.bundle)) + .doesNotContain(GroveExchangeIdentity.fullUrl(logicalIdentifier)) + } + + @Test + fun `logical Patient requires a complete absolute-system identifier and is revalidated`() { + assertThrows(IllegalArgumentException::class.java) { + HealthConnectPatientSubject.Logical(identifier("relative", "participant-42")) + } + val logical = HealthConnectPatientSubject.Logical( + identifier("https://deployment.example/fhir/patient", "participant-42"), + ) + val logicalConverter = HealthConnectConverter(fhirContext.copy(subject = logical), synchronizationScope) + logical.identifier.value = "" + + assertThrows(IllegalArgumentException::class.java) { + logicalConverter.convert(stepRecord(), convertedAt) + } + } + + @Test + fun `static context resource mutation after construction is revalidated`() { + fhirContext.assembler.resource.meta.profile.clear() + + assertThrows(IllegalArgumentException::class.java) { + converter.convert(stepRecord(), convertedAt) + } + } + + @Test + fun `recording callback cannot invalidate a previously checked static resource`() { + val mutatingContext = fhirContext.copy( + recordingDevice = { + fhirContext.assembler.resource.meta.profile.clear() + null + }, + ) + + assertThrows(IllegalArgumentException::class.java) { + HealthConnectConverter(mutatingContext, synchronizationScope) + .convert(stepRecord(), convertedAt) + } + } + + @Test + fun `conversion result protects its validated graph from caller mutation`() { + val result = converter.convert(stepRecord(), convertedAt) + val expectedSourceValue = result.sourceRecordIdentifier.value + val expectedObservation = result.observations.single() + val expectedProvenance = requireNotNull(result.provenance) + val expectedBundleJson = HealthConnectWireFormat.bundleJson(result.bundle) + + result.sourceRecordIdentifier.value = "caller-mutated-source" + result.observations.single().identifier.clear() + requireNotNull(result.provenance).target.clear() + result.bundle.entry.clear() + + assertThat(result.sourceRecordIdentifier.value).isEqualTo(expectedSourceValue) + assertThat(result.observations.single().equalsDeep(expectedObservation)).isTrue() + assertThat(requireNotNull(result.provenance).equalsDeep(expectedProvenance)).isTrue() + assertThat(HealthConnectWireFormat.bundleJson(result.bundle)).isEqualTo(expectedBundleJson) + } + + @Test + fun `record class participates in repository-scoped source identity`() { + val steps = converter.convert( + StepsRecord( + startTime = Instant.parse("2026-08-19T16:00:00Z"), + startZoneOffset = ZoneOffset.UTC, + endTime = Instant.parse("2026-08-19T17:00:00Z"), + endZoneOffset = ZoneOffset.UTC, + count = 1, + metadata = metadata(Metadata.autoRecorded(device), id = "same-raw-id"), + ), + convertedAt, + ) + val weight = converter.convert( + WeightRecord( + time = Instant.parse("2026-08-19T16:00:00Z"), + zoneOffset = ZoneOffset.UTC, + weight = Mass.kilograms(70.0), + metadata = metadata(Metadata.manualEntry(), id = "same-raw-id"), + ), + convertedAt, + ) + + assertThat(steps.sourceRecordIdentifier.value).isNotEqualTo(weight.sourceRecordIdentifier.value) + } +} diff --git a/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectR4ConverterMeasurementTest.kt b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectR4ConverterMeasurementTest.kt new file mode 100644 index 00000000..722d7ab4 --- /dev/null +++ b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectR4ConverterMeasurementTest.kt @@ -0,0 +1,558 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import androidx.health.connect.client.feature.ExperimentalMindfulnessSessionApi +import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord +import androidx.health.connect.client.records.BasalBodyTemperatureRecord +import androidx.health.connect.client.records.BasalMetabolicRateRecord +import androidx.health.connect.client.records.BodyFatRecord +import androidx.health.connect.client.records.BodyTemperatureMeasurementLocation +import androidx.health.connect.client.records.BodyTemperatureRecord +import androidx.health.connect.client.records.BodyWaterMassRecord +import androidx.health.connect.client.records.BoneMassRecord +import androidx.health.connect.client.records.DistanceRecord +import androidx.health.connect.client.records.ElevationGainedRecord +import androidx.health.connect.client.records.FloorsClimbedRecord +import androidx.health.connect.client.records.HeartRateRecord +import androidx.health.connect.client.records.HeartRateVariabilityRmssdRecord +import androidx.health.connect.client.records.HeightRecord +import androidx.health.connect.client.records.HydrationRecord +import androidx.health.connect.client.records.LeanBodyMassRecord +import androidx.health.connect.client.records.OxygenSaturationRecord +import androidx.health.connect.client.records.Record +import androidx.health.connect.client.records.RespiratoryRateRecord +import androidx.health.connect.client.records.SleepSessionRecord +import androidx.health.connect.client.records.StepsRecord +import androidx.health.connect.client.records.TotalCaloriesBurnedRecord +import androidx.health.connect.client.records.Vo2MaxRecord +import androidx.health.connect.client.records.WheelchairPushesRecord +import androidx.health.connect.client.records.metadata.Metadata +import androidx.health.connect.client.units.Energy +import androidx.health.connect.client.units.Length +import androidx.health.connect.client.units.Mass +import androidx.health.connect.client.units.Percentage +import androidx.health.connect.client.units.Power +import androidx.health.connect.client.units.Temperature +import androidx.health.connect.client.units.Volume +import com.google.common.truth.Truth.assertThat +import org.hl7.fhir.r4.model.Coding +import org.junit.Assert.assertThrows +import org.junit.Test +import java.math.BigDecimal +import java.time.Instant +import java.time.ZoneOffset +import org.hl7.fhir.r4.model.Device as FhirDevice + +@OptIn(ExperimentalMindfulnessSessionApi::class) +class HealthConnectR4ConverterMeasurementTest : HealthConnectR4ConverterTestSupport() { + @Test + @Suppress("LongMethod") + fun `maps a Health Connect step interval without changing its meaning`() { + val result = converter.convert( + StepsRecord( + startTime = Instant.parse("2026-08-19T16:00:00Z"), + startZoneOffset = ZoneOffset.ofHours(-7), + endTime = Instant.parse("2026-08-19T17:00:00Z"), + endZoneOffset = ZoneOffset.ofHours(-7), + count = 1042, + metadata = metadata(Metadata.autoRecorded(device)), + ), + convertedAt, + ) + + val observation = result.observations.single() + assertThat(observation.meta.profile.map { it.value }) + .contains(HealthConnectContract.MOBILE_STEP_COUNT_PROFILE) + assertThat(observation.code.codingFirstRep.code).isEqualTo("step-count-total") + assertThat(observation.valueQuantity.value.toLong()).isEqualTo(1042) + assertThat(observation.valueQuantity.code).isEqualTo("{steps}") + assertThat(observation.effectivePeriod.startElement.valueAsString).isEqualTo("2026-08-19T09:00:00-07:00") + assertThat(observation.effectivePeriod.endElement.valueAsString).isEqualTo("2026-08-19T10:00:00-07:00") + assertThat(observation.device.reference) + .isEqualTo(GroveExchangeIdentity.fullUrl(recordingDeviceSnapshotIdentifier)) + assertThat(observation.hasExtension(HealthConnectContract.RESEARCH_STUDY_EXTENSION)).isTrue() + assertThat( + observation.getExtensionsByUrl( + HealthConnectContract.HEALTH_CONNECT_RECORD_TYPE_EXTENSION, + ).single().value.primitiveValue(), + ).isEqualTo("StepsRecord") + assertThat( + (observation.getExtensionByUrl(HealthConnectContract.RECORDING_METHOD_EXTENSION).value as Coding).code, + ).isEqualTo("automatically-recorded") + assertThat(observation.identifier.count { it.hasGroveRole(GroveIdentifierRole.SOURCE_OUTPUT) }) + .isEqualTo(1) + assertThat(result.sourceRecordIdentifier.system) + .isEqualTo(testIdentityKey().identifierSystem(GroveOpaqueIdentityKind.SOURCE_RECORD)) + assertThat(sourceIdentifier(observation).value).startsWith("v2:test-key:1:") + assertThat(sourceIdentifier(observation).value).doesNotContain("source-record") + assertThat(outputIdentifier(observation).value).isNotEqualTo(result.sourceRecordIdentifier.value) + val provenance = requireNotNull(result.provenance) + assertThat(provenance.entityFirstRep.what.identifier.system) + .isEqualTo(result.sourceRecordIdentifier.system) + assertThat(provenance.entityFirstRep.what.identifier.value) + .isEqualTo(result.sourceRecordIdentifier.value) + assertThat(provenance.occurredPeriod.startElement.valueAsString) + .isEqualTo("2026-08-19T09:00:00-07:00") + assertThat(provenance.occurredPeriod.endElement.valueAsString) + .isEqualTo("2026-08-19T10:00:00-07:00") + assertThat(provenance.recordedElement.valueAsString).isEqualTo(convertedAt.toString()) + // The source version, not the conversion instant: an unchanged Record has to convert to + // the identical graph or the outbox stops recognising it as unchanged. + assertThat(observation.issuedElement.valueAsString).isEqualTo("2026-08-19T17:30:01Z") + assertThat(provenance.meta.profile.map { it.value }) + .containsExactly(HealthConnectContract.HEALTH_CONNECT_PROVENANCE_PROFILE) + assertThat(result.bundle.entry.map { it.fullUrl }).containsNoDuplicates() + val applicationSnapshots = result.bundle.entry.map { it.resource } + .filterIsInstance() + .filter { device -> + device.meta.profile.any { it.value == HealthConnectContract.MOBILE_APPLICATION_DEVICE_PROFILE } + } + assertThat(applicationSnapshots).hasSize(1) + assertThat(applicationSnapshots.all { device -> + device.identifier.count { it.hasGroveRole(GroveIdentifierRole.DEVICE_SNAPSHOT) } == 1 + }).isTrue() + assertThat(result.bundle.entry.map { it.resource.fhirType() }) + .containsAtLeast("Patient", "ResearchStudy", "Device", "Observation", "Provenance") + } + + @Test + fun `maps each heart-rate sample and retains one source entity`() { + val start = Instant.parse("2026-08-19T17:30:00Z") + val result = converter.convert( + HeartRateRecord( + startTime = start, + startZoneOffset = ZoneOffset.ofHours(-7), + endTime = start.plusSeconds(60), + endZoneOffset = ZoneOffset.ofHours(-7), + samples = listOf( + HeartRateRecord.Sample(start.plusSeconds(15), 72), + HeartRateRecord.Sample(start.plusSeconds(45), 75), + ), + metadata = metadata(Metadata.autoRecorded(device), id = "heart-record"), + ), + convertedAt, + ) + + assertThat(result.observations).hasSize(2) + assertThat(result.observationIdentifiers.map { it.value }).containsNoDuplicates() + result.observations.forEach { observation -> + assertThat(observation.meta.profile.map { it.value }) + .contains(HealthConnectContract.MOBILE_HEART_RATE_PROFILE) + assertThat(observation.code.codingFirstRep.code).isEqualTo("8867-4") + assertThat(observation.valueQuantity.code).isEqualTo("/min") + } + val provenance = requireNotNull(result.provenance) + assertThat(provenance.target).hasSize(2) + provenance.target.forEach { target -> + assertThat(target.reference).startsWith("urn:uuid:") + assertThat(target.identifier.hasSystem()).isTrue() + assertThat(target.identifier.hasValue()).isTrue() + } + assertThat(provenance.entity).hasSize(1) + assertThat(provenance.entityFirstRep.agentFirstRep.type.codingFirstRep.code) + .isEqualTo("enterer") + val dataOrigin = provenance.entityFirstRep.agentFirstRep.who + assertThat(dataOrigin.hasReference()).isFalse() + assertThat(dataOrigin.type).isEqualTo("Device") + assertThat(dataOrigin.identifier.system).isEqualTo(HealthConnectContract.ANDROID_PACKAGE_IDENTIFIER) + assertThat(dataOrigin.identifier.value).isEqualTo("com.example.source") + assertThat(result.bundle.entry.none { entry -> + entry.resource is FhirDevice && + (entry.resource as FhirDevice).identifier.any { identifier -> + identifier.system == HealthConnectContract.ANDROID_PACKAGE_IDENTIFIER && + identifier.value == "com.example.source" + } + }).isTrue() + assertThat(provenance.agentFirstRep.who.reference) + .isEqualTo(GroveExchangeIdentity.fullUrl(assemblerSnapshotIdentifier)) + assertThat(result.observations.map { it.device.reference }.distinct()) + .containsExactly(GroveExchangeIdentity.fullUrl(recordingDeviceSnapshotIdentifier)) + } + + @Test + fun `maps an empty zero-duration heart-rate record to a local zero-output conversion`() { + val instant = Instant.parse("2026-08-19T17:30:00Z") + val result = converter.convert( + HeartRateRecord( + startTime = instant, + startZoneOffset = ZoneOffset.UTC, + endTime = instant, + endZoneOffset = ZoneOffset.UTC, + samples = emptyList(), + metadata = metadata(Metadata.autoRecorded(device), id = "empty-heart-rate"), + ), + convertedAt, + ) + + assertThat(result.observations).isEmpty() + assertThat(result.provenance).isNull() + assertThat(result.bundle.entry.map { it.resource.fhirType() }) + .containsNoneOf("Observation", "Provenance") + } + + @Test + fun `maps sleep summary and stages without discarding Health Connect distinctions`() { + val start = Instant.parse("2026-08-19T08:00:00Z") + val result = converter.convert( + SleepSessionRecord( + startTime = start, + startZoneOffset = ZoneOffset.ofHours(-7), + endTime = start.plusSeconds(8 * 60 * 60), + endZoneOffset = ZoneOffset.ofHours(-7), + title = "Night sleep", + notes = "Participant-reported note", + stages = listOf( + SleepSessionRecord.Stage( + start, + start.plusSeconds(7 * 60 * 60), + SleepSessionRecord.STAGE_TYPE_SLEEPING, + ), + SleepSessionRecord.Stage( + start.plusSeconds(7 * 60 * 60), + start.plusSeconds(8 * 60 * 60), + SleepSessionRecord.STAGE_TYPE_AWAKE_IN_BED, + ), + ), + metadata = metadata(Metadata.autoRecorded(device), id = "sleep-record"), + ), + convertedAt, + ) + + val summary = result.observations.single { + it.meta.profile.any { profile -> profile.value == HealthConnectContract.MOBILE_SLEEP_DURATION_PROFILE } + } + val stages = result.observations.filter { + it.meta.profile.any { profile -> profile.value == HealthConnectContract.MOBILE_SLEEP_STAGE_PROFILE } + } + assertThat(stages).hasSize(2) + assertThat(summary.hasMember.map { it.reference }) + .containsExactlyElementsIn(stages.map { GroveExchangeIdentity.fullUrl(outputIdentifier(it)) }) + .inOrder() + assertThat(summary.valueQuantity.value).isEqualTo(BigDecimal("8")) + assertThat(summary.note.single().text).isEqualTo("Participant-reported note") + assertThat(summary.getExtensionByUrl(HealthConnectContract.HEALTH_CONNECT_SESSION_TITLE).value.toString()) + .isEqualTo("Night sleep") + assertThat(stages.map { it.valueCodeableConcept.coding[0].code }) + .containsExactly("asleep-unspecified", "awake") + .inOrder() + assertThat(stages.map { it.valueCodeableConcept.coding[1].code }) + .containsExactly("STAGE_TYPE_SLEEPING", "STAGE_TYPE_AWAKE_IN_BED") + .inOrder() + assertThat(stages.first().effectivePeriod.startElement.valueAsString).isEqualTo(start.toString()) + } + + @Test + @Suppress("LongMethod") + fun `normalizes every admitted scalar and interval quantity to the shared mobile contract`() { + val instant = Instant.parse("2026-08-19T16:00:00Z") + val end = instant.plusSeconds(3_600) + val sourceMetadata = metadata(Metadata.autoRecorded(device), id = "normalized-quantities") + val cases = listOf( + Triple( + ActiveCaloriesBurnedRecord( + instant, + ZoneOffset.ofHours(-7), + end, + ZoneOffset.ofHours(-7), + Energy.kilocalories(412.5), + sourceMetadata, + ), + HealthConnectContract.MOBILE_ACTIVE_ENERGY_PROFILE, + "kcal", + ), + Triple( + DistanceRecord( + instant, + ZoneOffset.UTC, + end, + ZoneOffset.UTC, + Length.kilometers(3.25), + sourceMetadata, + ), + HealthConnectContract.MOBILE_DISTANCE_PROFILE, + "m", + ), + Triple( + HeightRecord( + instant, + ZoneOffset.UTC, + Length.meters(1.82), + sourceMetadata, + ), + HealthConnectContract.MOBILE_BODY_HEIGHT_PROFILE, + "cm", + ), + Triple( + OxygenSaturationRecord( + instant, + ZoneOffset.UTC, + Percentage(98.2), + sourceMetadata, + ), + HealthConnectContract.MOBILE_OXYGEN_SATURATION_PROFILE, + "%", + ), + Triple( + RespiratoryRateRecord( + instant, + ZoneOffset.UTC, + 14.5, + sourceMetadata, + ), + HealthConnectContract.MOBILE_RESPIRATORY_RATE_PROFILE, + "/min", + ), + ) + + val observations = cases.map { (record, profile, unit) -> + converter.convert(record, convertedAt).observations.single().also { observation -> + assertThat(observation.meta.profile.map { it.value }).containsExactly( + profile, + HealthConnectContract.HEALTH_CONNECT_OBSERVATION_PROFILE, + ) + assertThat(observation.valueQuantity.system).isEqualTo(HealthConnectContract.UCUM) + assertThat(observation.valueQuantity.code).isEqualTo(unit) + } + } + + assertThat(observations[0].effectivePeriod.hasStart()).isTrue() + assertThat(observations[1].effectivePeriod.hasEnd()).isTrue() + assertThat(observations.drop(2).all { it.hasEffectiveDateTimeType() }).isTrue() + assertThat(observations[0].valueQuantity.value).isEqualTo(BigDecimal("412.5")) + assertThat(observations[1].valueQuantity.value).isEqualTo(BigDecimal("3250.0")) + assertThat(observations[2].valueQuantity.value).isEqualTo(BigDecimal("182.0")) + } + + @Test + fun `keeps basal and general body temperature as distinct shared measurements`() { + val instant = Instant.parse("2026-08-19T16:00:00Z") + val sourceMetadata = metadata(Metadata.autoRecorded(device), id = "temperature") + val basal = converter.convert( + BasalBodyTemperatureRecord( + instant, + ZoneOffset.UTC, + sourceMetadata, + Temperature.celsius(36.4), + BodyTemperatureMeasurementLocation.MEASUREMENT_LOCATION_MOUTH, + ), + convertedAt, + ).observations.single() + val general = converter.convert( + BodyTemperatureRecord( + instant, + ZoneOffset.UTC, + sourceMetadata, + Temperature.celsius(37.1), + BodyTemperatureMeasurementLocation.MEASUREMENT_LOCATION_EAR, + ), + convertedAt, + ).observations.single() + + assertThat(basal.meta.profile.map { it.value }).containsExactly( + HealthConnectContract.MOBILE_BASAL_BODY_TEMPERATURE_PROFILE, + HealthConnectContract.HEALTH_CONNECT_OBSERVATION_PROFILE, + ).inOrder() + assertThat(basal.code.codingFirstRep.system).isEqualTo(HealthConnectContract.GROVE_MOBILE_MEASUREMENT) + assertThat(basal.code.codingFirstRep.code).isEqualTo("basal-body-temperature") + assertThat(basal.bodySite.codingFirstRep.code).isEqualTo("74262004") + assertThat(general.meta.profile.map { it.value }).containsExactly( + HealthConnectContract.MOBILE_BODY_TEMPERATURE_PROFILE, + HealthConnectContract.HEALTH_CONNECT_OBSERVATION_PROFILE, + ).inOrder() + assertThat(general.code.codingFirstRep.code).isEqualTo("8310-5") + assertThat(general.bodySite.codingFirstRep.code).isEqualTo("117590005") + } + + @Test + fun `normalizes the body-composition quantities to their shared LOINC measurements`() { + val instant = Instant.parse("2026-08-19T16:00:00Z") + val sourceMetadata = metadata(Metadata.autoRecorded(device), id = "body-composition") + val cases = listOf( + Triple( + BodyFatRecord(instant, ZoneOffset.UTC, Percentage(23.4), sourceMetadata), + "41982-0" to "%", + HealthConnectContract.MOBILE_BODY_FAT_PERCENTAGE_PROFILE, + ), + Triple( + BodyWaterMassRecord(instant, ZoneOffset.UTC, Mass.kilograms(41.2), sourceMetadata), + "101683-1" to "kg", + HealthConnectContract.MOBILE_BODY_WATER_MASS_PROFILE, + ), + Triple( + BoneMassRecord(instant, ZoneOffset.UTC, Mass.kilograms(3.1), sourceMetadata), + "101685-6" to "kg", + HealthConnectContract.MOBILE_BONE_MASS_PROFILE, + ), + Triple( + LeanBodyMassRecord(instant, ZoneOffset.UTC, Mass.kilograms(54.8), sourceMetadata), + "91557-9" to "kg", + HealthConnectContract.MOBILE_LEAN_BODY_MASS_PROFILE, + ), + ) + + cases.forEach { (record, codes, profile) -> + val (loinc, unit) = codes + val observation = converter.convert(record, convertedAt).observations.single() + assertThat(observation.meta.profile.map { it.value }).containsExactly( + profile, + HealthConnectContract.HEALTH_CONNECT_OBSERVATION_PROFILE, + ).inOrder() + assertThat(observation.code.codingFirstRep.system).isEqualTo(HealthConnectContract.LOINC) + assertThat(observation.code.codingFirstRep.code).isEqualTo(loinc) + assertThat(observation.valueQuantity.code).isEqualTo(unit) + assertThat(observation.categoryFirstRep.codingFirstRep.code).isEqualTo("vital-signs") + assertThat(observation.hasEffectiveDateTimeType()).isTrue() + } + } + + @Test + fun `maps the adapter-owned quantity records with a single Health Connect profile claim`() { + val instant = Instant.parse("2026-08-19T16:00:00Z") + val end = instant.plusSeconds(3_600) + val sourceMetadata = metadata(Metadata.autoRecorded(device), id = "adapter-owned-quantities") + val basalMetabolicRate = converter.convert( + BasalMetabolicRateRecord(instant, ZoneOffset.UTC, Power.kilocaloriesPerDay(1585.5), sourceMetadata), + convertedAt, + ).observations.single() + val elevation = converter.convert( + ElevationGainedRecord(instant, ZoneOffset.UTC, end, ZoneOffset.UTC, Length.meters(-12.5), sourceMetadata), + convertedAt, + ).observations.single() + val totalEnergy = converter.convert( + TotalCaloriesBurnedRecord( + instant, + ZoneOffset.UTC, + end, + ZoneOffset.UTC, + Energy.kilocalories(2101.25), + sourceMetadata, + ), + convertedAt, + ).observations.single() + + assertThat(basalMetabolicRate.meta.profile.map { it.value }) + .containsExactly(HealthConnectContract.HEALTH_CONNECT_BASAL_METABOLIC_RATE_PROFILE) + assertThat(basalMetabolicRate.code.codingFirstRep.system) + .isEqualTo(HealthConnectContract.HEALTH_CONNECT_MEASUREMENT) + assertThat(basalMetabolicRate.code.codingFirstRep.code).isEqualTo("basal-metabolic-rate") + assertThat(basalMetabolicRate.valueQuantity.code).isEqualTo("kcal/d") + assertThat(basalMetabolicRate.valueQuantity.value).isEqualTo(BigDecimal("1585.5")) + assertThat(basalMetabolicRate.hasEffectiveDateTimeType()).isTrue() + assertThat(elevation.meta.profile.map { it.value }) + .containsExactly(HealthConnectContract.HEALTH_CONNECT_ELEVATION_GAINED_PROFILE) + assertThat(elevation.code.codingFirstRep.code).isEqualTo("elevation-gained") + assertThat(elevation.valueQuantity.value).isEqualTo(BigDecimal("-12.5")) + assertThat(elevation.effectivePeriod.hasStart()).isTrue() + assertThat(totalEnergy.meta.profile.map { it.value }) + .containsExactly(HealthConnectContract.HEALTH_CONNECT_TOTAL_ENERGY_PROFILE) + assertThat(totalEnergy.code.codingFirstRep.code).isEqualTo("total-energy-burned") + assertThat(totalEnergy.valueQuantity.code).isEqualTo("kcal") + } + + @Test + fun `normalizes intake activity and fitness quantities to their catalog units`() { + val instant = Instant.parse("2026-08-19T16:00:00Z") + val end = instant.plusSeconds(3_600) + val sourceMetadata = metadata(Metadata.autoRecorded(device), id = "catalog-quantities") + val fluidIntake = converter.convert( + HydrationRecord(instant, ZoneOffset.UTC, end, ZoneOffset.UTC, Volume.milliliters(330.0), sourceMetadata), + convertedAt, + ).observations.single() + val flights = converter.convert( + FloorsClimbedRecord(instant, ZoneOffset.UTC, end, ZoneOffset.UTC, 6.0, sourceMetadata), + convertedAt, + ).observations.single() + val pushes = converter.convert( + WheelchairPushesRecord(instant, ZoneOffset.UTC, end, ZoneOffset.UTC, 412, sourceMetadata), + convertedAt, + ).observations.single() + val vo2Max = converter.convert( + Vo2MaxRecord(instant, ZoneOffset.UTC, sourceMetadata, 44.1, Vo2MaxRecord.MEASUREMENT_METHOD_HEART_RATE_RATIO), + convertedAt, + ).observations.single() + val rmssd = converter.convert( + HeartRateVariabilityRmssdRecord(instant, ZoneOffset.UTC, 52.25, sourceMetadata), + convertedAt, + ).observations.single() + + assertThat(fluidIntake.code.codingFirstRep.code).isEqualTo("8985-4") + assertThat(fluidIntake.valueQuantity.code).isEqualTo("mL") + assertThat(fluidIntake.hasCategory()).isFalse() + assertThat(flights.code.codingFirstRep.code).isEqualTo("100304-5") + assertThat(flights.valueQuantity.code).isEqualTo("{flights}") + assertThat(pushes.code.codingFirstRep.code).isEqualTo("96502-0") + assertThat(pushes.valueQuantity.code).isEqualTo("{pushes}") + assertThat(pushes.valueQuantity.value.toLong()).isEqualTo(412) + assertThat(vo2Max.code.codingFirstRep.code).isEqualTo("vo2-max") + assertThat(vo2Max.valueQuantity.code).isEqualTo("mL/kg/min") + assertThat(vo2Max.method.codingFirstRep.system) + .isEqualTo(HealthConnectContract.HEALTH_CONNECT_VO2_MAX_MEASUREMENT_METHOD) + assertThat(vo2Max.method.codingFirstRep.code) + .isEqualTo("MEASUREMENT_METHOD_HEART_RATE_RATIO") + assertThat(rmssd.code.codingFirstRep.code).isEqualTo("heart-rate-variability-rmssd") + assertThat(rmssd.valueQuantity.code).isEqualTo("ms") + assertThat(rmssd.categoryFirstRep.codingFirstRep.code).isEqualTo("vital-signs") + } + + @Test + fun `catalog quantity domains admit zero and percentage endpoints but reject fractional totals`() { + val instant = Instant.parse("2026-08-19T16:00:00Z") + val end = instant.plusSeconds(3_600) + val sourceMetadata = metadata(Metadata.autoRecorded(device), id = "value-domain") + val records = listOf( + BodyFatRecord(instant, ZoneOffset.UTC, Percentage(0.0), sourceMetadata), + BodyFatRecord(instant, ZoneOffset.UTC, Percentage(100.0), sourceMetadata), + OxygenSaturationRecord(instant, ZoneOffset.UTC, Percentage(0.0), sourceMetadata), + OxygenSaturationRecord(instant, ZoneOffset.UTC, Percentage(100.0), sourceMetadata), + FloorsClimbedRecord(instant, ZoneOffset.UTC, end, ZoneOffset.UTC, 0.0, sourceMetadata), + WheelchairPushesRecord(instant, ZoneOffset.UTC, end, ZoneOffset.UTC, 0, sourceMetadata), + ) + + assertThat(records.map { converter.convert(it, convertedAt).observations.single().valueQuantity.value }) + .containsExactly( + BigDecimal("0.0"), + BigDecimal("100.0"), + BigDecimal("0.0"), + BigDecimal("100.0"), + BigDecimal("0.0"), + BigDecimal.ZERO, + ) + .inOrder() + assertThrows(InvalidHealthConnectRecord::class.java) { + converter.convert( + FloorsClimbedRecord(instant, ZoneOffset.UTC, end, ZoneOffset.UTC, 6.5, sourceMetadata), + convertedAt, + ) + } + assertThrows(InvalidHealthConnectRecord::class.java) { + HealthConnectContract.quantityValueDomains.getValue("body-fat-percentage") + .requireValue(BigDecimal("100.01"), "percentage") + } + assertThrows(InvalidHealthConnectRecord::class.java) { + HealthConnectContract.quantityValueDomains.getValue("oxygen-saturation") + .requireValue(BigDecimal("-0.01"), "percentage") + } + listOf("flights-climbed", "step-count", "wheelchair-push-count").forEach { measurement -> + assertThrows(InvalidHealthConnectRecord::class.java) { + HealthConnectContract.quantityValueDomains.getValue(measurement) + .requireValue(BigDecimal("-1"), "$measurement total") + } + } + assertThat( + HealthConnectContract.quantityValueDomains.getValue("wheelchair-push-count") + .requireValue(BigDecimal.ZERO, "total"), + ) + .isEqualTo(BigDecimal.ZERO) + assertThat( + HealthConnectContract.quantityValueDomains.getValue("step-count") + .requireValue(BigDecimal.ZERO, "total"), + ) + .isEqualTo(BigDecimal.ZERO) + } +} diff --git a/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectR4ConverterSeriesTest.kt b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectR4ConverterSeriesTest.kt new file mode 100644 index 00000000..57babbed --- /dev/null +++ b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectR4ConverterSeriesTest.kt @@ -0,0 +1,185 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import androidx.health.connect.client.records.PowerRecord +import androidx.health.connect.client.records.RestingHeartRateRecord +import androidx.health.connect.client.records.SkinTemperatureRecord +import androidx.health.connect.client.records.StepsCadenceRecord +import androidx.health.connect.client.records.metadata.Metadata +import androidx.health.connect.client.units.Power +import androidx.health.connect.client.units.Temperature +import androidx.health.connect.client.units.TemperatureDelta +import com.google.common.truth.Truth.assertThat +import org.junit.Assert.assertThrows +import org.junit.Test +import java.time.Instant +import java.time.ZoneOffset + +class HealthConnectR4ConverterSeriesTest : HealthConnectR4ConverterTestSupport() { + @Test + fun `maps resting heart rate onto its stated instant without inventing aggregation`() { + val observation = converter.convert( + RestingHeartRateRecord( + time = Instant.parse("2026-08-19T15:15:00Z"), + zoneOffset = ZoneOffset.ofHours(-7), + beatsPerMinute = 61, + metadata = metadata(Metadata.autoRecorded(device), id = "resting-heart-rate"), + ), + convertedAt, + ).observations.single() + + assertThat(observation.meta.profile.map { it.value }).containsExactly( + HealthConnectContract.MOBILE_RESTING_HEART_RATE_PROFILE, + HealthConnectContract.HEALTH_CONNECT_OBSERVATION_PROFILE, + ).inOrder() + assertThat(observation.code.coding.map { it.system to it.code }).containsExactly( + HealthConnectContract.LOINC to "40443-4", + HealthConnectContract.LOINC to "8867-4", + ).inOrder() + assertThat(observation.category.single().coding.single().system) + .isEqualTo(HealthConnectContract.OBSERVATION_CATEGORY) + assertThat(observation.category.single().coding.single().code).isEqualTo("vital-signs") + assertThat(observation.effectiveDateTimeType.valueAsString) + .isEqualTo("2026-08-19T08:15:00-07:00") + assertThat(observation.hasMethod()).isFalse() + assertThat(observation.valueQuantity.value.toLong()).isEqualTo(61) + assertThat(observation.valueQuantity.code).isEqualTo("/min") + } + + @Test + fun `maps each power sample and keeps same-instant occurrence identities distinct`() { + val start = Instant.parse("2026-08-19T16:00:00Z") + val result = converter.convert( + PowerRecord( + startTime = start, + startZoneOffset = ZoneOffset.ofHours(-7), + endTime = start.plusSeconds(60), + endZoneOffset = ZoneOffset.ofHours(-7), + samples = listOf( + PowerRecord.Sample(start.plusSeconds(15), Power.watts(215.5)), + PowerRecord.Sample(start.plusSeconds(15), Power.watts(220.0)), + PowerRecord.Sample(start.plusSeconds(45), Power.watts(215.5)), + ), + metadata = metadata(Metadata.autoRecorded(device), id = "power-series"), + ), + convertedAt, + ) + + assertThat(result.observations).hasSize(3) + assertThat(result.observationIdentifiers.map { it.value }.distinct()).hasSize(3) + result.observations.forEach { observation -> + assertThat(observation.meta.profile.map { it.value }) + .contains(HealthConnectContract.MOBILE_POWER_PROFILE) + assertThat(observation.code.codingFirstRep.code).isEqualTo("power") + assertThat(observation.valueQuantity.code).isEqualTo("W") + assertThat(observation.hasEffectiveDateTimeType()).isTrue() + } + assertThat(result.observations.map { it.valueQuantity.value.toPlainString() }) + .containsExactly("215.5", "220.0", "215.5") + .inOrder() + } + + @Test + fun `power sample identity is stable across replay and sample order`() { + val start = Instant.parse("2026-08-19T16:00:00Z") + val samples = listOf( + PowerRecord.Sample(start.plusSeconds(15), Power.watts(215.5)), + PowerRecord.Sample(start.plusSeconds(45), Power.watts(220.0)), + ) + val record = { ordered: List -> + PowerRecord( + startTime = start, + startZoneOffset = ZoneOffset.UTC, + endTime = start.plusSeconds(60), + endZoneOffset = ZoneOffset.UTC, + samples = ordered, + metadata = metadata(Metadata.autoRecorded(device), id = "power-replay"), + ) + } + + val first = converter.convert(record(samples), convertedAt).observationIdentifiers.map { it.value } + val replayed = converter.convert(record(samples.reversed()), convertedAt) + .observationIdentifiers + .map { it.value } + + assertThat(replayed).containsExactlyElementsIn(first).inOrder() + } + + @Test + fun `maps an empty steps-cadence series to a local zero-output conversion`() { + val instant = Instant.parse("2026-08-19T16:00:00Z") + val result = converter.convert( + StepsCadenceRecord( + startTime = instant, + startZoneOffset = ZoneOffset.UTC, + endTime = instant, + endZoneOffset = ZoneOffset.UTC, + samples = emptyList(), + metadata = metadata(Metadata.autoRecorded(device), id = "empty-cadence"), + ), + convertedAt, + ) + + assertThat(result.observations).isEmpty() + assertThat(result.provenance).isNull() + } + + @Test + fun `converts skin-temperature deltas against the explicit baseline`() { + val start = Instant.parse("2026-08-19T02:00:00Z") + val result = converter.convert( + SkinTemperatureRecord( + startTime = start, + startZoneOffset = ZoneOffset.UTC, + endTime = start.plusSeconds(7_200), + endZoneOffset = ZoneOffset.UTC, + metadata = metadata(Metadata.autoRecorded(device), id = "skin-temperature"), + deltas = listOf( + SkinTemperatureRecord.Delta(start.plusSeconds(600), TemperatureDelta.celsius(-0.25)), + SkinTemperatureRecord.Delta(start.plusSeconds(1_200), TemperatureDelta.celsius(0.5)), + ), + baseline = Temperature.celsius(33.5), + measurementLocation = SkinTemperatureRecord.MEASUREMENT_LOCATION_WRIST, + ), + convertedAt, + ) + + assertThat(result.observations).hasSize(2) + assertThat(result.observationIdentifiers.map { it.value }.distinct()).hasSize(2) + result.observations.forEach { observation -> + assertThat(observation.meta.profile.map { it.value }) + .contains(HealthConnectContract.MOBILE_SKIN_TEMPERATURE_PROFILE) + assertThat(observation.code.codingFirstRep.code).isEqualTo("61008-9") + assertThat(observation.valueQuantity.code).isEqualTo("Cel") + assertThat(observation.bodySite.codingFirstRep.code).isEqualTo("8205005") + } + assertThat(result.observations.map { it.valueQuantity.value.toDouble() }) + .containsExactly(33.25, 34.0) + .inOrder() + } + + @Test + fun `fails closed for skin-temperature deltas without a baseline`() { + val start = Instant.parse("2026-08-19T02:00:00Z") + val record = SkinTemperatureRecord( + startTime = start, + startZoneOffset = ZoneOffset.UTC, + endTime = start.plusSeconds(7_200), + endZoneOffset = ZoneOffset.UTC, + metadata = metadata(Metadata.autoRecorded(device), id = "skin-temperature-no-baseline"), + deltas = listOf( + SkinTemperatureRecord.Delta(start.plusSeconds(600), TemperatureDelta.celsius(-0.25)), + ), + ) + + assertThrows(InvalidHealthConnectRecord::class.java) { + converter.convert(record, convertedAt) + } + } +} diff --git a/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectR4ConverterTemporalSessionTest.kt b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectR4ConverterTemporalSessionTest.kt new file mode 100644 index 00000000..10f8fe0e --- /dev/null +++ b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectR4ConverterTemporalSessionTest.kt @@ -0,0 +1,646 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import androidx.health.connect.client.feature.ExperimentalMindfulnessSessionApi +import androidx.health.connect.client.records.ExerciseLap +import androidx.health.connect.client.records.ExerciseSegment +import androidx.health.connect.client.records.ExerciseSessionRecord +import androidx.health.connect.client.records.HeartRateRecord +import androidx.health.connect.client.records.PlannedExerciseSessionRecord +import androidx.health.connect.client.records.WeightRecord +import androidx.health.connect.client.records.metadata.DataOrigin +import androidx.health.connect.client.records.metadata.Device +import androidx.health.connect.client.records.metadata.Metadata +import androidx.health.connect.client.testing.populatedWithTestValues +import androidx.health.connect.client.units.Length +import androidx.health.connect.client.units.Mass +import com.google.common.truth.Truth.assertThat +import org.hl7.fhir.r4.model.Coding +import org.junit.Assert.assertThrows +import org.junit.Test +import java.math.BigDecimal +import java.time.Instant +import java.time.ZoneOffset + +@OptIn(ExperimentalMindfulnessSessionApi::class) +class HealthConnectR4ConverterTemporalSessionTest : HealthConnectR4ConverterTestSupport() { + @Test + fun `maps manual body weight to the standard profile`() { + val result = converter.convert( + WeightRecord( + time = Instant.parse("2026-08-19T15:15:00Z"), + zoneOffset = ZoneOffset.ofHours(-7), + weight = Mass.kilograms(68.4), + metadata = metadata(Metadata.manualEntry(), id = "weight-record"), + ), + convertedAt, + ) + + val observation = result.observations.single() + assertThat(observation.meta.profile.map { it.value }) + .contains(HealthConnectContract.MOBILE_BODY_WEIGHT_PROFILE) + assertThat(observation.code.codingFirstRep.code).isEqualTo("29463-7") + assertThat(observation.valueQuantity.value.toDouble()).isWithin(0.0001).of(68.4) + assertThat(observation.valueQuantity.code).isEqualTo("kg") + val method = observation.getExtensionByUrl(HealthConnectContract.RECORDING_METHOD_EXTENSION) + assertThat((method.value as Coding).code).isEqualTo("manual-entry") + assertThat(observation.hasDevice()).isFalse() + } + + @Test + fun `accepts both exact FHIR fourteen-hour offset boundaries`() { + val effectiveTimes = listOf(ZoneOffset.ofHours(-14), ZoneOffset.ofHours(14)).mapIndexed { index, offset -> + converter.convert( + WeightRecord( + time = Instant.parse("2026-08-19T15:15:00Z"), + zoneOffset = offset, + weight = Mass.kilograms(68.4), + metadata = metadata(Metadata.manualEntry(), id = "boundary-offset-$index"), + ), + convertedAt, + ).observations.single().effectiveDateTimeType.valueAsString + } + + assertThat(effectiveTimes).containsExactly( + "2026-08-19T01:15:00-14:00", + "2026-08-20T05:15:00+14:00", + ).inOrder() + } + + @Test + fun `canonicalizes Mobile effective instants to milliseconds with half-even rounding`() { + val cases = listOf( + "1970-01-01T00:00:00.000499999Z" to "1970-01-01T00:00:00Z", + "1970-01-01T00:00:00.000500000Z" to "1970-01-01T00:00:00Z", + "1970-01-01T00:00:00.001500000Z" to "1970-01-01T00:00:00.002Z", + "1969-12-31T23:59:59.999500000Z" to "1970-01-01T00:00:00Z", + "1969-12-31T23:59:59.998500000Z" to "1969-12-31T23:59:59.998Z", + ) + + val actual = cases.mapIndexed { index, (input, _) -> + converter.convert( + WeightRecord( + time = Instant.parse(input), + zoneOffset = null, + weight = Mass.kilograms(68.4), + metadata = metadata(Metadata.manualEntry(), id = "millisecond-rounding-$index"), + ), + convertedAt, + ).observations.single().effectiveDateTimeType.valueAsString + } + + assertThat(actual).containsExactlyElementsIn(cases.map { it.second }).inOrder() + } + + @Test + fun `preserves the source offset after Mobile millisecond canonicalization`() { + val effective = converter.convert( + WeightRecord( + time = Instant.parse("2026-08-20T15:30:00.251499999Z"), + zoneOffset = ZoneOffset.ofHours(-7), + weight = Mass.kilograms(68.4), + metadata = metadata(Metadata.manualEntry(), id = "millisecond-offset"), + ), + convertedAt, + ).observations.single().effectiveDateTimeType.valueAsString + + assertThat(effective).isEqualTo("2026-08-20T08:30:00.251-07:00") + } + + @Test + fun `rejects offsets outside the FHIR minute precision and fourteen-hour range`() { + val invalidOffsets = listOf( + ZoneOffset.ofHoursMinutes(14, 1), + ZoneOffset.ofHoursMinutes(-14, -1), + ZoneOffset.ofHoursMinutesSeconds(5, 30, 15), + ZoneOffset.ofHours(18), + ZoneOffset.ofHours(-18), + ) + + invalidOffsets.forEachIndexed { index, offset -> + assertThrows(InvalidHealthConnectRecord::class.java) { + converter.convert( + WeightRecord( + time = Instant.parse("2026-08-19T15:15:00Z"), + zoneOffset = offset, + weight = Mass.kilograms(68.4), + metadata = metadata(Metadata.manualEntry(), id = "invalid-offset-$index"), + ), + convertedAt, + ) + } + } + } + + @Test + fun `rejects effective source and conversion instants outside the FHIR year range`() { + listOf( + Instant.parse("0000-12-31T23:59:59Z"), + Instant.parse("+10000-01-01T00:00:00Z"), + ).forEachIndexed { index, invalidTime -> + assertThrows(InvalidHealthConnectRecord::class.java) { + converter.convert( + WeightRecord( + time = invalidTime, + zoneOffset = ZoneOffset.UTC, + weight = Mass.kilograms(68.4), + metadata = metadata(Metadata.manualEntry(), id = "invalid-effective-$index"), + ), + convertedAt, + ) + } + } + + val normal = WeightRecord( + time = Instant.parse("2026-08-19T15:15:00Z"), + zoneOffset = ZoneOffset.UTC, + weight = Mass.kilograms(68.4), + metadata = metadata(Metadata.manualEntry(), id = "invalid-conversion-time"), + ) + assertThrows(InvalidHealthConnectRecord::class.java) { + converter.convert(normal, Instant.parse("+10000-01-01T00:00:00Z")) + } + } + + @Test + fun `rejects a local date that crosses the four-digit year after applying an offset`() { + val record = WeightRecord( + time = Instant.parse("9999-12-31T12:00:00Z"), + zoneOffset = ZoneOffset.ofHours(14), + weight = Mass.kilograms(68.4), + metadata = metadata(Metadata.manualEntry(), id = "offset-crosses-year"), + ) + + assertThrows(InvalidHealthConnectRecord::class.java) { + converter.convert(record, convertedAt) + } + } + + @Test + fun `rejects issued and heart-rate sample instants outside the FHIR year range`() { + val futureMetadata = Metadata.manualEntry().populatedWithTestValues( + id = "invalid-issued", + dataOrigin = DataOrigin("com.example.source"), + lastModifiedTime = Instant.parse("+10000-01-01T00:00:00Z"), + ) + assertThrows(InvalidHealthConnectRecord::class.java) { + converter.convert( + WeightRecord( + time = Instant.parse("2026-08-19T15:15:00Z"), + zoneOffset = ZoneOffset.UTC, + weight = Mass.kilograms(68.4), + metadata = futureMetadata, + ), + HealthConnectWireFormat.MAX_FHIR_INSTANT, + ) + } + + val invalidSampleTime = Instant.parse("0000-12-31T23:59:59Z") + assertThrows(InvalidHealthConnectRecord::class.java) { + converter.convert( + HeartRateRecord( + startTime = invalidSampleTime, + startZoneOffset = ZoneOffset.UTC, + endTime = invalidSampleTime.plusSeconds(1), + endZoneOffset = ZoneOffset.UTC, + samples = listOf(HeartRateRecord.Sample(invalidSampleTime, 72)), + metadata = metadata(Metadata.autoRecorded(device), id = "invalid-sample"), + ), + convertedAt, + ) + } + } + + @Test + fun `maps active recording only when Health Connect states it`() { + val result = converter.convert( + WeightRecord( + time = Instant.parse("2026-08-19T15:15:00Z"), + zoneOffset = ZoneOffset.UTC, + weight = Mass.kilograms(68.4), + metadata = metadata(Metadata.activelyRecorded(device), id = "active-weight"), + ), + convertedAt, + ) + + val method = result.observations.single() + .getExtensionByUrl(HealthConnectContract.RECORDING_METHOD_EXTENSION) + assertThat((method.value as Coding).code).isEqualTo("actively-recorded") + } + + @Test + fun `omits unknown capture mode rather than inventing one`() { + val result = converter.convert( + WeightRecord( + time = Instant.parse("2026-08-19T15:15:00Z"), + zoneOffset = null, + weight = Mass.kilograms(68.4), + metadata = metadata(Metadata.unknownRecordingMethod(), id = "unknown-method"), + ), + convertedAt, + ) + + assertThat( + result.observations.single().hasExtension(HealthConnectContract.RECORDING_METHOD_EXTENSION), + ).isFalse() + } + + @Test + fun `fails closed for an unmapped Health Connect type`() { + val unsupported = PlannedExerciseSessionRecord( + startTime = Instant.parse("2026-08-19T15:15:00Z"), + startZoneOffset = ZoneOffset.UTC, + endTime = Instant.parse("2026-08-19T16:15:00Z"), + endZoneOffset = ZoneOffset.UTC, + metadata = metadata(Metadata.unknownRecordingMethod(), id = "planned-exercise-session"), + blocks = emptyList(), + exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_RUNNING, + title = null, + notes = null, + ) + + val refusal = assertThrows(UnsupportedHealthConnectRecord::class.java) { + converter.convert(unsupported, convertedAt) + } + assertThat(refusal).hasMessageThat().contains("PlannedExerciseSessionRecord") + + val outcome = converter.convertOutcome(unsupported, convertedAt, EventSequence("1")) + assertThat(outcome).isInstanceOf(HealthConnectConversionOutcome.Unsupported::class.java) + assertThat((outcome as HealthConnectConversionOutcome.Unsupported).sourceType) + .isEqualTo("PlannedExerciseSessionRecord") + } + + @Test + fun `maps a shared exercise type onto the shared workout activity`() { + val start = Instant.parse("2026-08-19T08:00:00Z") + val result = converter.convert( + exerciseSession( + exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_RUNNING, + id = "workout-running", + title = "Morning run", + notes = "Participant-reported note", + ), + convertedAt, + ) + + val workout = result.observations.single() + assertThat(workout.meta.profile.map { it.value }) + .containsExactly( + HealthConnectContract.MOBILE_WORKOUT_PROFILE, + HealthConnectContract.HEALTH_CONNECT_OBSERVATION_PROFILE, + ) + .inOrder() + assertThat(workout.code.codingFirstRep.code).isEqualTo("workout") + assertThat(workout.effectivePeriod.startElement.valueAsString).isEqualTo(start.toString()) + assertThat(workout.effectivePeriod.endElement.valueAsString).isEqualTo(start.plusSeconds(3_600).toString()) + assertThat(workout.valueCodeableConcept.coding.map { it.system to it.code }) + .containsExactly( + HealthConnectContract.GROVE_WORKOUT_ACTIVITY to "running", + HealthConnectContract.HEALTH_CONNECT_EXERCISE_TYPE to "EXERCISE_TYPE_RUNNING", + ) + .inOrder() + assertThat(workout.getExtensionByUrl(HealthConnectContract.HEALTH_CONNECT_SESSION_TITLE).value.toString()) + .isEqualTo("Morning run") + assertThat(workout.note.single().text).isEqualTo("Participant-reported note") + } + + @Test + fun `absorbs a long-tail exercise type into other while retaining its exact token`() { + val result = converter.convert( + exerciseSession( + exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_SURFING, + id = "workout-surfing", + ), + convertedAt, + ) + + val workout = result.observations.single() + assertThat(workout.valueCodeableConcept.coding.map { it.code }) + .containsExactly("other", "EXERCISE_TYPE_SURFING") + .inOrder() + assertThat(workout.valueCodeableConcept.coding[1].system) + .isEqualTo(HealthConnectContract.HEALTH_CONNECT_EXERCISE_TYPE) + assertThat(workout.note).isEmpty() + } + + @Test + @Suppress("LongMethod") + fun `fans a session out into one workout-segment child per segment and lap`() { + val start = Instant.parse("2026-08-19T08:00:00Z") + val result = converter.convert( + exerciseSession( + exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT, + id = "workout-with-children", + segments = listOf( + ExerciseSegment( + start, + start.plusSeconds(1_800), + ExerciseSegment.EXERCISE_SEGMENT_TYPE_SQUAT, + 12, + ), + ExerciseSegment( + start.plusSeconds(1_800), + start.plusSeconds(2_400), + ExerciseSegment.EXERCISE_SEGMENT_TYPE_PAUSE, + 0, + ), + ), + laps = listOf( + ExerciseLap(start, start.plusSeconds(900), Length.meters(400.0)), + ExerciseLap(start.plusSeconds(900), start.plusSeconds(1_800), null), + ), + ), + convertedAt, + ) + + val workout = result.observations.single { + it.meta.profile.any { profile -> profile.value == HealthConnectContract.MOBILE_WORKOUT_PROFILE } + } + val children = result.observations.filter { + it.meta.profile.any { profile -> profile.value == HealthConnectContract.MOBILE_WORKOUT_SEGMENT_PROFILE } + } + assertThat(children).hasSize(4) + assertThat(workout.hasMember.map { it.reference }) + .containsExactlyElementsIn(children.map { GroveExchangeIdentity.fullUrl(outputIdentifier(it)) }) + .inOrder() + assertThat(children.map { it.valueCodeableConcept.coding[0].system to it.valueCodeableConcept.coding[0].code }) + .containsExactly( + HealthConnectContract.GROVE_WORKOUT_ACTIVITY to "strength-training", + HealthConnectContract.GROVE_WORKOUT_SEGMENT_TYPE to "pause", + HealthConnectContract.GROVE_WORKOUT_SEGMENT_TYPE to "lap", + HealthConnectContract.GROVE_WORKOUT_SEGMENT_TYPE to "lap", + ) + .inOrder() + assertThat(children.map { it.valueCodeableConcept.coding[1].code }) + .containsExactly( + "EXERCISE_SEGMENT_TYPE_SQUAT", + "EXERCISE_SEGMENT_TYPE_PAUSE", + "EXERCISE_LAP", + "EXERCISE_LAP", + ) + .inOrder() + assertThat(children.map { child -> child.component.map { it.code.codingFirstRep.code } }) + .containsExactly(listOf("repetitions"), emptyList(), listOf("lap-length"), emptyList()) + .inOrder() + assertThat(children.first().componentFirstRep.valueQuantity.value).isEqualTo(BigDecimal("12")) + assertThat(children[2].componentFirstRep.valueQuantity.value).isEqualTo(BigDecimal("400.0")) + assertThat(children[2].componentFirstRep.valueQuantity.code).isEqualTo("m") + assertThat(children.first().effectivePeriod.startElement.valueAsString).isEqualTo(start.toString()) + } + + @Test + fun `derives a stable workout-segment identity from its exact interval and token`() { + val start = Instant.parse("2026-08-19T08:00:00Z") + val record = exerciseSession( + exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT, + id = "workout-stable-identity", + segments = listOf( + ExerciseSegment(start, start.plusSeconds(900), ExerciseSegment.EXERCISE_SEGMENT_TYPE_RUNNING, 0), + ExerciseSegment( + start.plusSeconds(900), + start.plusSeconds(1_800), + ExerciseSegment.EXERCISE_SEGMENT_TYPE_RUNNING, + 0, + ), + ), + laps = listOf(ExerciseLap(start, start.plusSeconds(900), Length.meters(400.0))), + ) + + val first = converter.convert(record, convertedAt).observations.map { outputIdentifier(it).value } + val second = converter.convert(record, convertedAt).observations.map { outputIdentifier(it).value } + + assertThat(first).isEqualTo(second) + assertThat(first.distinct()).hasSize(4) + assertThat(first.all { it.matches(Regex("v2:test-key:1:[A-Za-z0-9_-]{43}")) }).isTrue() + } + + @Test + fun `rejects a record that was not read back from Health Connect`() { + val record = WeightRecord( + time = Instant.parse("2026-08-19T15:15:00Z"), + zoneOffset = ZoneOffset.UTC, + weight = Mass.kilograms(68.4), + metadata = Metadata.unknownRecordingMethod(), + ) + + assertThrows(InvalidHealthConnectRecord::class.java) { + converter.convert(record, convertedAt) + } + } + + @Test + fun `carries the writer's client record identity so a revision supersedes`() { + fun weight(id: String, kilograms: Double, version: Long) = WeightRecord( + time = Instant.parse("2026-08-19T15:15:00Z"), + zoneOffset = ZoneOffset.UTC, + weight = Mass.kilograms(kilograms), + metadata = Metadata.activelyRecorded( + device = Device(type = Device.TYPE_SCALE), + clientRecordId = "scale-weighin-2026-08-19", + clientRecordVersion = version, + ).populatedWithTestValues( + id = id, + dataOrigin = DataOrigin("com.example.source"), + lastModifiedTime = Instant.parse("2026-08-19T17:30:01Z"), + ), + ) + + // The same logical measurement re-imported: Health Connect stores a new metadata.id, so + // only the client record identity ties the two together. + val first = converter.convert(weight("weight-v1", 68.4, 1), convertedAt, EventSequence("1")) + val revision = converter.convert(weight("weight-v2", 68.9, 2), convertedAt, EventSequence("2")) + + fun clientRecordId(conversion: HealthConnectConversion) = conversion.observations.single() + .identifier + .single { it.hasGroveRole(GroveIdentifierRole.WRITER_RECORD) } + .value + fun clientRecordVersion(conversion: HealthConnectConversion) = conversion.observations.single() + .getExtensionByUrl(HealthConnectContract.WRITER_RECORD_VERSION) + .value.primitiveValue() + + // Scoped to the writer: two apps choosing the same id stay distinct measurements. + assertThat(clientRecordId(first)).startsWith("v2:test-key:1:") + assertThat(clientRecordId(first)).doesNotContain("com.example.source") + assertThat(clientRecordId(first)).doesNotContain("scale-weighin") + assertThat(clientRecordId(revision)).isEqualTo(clientRecordId(first)) + assertThat(clientRecordVersion(first)).isEqualTo("1") + assertThat(clientRecordVersion(revision)).isEqualTo("2") + // The record identifiers differ, which is exactly why the client identity is needed. + assertThat(first.sourceRecordIdentifier.value).isNotEqualTo(revision.sourceRecordIdentifier.value) + } + + @Test + fun `a millisecond-scale client record version survives the wire`() { + // clientRecordVersion is a Long and writers commonly use epoch millis. Narrowing it to a + // FHIR integer wrapped 1.7e12 to a negative number, which inverted the supersession order. + val version = 1_700_000_000_000L + assertThat(version).isGreaterThan(Int.MAX_VALUE.toLong()) + + val record = WeightRecord( + time = Instant.parse("2026-08-19T15:15:00Z"), + zoneOffset = ZoneOffset.UTC, + weight = Mass.kilograms(68.4), + metadata = Metadata.activelyRecorded( + device = Device(type = Device.TYPE_SCALE), + clientRecordId = "scale-millis", + clientRecordVersion = version, + ).populatedWithTestValues( + id = "weight-millis", + dataOrigin = DataOrigin("com.example.source"), + lastModifiedTime = Instant.parse("2026-08-19T17:30:01Z"), + ), + ) + + val conversion = converter.convert(record, convertedAt, EventSequence("1")) + assertThat( + conversion.observations.single() + .getExtensionByUrl(HealthConnectContract.WRITER_RECORD_VERSION) + .value.primitiveValue(), + ).isEqualTo("1700000000000") + } + + @Test + fun `client record version preserves the complete non-negative Long domain`() { + listOf(0L, Long.MAX_VALUE).forEachIndexed { index, version -> + val record = WeightRecord( + time = Instant.parse("2026-08-19T15:15:00Z"), + zoneOffset = ZoneOffset.UTC, + weight = Mass.kilograms(68.4), + metadata = Metadata.activelyRecorded( + device = Device(type = Device.TYPE_SCALE), + clientRecordId = "scale-boundary-$index", + clientRecordVersion = version, + ).populatedWithTestValues( + id = "weight-boundary-$index", + dataOrigin = DataOrigin("com.example.source"), + lastModifiedTime = Instant.parse("2026-08-19T17:30:01Z"), + ), + ) + + val conversion = converter.convert( + record, + convertedAt, + EventSequence((index + 1).toString()), + ) + assertThat( + conversion.observations.single() + .getExtensionByUrl(HealthConnectContract.WRITER_RECORD_VERSION) + .value.primitiveValue(), + ).isEqualTo(version.toString()) + } + } + + @Test + fun `rejects a blank client record id and a negative version`() { + listOf("" to 0L, "writer-record" to -1L).forEachIndexed { index, (id, version) -> + val record = WeightRecord( + time = Instant.parse("2026-08-19T15:15:00Z"), + zoneOffset = ZoneOffset.UTC, + weight = Mass.kilograms(68.4), + metadata = metadataWithClientRecord( + recordingMethod = Metadata.RECORDING_METHOD_ACTIVELY_RECORDED, + id = "invalid-writer-$index", + dataOrigin = DataOrigin("com.example.source"), + lastModifiedTime = Instant.parse("2026-08-19T17:30:01Z"), + clientRecordId = id, + clientRecordVersion = version, + device = Device(type = Device.TYPE_SCALE), + ), + ) + + assertThrows(InvalidHealthConnectRecord::class.java) { + converter.convert(record, convertedAt, EventSequence((index + 1).toString())) + } + } + } + + @Test + fun `omits the default client record version when no client id exists`() { + val conversion = converter.convert( + WeightRecord( + time = Instant.parse("2026-08-19T15:15:00Z"), + zoneOffset = ZoneOffset.UTC, + weight = Mass.kilograms(68.4), + metadata = metadata(Metadata.manualEntry(), id = "no-writer-record"), + ), + convertedAt, + ) + + assertThat( + conversion.observations.single() + .getExtensionsByUrl(HealthConnectContract.WRITER_RECORD_VERSION), + ).isEmpty() + assertThat( + conversion.observations.single().identifier.any { + it.hasGroveRole(GroveIdentifierRole.WRITER_RECORD) + }, + ).isFalse() + } + + @Test + fun `rejects sentinel source-version time rather than exporting it as issued`() { + val record = WeightRecord( + time = Instant.parse("2026-08-19T15:15:00Z"), + zoneOffset = ZoneOffset.UTC, + weight = Mass.kilograms(68.4), + metadata = Metadata.manualEntry().populatedWithTestValues( + id = "weight-with-sentinel-version", + dataOrigin = DataOrigin("com.example.source"), + lastModifiedTime = Instant.EPOCH, + ), + ) + + assertThrows(InvalidHealthConnectRecord::class.java) { + converter.convert(record, convertedAt) + } + } + + @Test + fun `rejects a conversion event before the source version was available`() { + val record = WeightRecord( + time = Instant.parse("2026-08-19T15:15:00Z"), + zoneOffset = ZoneOffset.UTC, + weight = Mass.kilograms(68.4), + metadata = metadata(Metadata.manualEntry(), id = "future-source-version"), + ) + + assertThrows(InvalidHealthConnectRecord::class.java) { + converter.convert(record, Instant.parse("2026-08-19T17:30:00Z")) + } + } + + @Suppress("LongParameterList") + private fun metadataWithClientRecord( + recordingMethod: Int, + id: String, + dataOrigin: DataOrigin, + lastModifiedTime: Instant, + clientRecordId: String?, + clientRecordVersion: Long, + device: Device?, + ): Metadata = Metadata::class.java + .getDeclaredConstructor( + Int::class.javaPrimitiveType, + String::class.java, + DataOrigin::class.java, + Instant::class.java, + String::class.java, + Long::class.javaPrimitiveType, + Device::class.java, + ).apply { isAccessible = true } + .newInstance( + recordingMethod, + id, + dataOrigin, + lastModifiedTime, + clientRecordId, + clientRecordVersion, + device, + ) +} diff --git a/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectR4ConverterTestSupport.kt b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectR4ConverterTestSupport.kt new file mode 100644 index 00000000..208c580c --- /dev/null +++ b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/HealthConnectR4ConverterTestSupport.kt @@ -0,0 +1,253 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import androidx.health.connect.client.feature.ExperimentalMindfulnessSessionApi +import androidx.health.connect.client.records.BloodGlucoseRecord +import androidx.health.connect.client.records.ExerciseLap +import androidx.health.connect.client.records.ExerciseSegment +import androidx.health.connect.client.records.ExerciseSessionRecord +import androidx.health.connect.client.records.HeartRateRecord +import androidx.health.connect.client.records.MealType +import androidx.health.connect.client.records.Record +import androidx.health.connect.client.records.StepsRecord +import androidx.health.connect.client.records.metadata.DataOrigin +import androidx.health.connect.client.records.metadata.Device +import androidx.health.connect.client.records.metadata.Metadata +import androidx.health.connect.client.testing.populatedWithTestValues +import androidx.health.connect.client.units.BloodGlucose +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.Identifier +import org.hl7.fhir.r4.model.Observation +import org.hl7.fhir.r4.model.Patient +import org.hl7.fhir.r4.model.ResearchStudy +import java.time.Instant +import java.time.ZoneOffset +import org.hl7.fhir.r4.model.Device as FhirDevice + +@OptIn(ExperimentalMindfulnessSessionApi::class) +abstract class HealthConnectR4ConverterTestSupport { + protected val convertedAt = Instant.parse("2026-08-19T17:30:02Z") + protected val synchronizationScope = testSynchronizationScope( + repositoryScope = EXAMPLE_REPOSITORY_SCOPE, + configurationFingerprint = "all-supported-records-v1", + ) + protected val device = Device( + type = Device.TYPE_WATCH, + manufacturer = "Example Device Company", + model = "Study Watch", + ) + protected val subjectIdentifier = identifier(TEST_CONTEXT_IDENTIFIER_SYSTEM, "participant-001") + protected val assemblerIdentifier = identifier( + HealthConnectContract.ANDROID_PACKAGE_IDENTIFIER, + "edu.stanford.myheartcounts.fhir", + ) + protected val researchStudyIdentifier = identifier(TEST_CONTEXT_IDENTIFIER_SYSTEM, "my-heart-counts") + protected val conversionEventIdentifier = HealthConnectIdentity.exchange( + TEST_EVENT_SYSTEM, + TEST_PRODUCER_INSTANCE, + EventSequence("1"), + ) + protected val assemblerSnapshotIdentifier = HealthConnectIdentity.deviceSnapshot( + testIdentityKey(), + conversionEventIdentifier, + "application", + assemblerIdentifier.value, + ) + protected val recordingDeviceSnapshotIdentifier = HealthConnectIdentity.deviceSnapshot( + testIdentityKey(), + conversionEventIdentifier, + "recording-device", + "study-watch-unit-token", + ) + protected val fhirContext = HealthConnectConversionContext( + eventIdentifierSystem = TEST_EVENT_SYSTEM, + entryNodeIdentifierSystem = TEST_ENTRY_NODE_SYSTEM, + userAuthoredTextPolicy = HealthConnectUserAuthoredTextPolicy.RETAIN, + subject = HealthConnectPatientSubject.Bundled( + HealthConnectBundleResource( + subjectIdentifier, + Patient().apply { addIdentifier(subjectIdentifier.copy()) }, + ), + ), + assembler = application( + name = "My Heart Counts Android FHIR Converter", + identifierValue = "edu.stanford.myheartcounts.fhir", + version = "1.0.0", + ), + researchStudies = listOf( + HealthConnectBundleResource( + researchStudyIdentifier, + ResearchStudy().apply { + addIdentifier(researchStudyIdentifier.copy()) + status = ResearchStudy.ResearchStudyStatus.ACTIVE + }, + ), + ), + recordingDevice = { source -> + HealthConnectRecordingDeviceResource( + stablePerUnitToken = "study-watch-unit-token", + resource = FhirDevice().apply { + meta.addProfile(HealthConnectContract.MOBILE_RECORDING_DEVICE_PROFILE) + manufacturer = source.manufacturer + modelNumber = source.model + }, + ) + }, + ) + protected val converter = HealthConnectConverter(fhirContext, synchronizationScope) + + protected fun HealthConnectConverter.convert(record: Record, convertedAt: Instant): HealthConnectConversion = + convert(record, convertedAt, EventSequence("1")) + + protected fun activeBatch( + conversion: HealthConnectConversion, + bundle: Bundle, + ): HealthConnectExportBatch { + val bundleJson = HealthConnectWireFormat.bundleJson(bundle) + return HealthConnectExportBatch( + eventSequence = EventSequence(bundle.identifier.value.substringAfterLast(':')), + operation = HealthConnectExportOperation.ACTIVE, + sourceRecordIdentifier = conversion.sourceRecordIdentifier, + sourceVersion = conversion.sourceLastModified, + bundle = bundle, + bundleJson = bundleJson, + payloadSha256 = HealthConnectWireFormat.sha256(bundleJson), + ) + } + + protected fun metadata( + metadata: Metadata, + id: String = "source-record", + ): Metadata = metadata.populatedWithTestValues( + id = id, + dataOrigin = DataOrigin("com.example.source"), + lastModifiedTime = Instant.parse("2026-08-19T17:30:01Z"), + ) + + // The exercise-session primary constructor differs between the pinned compile classpath and the + // newer client forced by connect-testing; this ten-argument overload is shared by both. + @Suppress("LongParameterList") + protected fun exerciseSession( + exerciseType: Int, + id: String, + title: String? = null, + notes: String? = null, + segments: List = emptyList(), + laps: List = emptyList(), + ): ExerciseSessionRecord { + val start = Instant.parse("2026-08-19T08:00:00Z") + return ExerciseSessionRecord::class.java + .getConstructor( + Instant::class.java, + ZoneOffset::class.java, + Instant::class.java, + ZoneOffset::class.java, + Metadata::class.java, + Int::class.javaPrimitiveType, + String::class.java, + String::class.java, + List::class.java, + List::class.java, + ) + .newInstance( + start, + ZoneOffset.UTC, + start.plusSeconds(3_600), + ZoneOffset.UTC, + metadata(Metadata.autoRecorded(device), id = id), + exerciseType, + title, + notes, + segments, + laps, + ) + } + + protected fun heartRateRecord( + start: Instant, + samples: List, + ) = HeartRateRecord( + startTime = start, + startZoneOffset = ZoneOffset.ofHours(-7), + endTime = start.plusSeconds(60), + endZoneOffset = ZoneOffset.ofHours(-7), + samples = samples, + metadata = metadata(Metadata.autoRecorded(device), id = "heart-record"), + ) + + protected fun stepRecord() = StepsRecord( + startTime = Instant.parse("2026-08-19T16:00:00Z"), + startZoneOffset = ZoneOffset.UTC, + endTime = Instant.parse("2026-08-19T17:00:00Z"), + endZoneOffset = ZoneOffset.UTC, + count = 1, + metadata = metadata(Metadata.autoRecorded(device)), + ) + + protected fun bloodGlucoseRecord(specimenSource: Int, id: String) = BloodGlucoseRecord( + time = Instant.parse("2026-08-19T16:00:00Z"), + zoneOffset = ZoneOffset.UTC, + metadata = metadata(Metadata.autoRecorded(device), id = id), + level = BloodGlucose.milligramsPerDeciliter(95.5), + specimenSource = specimenSource, + mealType = androidx.health.connect.client.records.MealType.MEAL_TYPE_UNKNOWN, + relationToMeal = BloodGlucoseRecord.RELATION_TO_MEAL_UNKNOWN, + ) + + protected fun application( + name: String, + identifierValue: String, + version: String? = null, + ): HealthConnectBundleResource { + val entryIdentifier = identifier(HealthConnectContract.ANDROID_PACKAGE_IDENTIFIER, identifierValue) + return HealthConnectBundleResource( + entryIdentifier, + FhirDevice().apply { + meta.addProfile(HealthConnectContract.MOBILE_APPLICATION_DEVICE_PROFILE) + addIdentifier(entryIdentifier.copy()) + addDeviceName().setName(name).setType(FhirDevice.DeviceNameType.USERFRIENDLYNAME) + version?.let { + addVersion() + .setType( + org.hl7.fhir.r4.model.CodeableConcept( + Coding( + HealthConnectContract.MDC, + HealthConnectContract.APPLICATION_SOFTWARE_VERSION, + "MDC_ID_PROD_SPEC_SW", + ), + ), + ) + .setValue(it) + } + }, + ) + } + + protected fun identifier(system: String, value: String): Identifier = + Identifier().setSystem(system).setValue(value) + + protected fun outputIdentifier(observation: org.hl7.fhir.r4.model.Observation): Identifier = + observationIdentity(observation) + + protected fun sourceIdentifier(observation: org.hl7.fhir.r4.model.Observation): Identifier = + observation.identifier.single { + it.hasGroveRole(GroveIdentifierRole.SOURCE_RECORD) + } + + protected fun entryIdentifier(bundle: org.hl7.fhir.r4.model.Bundle, resourceType: String): Identifier = + bundle.entry.single { it.resource.fhirType() == resourceType } + .getExtensionByUrl(GroveExchangeIdentity.ENTRY_IDENTIFIER_EXTENSION) + .value as Identifier + + companion object { + const val EXAMPLE_REPOSITORY_SCOPE = "1f5c58aa-6ec6-4e79-a682-829a9debd3f5" + const val TEST_CONTEXT_IDENTIFIER_SYSTEM = "urn:uuid:8d3fd52b-efda-5f3d-b83d-50f0a70b44aa" + } +} diff --git a/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/RoomHealthConnectExportJournalTest.kt b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/RoomHealthConnectExportJournalTest.kt new file mode 100644 index 00000000..84c22da5 --- /dev/null +++ b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/RoomHealthConnectExportJournalTest.kt @@ -0,0 +1,413 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +import android.content.Context +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.withTransaction +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.time.Duration +import java.util.UUID +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.atomic.AtomicLong + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class RoomHealthConnectExportJournalTest : HealthConnectExportCoordinatorTestSupport() { + private val context = ApplicationProvider.getApplicationContext() + private val databaseNames = mutableListOf() + + @After + fun deleteDatabases() { + databaseNames.forEach(context::deleteDatabase) + } + + @Test + fun `exact pending payload and sequence survive database reopen`() = runTest { + val databaseName = databaseName() + val firstJournal = open(databaseName) + val firstSink = CaptureThenFailSink() + val firstCoordinator = HealthConnectExportCoordinator(converter, firstJournal, firstSink) + + val failure = runCatching { + firstCoordinator.upsert(stepRecord("room-replay"), conversionTime) + }.exceptionOrNull() + val failedBatch = requireNotNull(firstSink.batch) + firstJournal.close() + + assertThat(failure).isInstanceOf(IllegalStateException::class.java) + + val reopened = open(databaseName) + val retrySink = RecordingSink() + val retryCoordinator = HealthConnectExportCoordinator(converter, reopened, retrySink) + retryCoordinator.upsert(stepRecord("room-replay"), conversionTime.plusSeconds(30)) + retryCoordinator.upsert(stepRecord("room-next"), conversionTime.plusSeconds(31)) + + assertThat(retrySink.batches.map { it.eventSequence.value }).containsExactly("1", "2").inOrder() + assertThat(retrySink.batches.first().bundleJson).isEqualTo(failedBatch.bundleJson) + assertThat(retrySink.batches.first().payloadSha256).isEqualTo(failedBatch.payloadSha256) + assertThat(retrySink.batches.first().bundle.equalsDeep(failedBatch.bundle)).isTrue() + reopened.withSourceTransition( + synchronizationScope.repositoryScopeKey, + "StepsRecord", + "room-replay", + ) { lease -> + assertThat(reopened.pending(lease)).isNull() + assertThat(requireNotNull(reopened.entry(lease)).lastEventSequence?.value).isEqualTo("1") + } + reopened.close() + } + + @Test + fun `base revision compare and swap rejects stale local transition`() = runTest { + val journal = open(databaseName()) + val coordinator = HealthConnectExportCoordinator(converter, journal, RecordingSink()) + coordinator.upsert(heartRateRecord(emptyList()), conversionTime) + + journal.withSourceTransition( + synchronizationScope.repositoryScopeKey, + "HeartRateRecord", + "heart-record", + ) { lease -> + val stored = requireNotNull(journal.entry(lease)) + val failure = runCatching { journal.storeLocal(lease, null, stored) }.exceptionOrNull() + + assertThat(failure).isInstanceOf(IllegalStateException::class.java) + journal.storeLocal(lease, stored.revision, stored) + assertThat(requireNotNull(journal.entry(lease)).revision).isEqualTo(stored.revision) + } + journal.close() + } + + @Test + fun `local transition cannot bypass delivery for active outputs`() = runTest { + val journal = open(databaseName()) + val record = stepRecord("local-output-bypass") + val conversion = converter.convert(record, conversionTime, EventSequence("1")) + val entry = HealthConnectActiveExportBuilder(synchronizationScope).entry(record, conversion, null) + + journal.withSourceTransition( + synchronizationScope.repositoryScopeKey, + "StepsRecord", + "local-output-bypass", + ) { lease -> + val failure = runCatching { journal.storeLocal(lease, null, entry) }.exceptionOrNull() + + assertThat(failure).isInstanceOf(IllegalStateException::class.java) + assertThat(journal.entry(lease)).isNull() + } + journal.close() + } + + @Test + fun `two database instances serialize one source with increasing fences`() = runBlocking { + val databaseName = databaseName() + val options = shortLeaseOptions() + val firstJournal = open(databaseName, options) + val secondJournal = open(databaseName, options) + val firstEntered = CompletableDeferred() + val releaseFirst = CompletableDeferred() + + supervisorScope { + val first = async(Dispatchers.Default) { + firstJournal.withSourceTransition( + synchronizationScope.repositoryScopeKey, + "StepsRecord", + "fenced-source", + ) { lease -> + firstEntered.complete(lease.fence) + releaseFirst.await() + } + } + val firstFence = firstEntered.await() + val secondEntered = CompletableDeferred() + val second = async(Dispatchers.Default) { + secondJournal.withSourceTransition( + synchronizationScope.repositoryScopeKey, + "StepsRecord", + "fenced-source", + ) { lease -> secondEntered.complete(lease.fence) } + } + delay(BLOCKED_ASSERTION_MILLIS) + assertThat(secondEntered.isCompleted).isFalse() + + releaseFirst.complete(Unit) + first.await() + second.await() + + assertThat(secondEntered.await()).isGreaterThan(firstFence) + } + firstJournal.close() + secondJournal.close() + } + + @Test + fun `stale owner cannot stage or complete after a larger fence takes over`() = runBlocking { + val database = Room.inMemoryDatabaseBuilder( + context, + RoomHealthConnectExportDatabase::class.java, + ).build() + val clock = AtomicLong(1_800_000_000_000L) + val options = RoomHealthConnectJournalOptions( + leaseDuration = Duration.ofMillis(STALE_WRITER_LEASE_MILLIS), + unavailableLeaseRetryDelay = Duration.ofMillis(LEASE_RETRY_MILLIS), + ) + val staleOwner = RoomHealthConnectExportJournal.createForTest(database, options, clock::get) + val nextOwner = RoomHealthConnectExportJournal.createForTest(database, options, clock::get) + val record = stepRecord("stale-room-writer") + val staleEntered = CompletableDeferred>() + val releaseStale = CompletableDeferred() + + supervisorScope { + val staleTransition = async(Dispatchers.Default) { + staleOwner.withSourceTransition( + synchronizationScope.repositoryScopeKey, + "StepsRecord", + "stale-room-writer", + ) { lease -> + val pending = staleOwner.stage(lease, null) { sequence -> + val conversion = converter.convert(record, conversionTime, sequence) + HealthConnectActiveExportBuilder(synchronizationScope).draft(record, conversion, null) + } + staleEntered.complete(lease to pending) + releaseStale.await() + } + } + val (staleLease, pending) = staleEntered.await() + clock.addAndGet(STALE_WRITER_LEASE_MILLIS + 1L) + + nextOwner.withSourceTransition( + synchronizationScope.repositoryScopeKey, + "StepsRecord", + "stale-room-writer", + ) { takeoverLease -> + assertThat(takeoverLease.fence).isGreaterThan(staleLease.fence) + assertStaleWriterRejected(staleOwner, staleLease, nextOwner, takeoverLease, pending) + } + releaseStale.complete(Unit) + staleTransition.await() + } + database.close() + } + + @Test + fun `reconciliation lease admits child fence and excludes ordinary source in another instance`() = runBlocking { + val databaseName = databaseName() + val options = shortLeaseOptions() + val reconciliationJournal = open(databaseName, options) + val ordinaryJournal = open(databaseName, options) + val childEntered = CompletableDeferred>() + val releaseChild = CompletableDeferred() + val childExited = CompletableDeferred() + val releaseParent = CompletableDeferred() + + supervisorScope { + val reconciliation = async(Dispatchers.Default) { + reconciliationJournal.withReconciliationLease( + synchronizationScope.repositoryScopeKey, + "StepsRecord", + ) { parent -> + reconciliationJournal.withSourceTransition( + synchronizationScope.repositoryScopeKey, + "StepsRecord", + "reconciliation-child", + parent, + ) { child -> + childEntered.complete(parent to child) + releaseChild.await() + } + childExited.complete(Unit) + releaseParent.await() + } + } + val (parent, child) = childEntered.await() + assertThat(child.reconciliationFence).isEqualTo(parent.fence) + val ordinaryEntered = CompletableDeferred() + val ordinary = async(Dispatchers.Default) { + ordinaryJournal.withSourceTransition( + synchronizationScope.repositoryScopeKey, + "StepsRecord", + "ordinary-during-reconciliation", + ) { lease -> ordinaryEntered.complete(lease.fence) } + } + + delay(BLOCKED_ASSERTION_MILLIS) + assertThat(ordinaryEntered.isCompleted).isFalse() + releaseChild.complete(Unit) + childExited.await() + delay(BLOCKED_ASSERTION_MILLIS) + assertThat(ordinaryEntered.isCompleted).isFalse() + releaseParent.complete(Unit) + reconciliation.await() + ordinary.await() + assertThat(ordinaryEntered.await()).isGreaterThan(parent.fence) + } + reconciliationJournal.close() + ordinaryJournal.close() + } + + @Test + fun `expired orphaned lease is recovered with a larger fence after database reopen`() = runBlocking { + val databaseName = databaseName() + val options = RoomHealthConnectJournalOptions( + leaseDuration = Duration.ofMillis(PROCESS_DEATH_LEASE_MILLIS), + unavailableLeaseRetryDelay = Duration.ofMillis(LEASE_RETRY_MILLIS), + ) + val clock = AtomicLong(PROCESS_DEATH_EPOCH_MILLIS) + val orphanedFence = HealthConnectJournalFence("1") + val failedProcessDatabase = database(databaseName) + failedProcessDatabase.withTransaction { + val dao = failedProcessDatabase.journalDao() + dao.initializeCounter(RoomHealthConnectCounter(nextEventSequence = "1", nextFence = "2")) + dao.upsertSourceLease( + RoomHealthConnectSourceLease( + repositoryScopeKey = synchronizationScope.repositoryScopeKey.value, + recordType = "StepsRecord", + healthConnectId = "process-death", + owner = "terminated-process", + fence = orphanedFence.value, + reconciliationFence = null, + expiresAtEpochMillis = clock.get() + PROCESS_DEATH_LEASE_MILLIS, + ), + ) + } + failedProcessDatabase.close() + + clock.addAndGet(PROCESS_DEATH_LEASE_MILLIS + 1L) + val survivor = RoomHealthConnectExportJournal.createForTest( + database(databaseName), + options, + clock::get, + ) + val recoveredFence = survivor.withSourceTransition( + synchronizationScope.repositoryScopeKey, + "StepsRecord", + "process-death", + ) { it.fence } + + assertThat(recoveredFence).isGreaterThan(orphanedFence) + survivor.close() + } + + @Test + fun `concurrent sources across database instances receive unique global sequences`() = runTest { + val databaseName = databaseName() + val firstJournal = open(databaseName) + val secondJournal = open(databaseName) + val sink = ConcurrentRecordingSink() + val firstCoordinator = HealthConnectExportCoordinator(converter, firstJournal, sink) + val secondCoordinator = HealthConnectExportCoordinator(converter, secondJournal, sink) + + listOf( + async(Dispatchers.Default) { + firstCoordinator.upsert(stepRecord("parallel-a"), conversionTime) + }, + async(Dispatchers.Default) { + secondCoordinator.upsert(stepRecord("parallel-b"), conversionTime.plusSeconds(1)) + }, + ).awaitAll() + + assertThat(sink.batches.map { it.eventSequence.value }).containsExactly("1", "2") + assertThat(sink.batches.map { it.eventSequence }.toSet()).hasSize(2) + firstJournal.close() + secondJournal.close() + } + + private fun databaseName(): String = "room-journal-${UUID.randomUUID()}.db".also(databaseNames::add) + + private fun open( + databaseName: String, + options: RoomHealthConnectJournalOptions = RoomHealthConnectJournalOptions(), + ): RoomHealthConnectExportJournal = RoomHealthConnectExportJournal.open(context, databaseName, options) + + private fun database(databaseName: String): RoomHealthConnectExportDatabase = Room.databaseBuilder( + context, + RoomHealthConnectExportDatabase::class.java, + databaseName, + ).setJournalMode(RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING).build() + + private fun shortLeaseOptions() = RoomHealthConnectJournalOptions( + leaseDuration = Duration.ofMillis(SERIALIZATION_LEASE_MILLIS), + unavailableLeaseRetryDelay = Duration.ofMillis(LEASE_RETRY_MILLIS), + ) + + private suspend fun assertStaleWriterRejected( + staleOwner: RoomHealthConnectExportJournal, + staleLease: HealthConnectSourceTransitionLease, + nextOwner: RoomHealthConnectExportJournal, + takeoverLease: HealthConnectSourceTransitionLease, + pending: HealthConnectPendingExport, + ) { + val stageFailure = runCatching { + staleOwner.stage(staleLease, null) { + error("A stale writer must fail before constructing another draft.") + } + }.exceptionOrNull() + val acknowledged = pending.acknowledgedEntry( + pending.nextEntry.outputIdentifiers.associate { identifier -> + identifier.key() to "Observation/takeover" + }, + ) + val completeFailure = runCatching { + staleOwner.complete(staleLease, pending, acknowledged) + }.exceptionOrNull() + + assertThat(stageFailure).isInstanceOf(HealthConnectJournalLeaseLostException::class.java) + assertThat(completeFailure).isInstanceOf(HealthConnectJournalLeaseLostException::class.java) + assertThat(requireNotNull(nextOwner.pending(takeoverLease)).eventSequence) + .isEqualTo(pending.eventSequence) + nextOwner.complete(takeoverLease, pending, acknowledged) + assertThat(requireNotNull(nextOwner.entry(takeoverLease)).revision) + .isEqualTo(acknowledged.revision) + } + + private class CaptureThenFailSink : HealthConnectExportSink { + var batch: HealthConnectExportBatch? = null + + override suspend fun apply(batch: HealthConnectExportBatch): HealthConnectExportAcknowledgement { + this.batch = batch + error("Simulated sink outage after receiving the exact payload.") + } + } + + private class ConcurrentRecordingSink : HealthConnectExportSink { + val batches = ConcurrentLinkedQueue() + + override suspend fun apply(batch: HealthConnectExportBatch): HealthConnectExportAcknowledgement { + batches += batch + val destinations = batch.bundle.groveOutputIdentifiers().associate { identifier -> + identifier.key() to "Resource/destination-${identifier.value.takeLast(12)}" + } + return HealthConnectExportAcknowledgement(destinations) + } + } + + private companion object { + const val BLOCKED_ASSERTION_MILLIS = 75L + const val LEASE_RETRY_MILLIS = 10L + const val PROCESS_DEATH_EPOCH_MILLIS = 1_800_000_000_000L + const val PROCESS_DEATH_LEASE_MILLIS = 120L + const val SERIALIZATION_LEASE_MILLIS = 600L + const val STALE_WRITER_LEASE_MILLIS = 5_000L + } +} diff --git a/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/TestIdentityFixtures.kt b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/TestIdentityFixtures.kt new file mode 100644 index 00000000..59f84305 --- /dev/null +++ b/health-fhir/src/test/kotlin/org/grovealliance/health/fhir/TestIdentityFixtures.kt @@ -0,0 +1,33 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.fhir + +internal const val TEST_PRODUCER_INSTANCE = "1f5c58aa-6ec6-4e79-a682-829a9debd3f5" +internal const val TEST_EVENT_SYSTEM = "https://conformance.grovealliance.org/fhir/NamingSystem/grove-event-v2" +internal const val TEST_ENTRY_NODE_SYSTEM = "https://conformance.grovealliance.org/fhir/NamingSystem/grove-entry-node-v2" +internal const val TEST_OPAQUE_IDENTITY_SYSTEM_FAMILY = + "https://conformance.grovealliance.org/fhir/NamingSystem/grove-opaque-v2" + +internal fun testIdentityKey(): GroveHmacIdentityKey = GroveHmacIdentityKey.forConformanceTesting( + identifierSystemFamily = TEST_OPAQUE_IDENTITY_SYSTEM_FAMILY, + keyId = "test-key", + epoch = "1", + secret = ByteArray(32) { index -> index.toByte() }, +) + +internal fun testSynchronizationScope( + repositoryScope: String, + configurationFingerprint: String, + conversionContractVersion: String = HealthConnectContract.CONVERSION_CONTRACT_VERSION, +): HealthConnectSynchronizationScope = HealthConnectSynchronizationScope.createForContractVersion( + repositoryScope = FhirIdentifierKey("urn:uuid:$repositoryScope", "default"), + producerInstance = TEST_PRODUCER_INSTANCE, + configurationFingerprint = configurationFingerprint, + conversionContractVersion = conversionContractVersion, + identityKey = testIdentityKey(), +) diff --git a/health-fhir/src/test/resources/grove-exchange-protocol-test-vectors.json b/health-fhir/src/test/resources/grove-exchange-protocol-test-vectors.json new file mode 100644 index 00000000..e2c23d65 --- /dev/null +++ b/health-fhir/src/test/resources/grove-exchange-protocol-test-vectors.json @@ -0,0 +1,276 @@ +{ + "keyHex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "keyId": "test-key", + "epoch": "1", + "warning": "Public conformance key only. A production deployment must reject this key.", + "identitySystems": [ + { + "identityKind": "source-record", + "system": "https://study.example.org/fhir/NamingSystem/grove-source-record-v2/test-key/1" + }, + { + "identityKind": "source-output", + "system": "https://study.example.org/fhir/NamingSystem/grove-source-output-v2/test-key/1" + }, + { + "identityKind": "writer-record", + "system": "https://study.example.org/fhir/NamingSystem/grove-writer-record-v2/test-key/1" + }, + { + "identityKind": "provider-record", + "system": "https://study.example.org/fhir/NamingSystem/grove-provider-record-v2/test-key/1" + }, + { + "identityKind": "provider-output", + "system": "https://study.example.org/fhir/NamingSystem/grove-provider-output-v2/test-key/1" + }, + { + "identityKind": "source-artifact", + "system": "https://study.example.org/fhir/NamingSystem/grove-source-artifact-v2/test-key/1" + }, + { + "identityKind": "provider-artifact", + "system": "https://study.example.org/fhir/NamingSystem/grove-provider-artifact-v2/test-key/1" + }, + { + "identityKind": "source-context", + "system": "https://study.example.org/fhir/NamingSystem/grove-source-context-v2/test-key/1" + }, + { + "identityKind": "recording-device", + "system": "https://study.example.org/fhir/NamingSystem/grove-recording-device-v2/test-key/1" + }, + { + "identityKind": "device-snapshot", + "system": "https://study.example.org/fhir/NamingSystem/grove-device-snapshot-v2/test-key/1" + } + ], + "measurementExamples": { + "effectiveInstant": "2026-08-19T10:30:00-07:00", + "effectivePeriodStart": "2026-08-19T00:00:00-07:00", + "effectivePeriodEnd": "2026-08-20T00:00:00-07:00", + "healthConnectLastModifiedTime": "2026-08-20T08:00:00Z", + "nativeRecordPrefix": "record-", + "repositoryScope": { + "system": "https://study.example.org/fhir/NamingSystem/source-repository", + "value": "1f5c58aa-6ec6-4e79-a682-829a9debd3f5" + }, + "providerScope": { + "system": "https://study.example.org/fhir/NamingSystem/provider-account", + "value": "acct-7f3a9c" + }, + "globalProviderScope": { + "system": "https://study.example.org/fhir/NamingSystem/provider-key-space", + "value": "oura-document-id-global" + }, + "coordinates": { + "canonicalInstant": "2026-08-19T17:30:00.000000000Z", + "canonicalPeriodStart": "2026-08-19T07:00:00.000000000Z", + "canonicalPeriodEnd": "2026-08-20T07:00:00.000000000Z", + "occurrence": "0", + "sleepStageToken": "STAGE_TYPE_LIGHT", + "exerciseSegmentToken": "EXERCISE_LAP" + } + }, + "identities": [ + { + "id": "unicode-and-separator-source-record", + "identityKind": "source-record", + "components": [ + "health-connect", + "RestingHeartRateRecord", + "urn:uuid:1f5c58aa-6ec6-4e79-a682-829a9debd3f5", + "default", + "record|東京" + ], + "value": "v2:test-key:1:UKY2qgzSB8--SueGxEfOhpElzHTVJ6usIUWV_KUTD6o" + }, + { + "id": "multi-output-sample", + "identityKind": "source-output", + "components": [ + "health-connect", + "HeartRateRecord", + "urn:uuid:1f5c58aa-6ec6-4e79-a682-829a9debd3f5", + "default", + "record-heart-001", + "sample", + "2026-08-19T10:30:00.000000000Z|0" + ], + "value": "v2:test-key:1:PQCWz9dZSrJm-KrbhbkckGeowkjhSSwWDRCVuF3VfXw" + }, + { + "id": "provider-account-complete-pair", + "identityKind": "provider-record", + "components": [ + "withings", + "measure", + "https://accounts.example.org", + "patient|α", + "17348211" + ], + "value": "v2:test-key:1:p3NFdQ-hmHon98JG7cmCbLncbAmNkjkBa5sYocSr6pw" + }, + { + "id": "provider-output-domain-separated", + "identityKind": "provider-output", + "components": [ + "withings", + "getmeas:9+10", + "https://accounts.example.org", + "patient|α", + "17348211", + "blood-pressure-panel", + "single" + ], + "value": "v2:test-key:1:HjBwHRt0W3-CbhJbc7hGpWRp92zug70gt5m626T4Y2U" + }, + { + "id": "provider-artifact-domain-separated", + "identityKind": "provider-artifact", + "components": [ + "google-health-api", + "heart-rate", + "https://accounts.example.org", + "patient|α", + "recording-001", + "provider-recording", + "0" + ], + "value": "v2:test-key:1:CxLpZ4NQee12xCyJCGimrxMLEKvbRt54Kl6RTh9UsrU" + }, + { + "id": "generic-source-artifact", + "identityKind": "source-artifact", + "components": [ + "sensorkit", + "device-usage", + "https://store.example.org", + "default", + "record-001", + "native-recording", + "0" + ], + "value": "v2:test-key:1:7QkJwJojYb7w6BlKMy3TyoX-sgiPzjGJHL1euY-4SKk" + }, + { + "id": "writer-record-complete-application-pair", + "identityKind": "writer-record", + "components": [ + "https://applications.example.org", + "com.withings.wiscale2", + "logical-record-001" + ], + "value": "v2:test-key:1:N4QSlWU6sNp9ahfyfSRTUO0K_VIZZoy-Lw3JTNrDzP4" + }, + { + "id": "healthkit-medication-source-context", + "identityKind": "source-context", + "components": [ + "healthkit", + "medication-health-concept", + "urn:uuid:1f5c58aa-6ec6-4e79-a682-829a9debd3f5", + "default", + "2f8a51c6-9d34-4e07-b2f1-63c8ad905e12" + ], + "value": "v2:test-key:1:nq3ZogmXHSznC1LC1wNMm7KTQChgapPzmjmGeB9RHcw" + }, + { + "id": "recording-device-per-unit", + "identityKind": "recording-device", + "components": [ + "healthkit", + "https://study.example.org/participants", + "participant-001", + "watch-unit-token-001" + ], + "value": "v2:test-key:1:MWGV3Vfk0jfLIr0nowr_I7TAwoqGtpSkxUi1d8FxTnE" + }, + { + "id": "device-snapshot-per-event", + "identityKind": "device-snapshot", + "components": [ + "https://study.example.org/fhir/NamingSystem/grove-event-v2", + "e2:1f5c58aa-6ec6-4e79-a682-829a9debd3f5:42", + "application", + "com.example.app|1.2.3" + ], + "value": "v2:test-key:1:TUaZacIuSMAFl_WbhxvMBX4AgxIKcHw_KvbntQdXYxQ" + } + ], + "invalidIdentities": [ + { + "id": "empty-typed-component", + "identityKind": "source-record", + "components": [ + "health-connect", + "RestingHeartRateRecord", + "urn:uuid:1f5c58aa-6ec6-4e79-a682-829a9debd3f5", + "", + "record-001" + ], + "expectedError": "empty-component" + }, + { + "id": "provider-components-under-generic-record-kind", + "identityKind": "source-record", + "components": [ + "oura", + "daily_activity", + "https://accounts.example.org", + "patient-001", + "activity-001" + ], + "expectedError": "provider-kind-required" + }, + { + "id": "provider-components-under-generic-output-kind", + "identityKind": "source-output", + "components": [ + "withings", + "getmeas:9+10", + "https://accounts.example.org", + "patient-001", + "17348211", + "blood-pressure-panel", + "single" + ], + "expectedError": "provider-kind-required" + }, + { + "id": "provider-components-under-generic-artifact-kind", + "identityKind": "source-artifact", + "components": [ + "google-health-api", + "heart-rate", + "https://accounts.example.org", + "patient-001", + "recording-001", + "provider-recording", + "0" + ], + "expectedError": "provider-kind-required" + } + ], + "event": { + "system": "https://study.example.org/fhir/NamingSystem/grove-event-v2", + "producerInstance": "1f5c58aa-6ec6-4e79-a682-829a9debd3f5", + "sequence": "42", + "value": "e2:1f5c58aa-6ec6-4e79-a682-829a9debd3f5:42" + }, + "entryNode": { + "system": "https://study.example.org/fhir/NamingSystem/grove-entry-node-v2", + "role": "conversion-provenance", + "ordinal": "0", + "value": "n2:conversion-provenance:0:SwGD7C4DT5_9kgIOQ9h7W8I4UdwJPuEOnkh2TgQVwko", + "fullUrl": "urn:uuid:9908feb7-0370-5f06-a689-f8afa210eb41" + }, + "fullUrls": [ + { + "id": "unicode-value-and-escaped-system", + "system": "https://xn--fsq.example/%E8%AD%98%E5%88%A5%E5%AD%90", + "value": "café|東京", + "fullUrl": "urn:uuid:d35e4203-71f6-595c-bd1b-306b8414974e" + } + ] +} diff --git a/health-fhir/src/test/resources/grove-exchange-protocol-test-vectors.json.license b/health-fhir/src/test/resources/grove-exchange-protocol-test-vectors.json.license new file mode 100644 index 00000000..fa4e8d92 --- /dev/null +++ b/health-fhir/src/test/resources/grove-exchange-protocol-test-vectors.json.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) + +SPDX-License-Identifier: MIT diff --git a/health/build.gradle.kts b/health/build.gradle.kts index 572db40e..3c80f2a6 100644 --- a/health/build.gradle.kts +++ b/health/build.gradle.kts @@ -24,4 +24,6 @@ dependencies { implementation(project(":storage-local")) implementation(libs.androidx.fragment.ktx) implementation(libs.androidx.appcompat) + + testImplementation(libs.androidx.health.connect.testing) } diff --git a/health/src/main/kotlin/org/grovealliance/health/HealthConstraint.kt b/health/src/main/kotlin/org/grovealliance/health/HealthConstraint.kt index c6d3645e..2445e3d2 100644 --- a/health/src/main/kotlin/org/grovealliance/health/HealthConstraint.kt +++ b/health/src/main/kotlin/org/grovealliance/health/HealthConstraint.kt @@ -14,6 +14,18 @@ import org.grovealliance.core.Standard * A [Standard] extension for handling Health Connect data changes. */ interface HealthConstraint : Standard { + /** + * Stable identifier for the repository and filter/configuration projection being collected. + * + * Change this value whenever the source repository, time range, predicate, or other full-read + * membership rule changes. It namespaces durable change-token state. Implementations that do + * not maintain a synchronized projection may keep the default. + */ + fun collectionScopeId(type: RecordType): String = "default" + + /** Stable repository identity shared by every filter projection over the same source store. */ + fun collectionRepositoryId(type: RecordType): String = DEFAULT_REPOSITORY_SCOPE_ID + /** * Called when new Health Connect records of a given type are detected. */ @@ -24,11 +36,29 @@ interface HealthConstraint : Standard { */ suspend fun handleDeletedRecords(deletedRecordIds: Set, type: RecordType) + /** + * Called when an upserted Record does not match this collector's time range or predicate. + * + * Constraints that maintain a synchronized projection use this callback to invalidate a + * previously included Record. The default preserves source compatibility for constraints that + * treat collection filters as delivery-only. + */ + suspend fun handleExcludedRecords(excludedRecordIds: Set, type: RecordType) = Unit + /** * Called when Health Connect reports that the stored changes token for this - * record type has expired. In this state, incremental updates are no longer - * reliable, so the app must clear its local cache (if any) for this type and perform a - * full re-sync by fetching all records again. + * record type has expired. Incremental updates are no longer reliable, so the app must + * perform a complete scoped read and reconcile it with durable exported state before returning. + * + * Do not discard identity or deletion-journal entries before reconciliation. They are needed + * to invalidate records that no longer exist in Health Connect. The collector establishes a + * durable change-token boundary before this callback and replays every page from that boundary. + * The same callback is used to establish the first baseline for a new collection scope. */ suspend fun onFullyResyncRequired(type: RecordType) + + companion object { + /** One [org.grovealliance.health.internal.DefaultHealthClient] fronts one repository. */ + const val DEFAULT_REPOSITORY_SCOPE_ID = "health-connect-default-repository" + } } diff --git a/health/src/main/kotlin/org/grovealliance/health/RecordType.kt b/health/src/main/kotlin/org/grovealliance/health/RecordType.kt index cc51a9ab..fe1cce9e 100644 --- a/health/src/main/kotlin/org/grovealliance/health/RecordType.kt +++ b/health/src/main/kotlin/org/grovealliance/health/RecordType.kt @@ -141,6 +141,58 @@ data class RecordType( val weight = RecordType(type = WeightRecord::class, identifier = "WeightRecord") val wheelchairPushes = RecordType(type = WheelchairPushesRecord::class, identifier = "WheelchairPushesRecord") + /** + * Every Health Connect Record type exposed by this version of Grove Health. + * + * Adapter contracts use this closed inventory to prove that each source type is either + * supported or carries an explicit non-supported status. Adding a new declaration above + * therefore requires adding it here and classifying it in every adapter that claims + * inventory completeness. + */ + val all: Set = setOf( + activeCaloriesBurned, + basalBodyTemperature, + basalMetabolicRate, + bloodGlucose, + bloodPressure, + bodyFat, + bodyTemperature, + bodyWaterMass, + boneMass, + cervicalMucus, + cyclingPedalingCadence, + distance, + elevationGained, + exerciseSession, + floorsClimbed, + heartRate, + heartRateVariabilityRmssd, + height, + hydration, + intermenstrualBleeding, + leanBodyMass, + menstruationFlow, + menstruationPeriod, + mindfulnessSession, + nutrition, + ovulationTest, + oxygenSaturation, + plannedExerciseSession, + power, + respiratoryRate, + restingHeartRate, + sexualActivity, + sleepSession, + speed, + skinTemperature, + stepsCadence, + steps, + totalCaloriesBurned, + vo2Max, + weight, + wheelchairPushes, + ) + @Suppress("ComplexMethod", "LongMethod") fun from(record: Record): RecordType = when (record) { is ActiveCaloriesBurnedRecord -> activeCaloriesBurned diff --git a/health/src/main/kotlin/org/grovealliance/health/internal/ChangesTokenStore.kt b/health/src/main/kotlin/org/grovealliance/health/internal/ChangesTokenStore.kt index e243bd42..e2264d7a 100644 --- a/health/src/main/kotlin/org/grovealliance/health/internal/ChangesTokenStore.kt +++ b/health/src/main/kotlin/org/grovealliance/health/internal/ChangesTokenStore.kt @@ -7,32 +7,38 @@ package org.grovealliance.health.internal +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.Serializable import kotlinx.serialization.serializer import org.grovealliance.health.AnyRecordType +import org.grovealliance.health.HealthConstraint import org.grovealliance.storage.local.LocalStorage import org.grovealliance.storage.local.LocalStorageSetting +import java.nio.charset.StandardCharsets +import java.security.MessageDigest /** * Internal class for storing and retrieving health changes tokens. * * @property storage The [LocalStorage] instance used for storing tokens. */ +@Suppress("TooManyFunctions") internal class ChangesTokenStore( private val storage: LocalStorage, ) { + private val ownerMutex = Mutex() + /** * Stores a changes token for the specified [recordType]. * * @param recordType The [AnyRecordType] for which the token is stored. * @param token The changes token to store. */ - suspend fun storeToken(recordType: AnyRecordType, token: String) { - storage.store( - key = keyFor(recordType), - value = token, - settings = LocalStorageSetting.Unencrypted, - serializer = serializer(), - ) + suspend fun storeToken(recordType: AnyRecordType, token: String, collectionScopeId: String = "default") { + val lease = claimProjection(recordType, HealthConstraint.DEFAULT_REPOSITORY_SCOPE_ID, collectionScopeId) + storePendingBoundary(recordType, lease, token) + commitToken(recordType, lease, token) } /** @@ -41,22 +47,194 @@ internal class ChangesTokenStore( * @param recordType The [AnyRecordType] for which the token is retrieved. * @return The changes token, or null if not found. */ - suspend fun getToken(recordType: AnyRecordType): String? { + suspend fun getToken( + recordType: AnyRecordType, + collectionScopeId: String = "default", + repositoryScopeId: String = HealthConstraint.DEFAULT_REPOSITORY_SCOPE_ID, + ): String? = getState(recordType, collectionScopeId, repositoryScopeId)?.token + + suspend fun getState( + recordType: AnyRecordType, + collectionScopeId: String, + repositoryScopeId: String = HealthConstraint.DEFAULT_REPOSITORY_SCOPE_ID, + ): ChangesTokenState? { return storage.read( - key = keyFor(recordType), + key = keyFor(recordType, repositoryScopeId, collectionScopeId), settings = LocalStorageSetting.Unencrypted, serializer = serializer(), ) } + suspend fun getState(recordType: AnyRecordType, lease: CollectionProjectionLease): ChangesTokenState? = + ownerMutex.withLock { + requireOwner(recordType, lease) + getState(recordType, lease.projectionScopeId, lease.repositoryScopeId) + } + /** * Deletes the changes token for the specified [recordType]. * * @param recordType The [AnyRecordType] for which the token is deleted. */ - suspend fun deleteToken(recordType: AnyRecordType) { - storage.delete(keyFor(recordType)) + suspend fun deleteToken( + recordType: AnyRecordType, + collectionScopeId: String = "default", + repositoryScopeId: String = HealthConstraint.DEFAULT_REPOSITORY_SCOPE_ID, + ) { + storage.delete(keyFor(recordType, repositoryScopeId, collectionScopeId)) + } + + /** Claims exclusive ownership of one repository/type for a projection generation. */ + suspend fun claimProjection( + recordType: AnyRecordType, + repositoryScopeId: String, + projectionScopeId: String, + ): CollectionProjectionLease = ownerMutex.withLock { + require(repositoryScopeId.isNotBlank() && projectionScopeId.isNotBlank()) { + "Repository and projection scope ids are required." + } + val current = readOwner(recordType, repositoryScopeId) + val owner = if (current?.projectionScopeId == projectionScopeId) { + current + } else { + CollectionProjectionOwner( + projectionScopeId = projectionScopeId, + generation = (current?.generation ?: 0L) + 1L, + baselineRequired = true, + ).also { storeOwner(recordType, repositoryScopeId, it) } + } + owner.lease(repositoryScopeId) + } + + suspend fun baselineRequired(recordType: AnyRecordType, lease: CollectionProjectionLease): Boolean = + ownerMutex.withLock { requireOwner(recordType, lease).baselineRequired } + + /** Stores the boundary before clearing the durable baseline-required marker. */ + suspend fun storePendingBoundary( + recordType: AnyRecordType, + lease: CollectionProjectionLease, + token: String, + ) = ownerMutex.withLock { + val owner = requireOwner(recordType, lease) + storeState(recordType, lease, ChangesTokenState(token, ChangesTokenPhase.PENDING_BASELINE)) + storeOwner(recordType, lease.repositoryScopeId, owner.copy(baselineRequired = false)) + } + + suspend fun commitToken(recordType: AnyRecordType, lease: CollectionProjectionLease, token: String) = + ownerMutex.withLock { + requireOwner(recordType, lease) + storeState(recordType, lease, ChangesTokenState(token, ChangesTokenPhase.COMMITTED)) + } + + suspend fun requireProjectionOwner(recordType: AnyRecordType, lease: CollectionProjectionLease) { + ownerMutex.withLock { requireOwner(recordType, lease) } + } + + private suspend fun requireOwner( + recordType: AnyRecordType, + lease: CollectionProjectionLease, + ): CollectionProjectionOwner { + val owner = readOwner(recordType, lease.repositoryScopeId) + check(owner?.projectionScopeId == lease.projectionScopeId && owner.generation == lease.generation) { + "This collector no longer owns the repository/type projection lease." + } + return owner + } + + private suspend fun readOwner(type: AnyRecordType, repositoryScopeId: String): CollectionProjectionOwner? = + storage.read( + key = ownerKeyFor(type, repositoryScopeId), + settings = LocalStorageSetting.Unencrypted, + serializer = serializer(), + ) + + private suspend fun storeOwner( + type: AnyRecordType, + repositoryScopeId: String, + owner: CollectionProjectionOwner, + ) { + storage.store( + key = ownerKeyFor(type, repositoryScopeId), + value = owner, + settings = LocalStorageSetting.Unencrypted, + serializer = serializer(), + ) } - private fun keyFor(type: AnyRecordType) = "health_changes_token_${type.identifier}" + private suspend fun storeState( + type: AnyRecordType, + lease: CollectionProjectionLease, + state: ChangesTokenState, + ) { + storage.store( + key = keyFor(type, lease.repositoryScopeId, lease.projectionScopeId), + value = state, + settings = LocalStorageSetting.Unencrypted, + serializer = serializer(), + ) + } + + private fun keyFor(type: AnyRecordType, repositoryScopeId: String, collectionScopeId: String): String = + "health_changes_token_${type.identifier}_${scopeDigest(repositoryScopeId)}_${scopeDigest(collectionScopeId)}" + + private fun ownerKeyFor(type: AnyRecordType, repositoryScopeId: String): String = + "health_projection_owner_${type.identifier}_${scopeDigest(repositoryScopeId)}" + + private fun scopeDigest(scopeId: String): String { + require(scopeId.isNotBlank()) { "A collection scope id is required for token storage." } + return MessageDigest.getInstance("SHA-256") + .digest(scopeId.toByteArray(StandardCharsets.UTF_8)) + .joinToString("") { byte -> + val unsigned = byte.toInt() and BYTE_MASK + "${LOWERCASE_HEX[unsigned ushr HEX_NIBBLE_BITS]}${LOWERCASE_HEX[unsigned and HEX_NIBBLE_MASK]}" + } + } + + private companion object { + const val LOWERCASE_HEX = "0123456789abcdef" + const val HEX_NIBBLE_BITS = 4 + const val HEX_NIBBLE_MASK = 0x0f + const val BYTE_MASK = 0xff + } } + +@Serializable +internal data class ChangesTokenState( + val token: String, + val phase: ChangesTokenPhase, +) { + init { + require(token.isNotBlank()) { "A Health Connect changes token must not be blank." } + } +} + +@Serializable +internal enum class ChangesTokenPhase { + PENDING_BASELINE, + COMMITTED, +} + +@Serializable +private data class CollectionProjectionOwner( + val projectionScopeId: String, + val generation: Long, + val baselineRequired: Boolean, +) { + init { + require(projectionScopeId.isNotBlank() && generation > 0L) { + "A projection owner requires a scope id and positive generation." + } + } + + fun lease(repositoryScopeId: String) = CollectionProjectionLease( + repositoryScopeId = repositoryScopeId, + projectionScopeId = projectionScopeId, + generation = generation, + ) +} + +internal data class CollectionProjectionLease( + val repositoryScopeId: String, + val projectionScopeId: String, + val generation: Long, +) diff --git a/health/src/main/kotlin/org/grovealliance/health/internal/DefaultHealthClient.kt b/health/src/main/kotlin/org/grovealliance/health/internal/DefaultHealthClient.kt index 525d1335..10203164 100644 --- a/health/src/main/kotlin/org/grovealliance/health/internal/DefaultHealthClient.kt +++ b/health/src/main/kotlin/org/grovealliance/health/internal/DefaultHealthClient.kt @@ -132,9 +132,15 @@ internal class DefaultHealthClient( override fun resetRecordCollection(type: AnyRecordType) { ioScope.launch { val collectorsToReset = registeredDataCollectors.filter { it.recordType == type } - changesTokenStore.deleteToken(type) collectorsToReset.forEach { collector -> - collector.stopDataCollection() + collector.stopDataCollectionAndJoin() + } + collectorsToReset.forEach { collector -> + changesTokenStore.deleteToken( + type, + collector.collectionScopeId, + collector.collectionRepositoryId, + ) } registeredDataCollectors.removeAll(collectorsToReset) } @@ -303,6 +309,8 @@ internal class DefaultHealthClient( val existingSetting = existing.deliverySetting val newSetting = collector.deliverySetting when { + existing.collectionRepositoryId != collector.collectionRepositoryId || + existing.collectionScopeId != collector.collectionScopeId -> CollectorAction.Replace(existingIndex) existingSetting == newSetting -> CollectorAction.Ignore existingSetting.continueInBackground && !newSetting.continueInBackground -> CollectorAction.Ignore !existingSetting.continueInBackground && newSetting.continueInBackground -> CollectorAction.Replace(existingIndex) @@ -319,7 +327,7 @@ internal class DefaultHealthClient( is CollectorAction.Replace -> { val existing = registeredDataCollectors.getOrNull(action.index) - existing?.stopDataCollection() + existing?.stopDataCollectionAndJoin() registeredDataCollectors[action.index] = collector startAutomaticDataCollectionIfPossible(collector) logger.i { "Replaced HealthDataCollector for ${collector.recordType.identifier}" } @@ -366,7 +374,7 @@ internal class DefaultHealthClient( registeredDataCollectors.forEach { collector -> val continuesInBackground = collector.deliverySetting.continueInBackground if (continuesInBackground.not() || hasBackgroundPermission.not()) { - collector.stopDataCollection() + collector.stopDataCollectionAndJoin() logger.i { "Stopped collection for ${collector.recordType.identifier} due to to background" } } } diff --git a/health/src/main/kotlin/org/grovealliance/health/internal/HealthDataCollector.kt b/health/src/main/kotlin/org/grovealliance/health/internal/HealthDataCollector.kt index 8107b1f5..a2049f18 100644 --- a/health/src/main/kotlin/org/grovealliance/health/internal/HealthDataCollector.kt +++ b/health/src/main/kotlin/org/grovealliance/health/internal/HealthDataCollector.kt @@ -15,6 +15,7 @@ import androidx.health.connect.client.request.ChangesTokenRequest import androidx.health.connect.client.response.ChangesResponse import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.grovealliance.health.AnyRecordType @@ -44,6 +45,13 @@ internal class HealthDataCollector( ) { private val logger by healthLogger() private var collectionJob: Job? = null + private var projectionLease: CollectionProjectionLease? = null + val collectionScopeId: String = healthConstraint.collectionScopeId(recordType).also { + require(it.isNotBlank()) { "A HealthConstraint collection scope id must not be blank." } + } + val collectionRepositoryId: String = healthConstraint.collectionRepositoryId(recordType).also { + require(it.isNotBlank()) { "A HealthConstraint repository scope id must not be blank." } + } val isActive: Boolean get() = collectionJob?.isActive == true @@ -54,8 +62,7 @@ internal class HealthDataCollector( collectionJob = scope.launch { when (val mode = deliverySetting.collectionMode) { is CollectionMode.Manual -> { - val result = getChangesOrResync() - processResult(result) + collectUntilDrained() } is CollectionMode.Automatic -> { @@ -65,45 +72,64 @@ internal class HealthDataCollector( } } - fun stopDataCollection() { - collectionJob?.cancel() + suspend fun stopDataCollectionAndJoin() { + collectionJob?.cancelAndJoin() collectionJob = null } - private suspend fun getChangesOrResync(): ChangesResponse { - var token = tokenStore.getToken(recordType) - - if (token == null) { - token = client.getChangesToken( - ChangesTokenRequest(setOf(recordType.type)) - ) - tokenStore.storeToken(recordType, token) + private suspend fun ensureProjectionLease(): CollectionProjectionLease { + projectionLease?.let { + tokenStore.requireProjectionOwner(recordType, it) + return it } - - val response = client.getChanges(token) - - return if (response.changesTokenExpired) { - handleTokenExpired() - } else { - tokenStore.storeToken(recordType, response.nextChangesToken) - response + return tokenStore.claimProjection(recordType, collectionRepositoryId, collectionScopeId).also { + projectionLease = it } } - private suspend fun handleTokenExpired(): ChangesResponse { - logger.w { "Token expired for $recordType. Performing full resync." } - healthConstraint.onFullyResyncRequired(recordType) - tokenStore.deleteToken(recordType) - val newToken = client.getChangesToken(ChangesTokenRequest(setOf(recordType.type))) - tokenStore.storeToken(recordType, newToken) - return client.getChanges(newToken) + private suspend fun acquireDurableBoundary(lease: CollectionProjectionLease): String { + val boundary = client.getChangesToken(ChangesTokenRequest(setOf(recordType.type))) + tokenStore.storePendingBoundary(recordType, lease, boundary) + return boundary + } + + /** + * Establishes an exact baseline and drains every page after its durable boundary. + * + * A pending boundary survives callback, process, and token-store failures. Retrying repeats the + * scoped baseline and the same page sequence. If Health Connect expires that boundary, a new + * boundary is durably installed before the baseline is repeated. + */ + private suspend fun reconcileFromBoundary( + initialBoundary: String?, + lease: CollectionProjectionLease, + ): ChangesResponse { + var boundary = initialBoundary ?: acquireDurableBoundary(lease) + while (true) { + tokenStore.requireProjectionOwner(recordType, lease) + healthConstraint.onFullyResyncRequired(recordType) + var pageToken = boundary + while (true) { + val response = client.getChanges(pageToken) + if (response.changesTokenExpired) { + logger.w { "Recovery boundary expired for $recordType. Re-establishing baseline." } + boundary = acquireDurableBoundary(lease) + break + } + processResult(response, lease) + pageToken = response.nextChangesToken + if (!response.hasMore) { + tokenStore.commitToken(recordType, lease, pageToken) + return response + } + } + } } private suspend fun runPollingLoop(interval: Duration) { while (isActive) { runCatching { - val result = getChangesOrResync() - processResult(result) + val result = collectOnce() if (!result.hasMore) delay(interval) }.onFailure { logger.e(it) { "Error collecting Health data for $recordType" } @@ -112,31 +138,70 @@ internal class HealthDataCollector( } } - private suspend fun processResult(result: ChangesResponse) { - val inserts = mutableSetOf() - val deletes = mutableSetOf() + /** + * Delivers one change page and advances its token only after every callback succeeds. + * + * The token is the commit point for a page. Advancing it before the constraint has durably + * accepted upserts and deletions loses that page when a callback fails. + */ + internal suspend fun collectOnce(): ChangesResponse { + val lease = ensureProjectionLease() + var state = tokenStore.getState(recordType, lease) + if (tokenStore.baselineRequired(recordType, lease)) { + val boundary = state + ?.takeIf { it.phase == ChangesTokenPhase.PENDING_BASELINE } + ?.token + ?: client.getChangesToken(ChangesTokenRequest(setOf(recordType.type))) + tokenStore.storePendingBoundary(recordType, lease, boundary) + state = ChangesTokenState(boundary, ChangesTokenPhase.PENDING_BASELINE) + } + if (state == null || state.phase == ChangesTokenPhase.PENDING_BASELINE) { + return reconcileFromBoundary(state?.token, lease) + } + + val result = client.getChanges(state.token) + if (result.changesTokenExpired) { + logger.w { "Token expired for $recordType. Performing full resync." } + return reconcileFromBoundary(initialBoundary = null, lease = lease) + } + processResult(result, lease) + tokenStore.commitToken(recordType, lease, result.nextChangesToken) + return result + } + internal suspend fun collectUntilDrained() { + var result: ChangesResponse + do { + result = collectOnce() + } while (result.hasMore) + } + + private suspend fun processResult(result: ChangesResponse, lease: CollectionProjectionLease) { + // Preserve Health Connect's change order. Collapsing a page into independent upsert and + // deletion sets can resurrect a record or delete its replacement when both changes for an + // id occur in the same page. for (change in result.changes) { + tokenStore.requireProjectionOwner(recordType, lease) when (change) { is UpsertionChange -> { val record = change.record - if (matchesFilter(record)) inserts += record + if (!recordType.type.java.isAssignableFrom(record::class.java)) continue + if (matchesFilter(record)) { + healthConstraint.handleNewRecords(setOf(record), recordType) + } else { + healthConstraint.handleExcludedRecords(setOf(record.metadata.id), recordType) + } } - is DeletionChange -> deletes += change.recordId + is DeletionChange -> { + healthConstraint.handleDeletedRecords(setOf(change.recordId), recordType) + } } - } - - if (inserts.isNotEmpty()) { - healthConstraint.handleNewRecords(inserts.toSet(), recordType) - } - if (deletes.isNotEmpty()) { - healthConstraint.handleDeletedRecords(deletes.toSet(), recordType) + tokenStore.requireProjectionOwner(recordType, lease) } } private fun matchesFilter(record: Record): Boolean { - if (!recordType.type.java.isAssignableFrom(record::class.java)) return false val predicateFilterMatched = predicate?.invoke(record) ?: true if (!predicateFilterMatched) return false diff --git a/health/src/test/kotlin/org/grovealliance/health/RecordTypeTest.kt b/health/src/test/kotlin/org/grovealliance/health/RecordTypeTest.kt new file mode 100644 index 00000000..911ef28b --- /dev/null +++ b/health/src/test/kotlin/org/grovealliance/health/RecordTypeTest.kt @@ -0,0 +1,34 @@ +// +// This source file is part of the My Heart Counts Android open-source project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class RecordTypeTest { + @Test + fun `closed inventory exposes every declared Health Connect record type exactly once`() { + val declared = RecordType.Companion::class.java.methods + .filter { method -> method.parameterCount == 0 && method.returnType == RecordType::class.java } + .map { method -> method.invoke(RecordType.Companion) as RecordType<*> } + .toSet() + + assertThat(RecordType.all).containsExactlyElementsIn(declared) + assertThat(RecordType.all.map { it.identifier }).containsNoDuplicates() + assertThat(RecordType.all.map { it.type }).containsNoDuplicates() + assertThat(RecordType.all.map { it.identifier }).containsAtLeast( + "ActiveCaloriesBurnedRecord", + "BloodGlucoseRecord", + "BloodPressureRecord", + "HeartRateRecord", + "SleepSessionRecord", + "StepsRecord", + "WeightRecord", + ) + } +} diff --git a/health/src/test/kotlin/org/grovealliance/health/internal/HealthDataCollectorTest.kt b/health/src/test/kotlin/org/grovealliance/health/internal/HealthDataCollectorTest.kt new file mode 100644 index 00000000..2766410e --- /dev/null +++ b/health/src/test/kotlin/org/grovealliance/health/internal/HealthDataCollectorTest.kt @@ -0,0 +1,447 @@ +// +// This source file belongs to the My Heart Counts Android project +// +// SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT + +package org.grovealliance.health.internal + +import androidx.health.connect.client.changes.DeletionChange +import androidx.health.connect.client.changes.UpsertionChange +import androidx.health.connect.client.records.Record +import androidx.health.connect.client.records.StepsRecord +import androidx.health.connect.client.records.metadata.Device +import androidx.health.connect.client.records.metadata.Metadata +import androidx.health.connect.client.request.ChangesTokenRequest +import androidx.health.connect.client.response.ChangesResponse +import androidx.health.connect.client.testing.FakeHealthConnectClient +import androidx.health.connect.client.testing.stubs.MutableStub +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerializationStrategy +import org.grovealliance.health.CollectionMode +import org.grovealliance.health.CollectionTimeRange +import org.grovealliance.health.HealthConstraint +import org.grovealliance.health.RecordType +import org.grovealliance.storage.local.LocalStorage +import org.grovealliance.storage.local.LocalStorageSetting +import org.junit.Test +import java.time.Instant + +class HealthDataCollectorTest { + @Test + fun `first collection durably baselines and drains every boundary page`() = runTest { + val client = FakeHealthConnectClient() + val tokenStore = ChangesTokenStore(InMemoryLocalStorage()) + var page = 0 + client.overrides.getChanges = MutableStub { + page += 1 + ChangesResponse( + changes = listOf(UpsertionChange(stepRecord("2026-08-19T1$page:00:00Z"))), + nextChangesToken = "baseline-page-$page", + hasMore = page == 1, + changesTokenExpired = false, + ) + } + val constraint = OrderedHealthConstraint() + val collector = collector(client, tokenStore, constraint, backgroundScope) + + val response = collector.collectOnce() + + assertThat(response.hasMore).isFalse() + assertThat(constraint.resyncCalls).isEqualTo(1) + assertThat(constraint.events).containsExactly("upsert", "upsert").inOrder() + assertThat(tokenStore.getState(RecordType.steps, collector.collectionScopeId)?.phase) + .isEqualTo(ChangesTokenPhase.COMMITTED) + assertThat(tokenStore.getToken(RecordType.steps, collector.collectionScopeId)) + .isEqualTo("baseline-page-2") + } + + @Test + fun `failed first baseline retains and retries the exact durable boundary`() = runTest { + val client = FakeHealthConnectClient() + val tokenStore = ChangesTokenStore(InMemoryLocalStorage()) + val constraint = FailFirstBaselineHealthConstraint() + val collector = collector(client, tokenStore, constraint, backgroundScope) + + val failure = runCatching { collector.collectOnce() }.exceptionOrNull() + val pending = requireNotNull(tokenStore.getState(RecordType.steps, collector.collectionScopeId)) + + assertThat(failure).isInstanceOf(IllegalStateException::class.java) + assertThat(pending.phase).isEqualTo(ChangesTokenPhase.PENDING_BASELINE) + + collector.collectOnce() + + assertThat(constraint.resyncCalls).isEqualTo(2) + assertThat(tokenStore.getState(RecordType.steps, collector.collectionScopeId)?.phase) + .isEqualTo(ChangesTokenPhase.COMMITTED) + } + + @Test + fun `does not advance the token when delivery fails`() = runTest { + val client = FakeHealthConnectClient() + val tokenStore = ChangesTokenStore(InMemoryLocalStorage()) + val initialToken = client.getChangesToken(ChangesTokenRequest(setOf(StepsRecord::class))) + tokenStore.storeToken(RecordType.steps, initialToken) + client.insertRecords(listOf(stepRecord("2026-08-19T17:00:00Z"))) + val constraint = FailOnceHealthConstraint() + val collector = collector(client, tokenStore, constraint, backgroundScope) + + val failure = runCatching { collector.collectOnce() }.exceptionOrNull() + + assertThat(failure).isInstanceOf(IllegalStateException::class.java) + assertThat(tokenStore.getToken(RecordType.steps)).isEqualTo(initialToken) + + collector.collectOnce() + + assertThat(constraint.newRecordCalls).isEqualTo(2) + assertThat(tokenStore.getToken(RecordType.steps)).isNotEqualTo(initialToken) + } + + @Test + fun `captures changes made while an expired token is fully resynchronized`() = runTest { + val client = FakeHealthConnectClient() + val tokenStore = ChangesTokenStore(InMemoryLocalStorage()) + val expiredToken = client.getChangesToken(ChangesTokenRequest(setOf(StepsRecord::class))) + tokenStore.storeToken(RecordType.steps, expiredToken) + client.expireToken(expiredToken) + val constraint = InsertDuringResyncHealthConstraint(client) + + collector(client, tokenStore, constraint, backgroundScope).collectOnce() + + assertThat(constraint.resyncCalls).isEqualTo(1) + assertThat(constraint.deliveredRecords).isEqualTo(1) + assertThat(tokenStore.getToken(RecordType.steps)).isNotEqualTo(expiredToken) + } + + @Test + fun `delivers upsertion and deletion in Health Connect page order`() = runTest { + val client = FakeHealthConnectClient() + val tokenStore = ChangesTokenStore(InMemoryLocalStorage()) + val token = client.getChangesToken(ChangesTokenRequest(setOf(StepsRecord::class))) + tokenStore.storeToken(RecordType.steps, token) + client.overrides.getChanges = MutableStub { + ChangesResponse( + changes = listOf( + UpsertionChange(stepRecord("2026-08-19T17:00:00Z")), + DeletionChange("source-record"), + ), + nextChangesToken = "next-token", + hasMore = false, + changesTokenExpired = false, + ) + } + val constraint = OrderedHealthConstraint() + + collector(client, tokenStore, constraint, backgroundScope).collectOnce() + + assertThat(constraint.events).containsExactly("upsert", "delete").inOrder() + } + + @Test + fun `manual collection drains every acknowledged page`() = runTest { + val client = FakeHealthConnectClient() + val tokenStore = ChangesTokenStore(InMemoryLocalStorage()) + val token = client.getChangesToken(ChangesTokenRequest(setOf(StepsRecord::class))) + tokenStore.storeToken(RecordType.steps, token) + var page = 0 + client.overrides.getChanges = MutableStub { + page += 1 + ChangesResponse( + changes = listOf(UpsertionChange(stepRecord("2026-08-19T1$page:00:00Z"))), + nextChangesToken = "page-$page", + hasMore = page == 1, + changesTokenExpired = false, + ) + } + val constraint = OrderedHealthConstraint() + + collector(client, tokenStore, constraint, backgroundScope).collectUntilDrained() + + assertThat(constraint.events).containsExactly("upsert", "upsert").inOrder() + assertThat(tokenStore.getToken(RecordType.steps)).isEqualTo("page-2") + } + + @Test + fun `reports an upsert that leaves the collection filter`() = runTest { + val client = FakeHealthConnectClient() + val tokenStore = ChangesTokenStore(InMemoryLocalStorage()) + val token = client.getChangesToken(ChangesTokenRequest(setOf(StepsRecord::class))) + tokenStore.storeToken(RecordType.steps, token) + client.overrides.getChanges = MutableStub { + ChangesResponse( + changes = listOf(UpsertionChange(stepRecord("2026-08-19T17:00:00Z"))), + nextChangesToken = "next-token", + hasMore = false, + changesTokenExpired = false, + ) + } + val constraint = OrderedHealthConstraint() + + collector(client, tokenStore, constraint, backgroundScope, predicate = { false }).collectOnce() + + assertThat(constraint.events).containsExactly("excluded") + } + + @Test + fun `switching projection A to B to A forces baselines and fences the old collector`() = runTest { + val client = FakeHealthConnectClient() + val tokenStore = ChangesTokenStore(InMemoryLocalStorage()) + val firstA = ScopedHealthConstraint("projection-a") + val projectionB = ScopedHealthConstraint("projection-b") + val secondA = ScopedHealthConstraint("projection-a") + val firstCollector = collector(client, tokenStore, firstA, backgroundScope) + + firstCollector.collectOnce() + collector(client, tokenStore, projectionB, backgroundScope).collectOnce() + + val fenced = runCatching { firstCollector.collectOnce() }.exceptionOrNull() + collector(client, tokenStore, secondA, backgroundScope).collectOnce() + + assertThat(fenced).isInstanceOf(IllegalStateException::class.java) + assertThat(firstA.resyncCalls).isEqualTo(1) + assertThat(projectionB.resyncCalls).isEqualTo(1) + assertThat(secondA.resyncCalls).isEqualTo(1) + } + + @Test + fun `same projection label in two repositories has independent owner and token state`() = runTest { + val client = FakeHealthConnectClient() + val tokenStore = ChangesTokenStore(InMemoryLocalStorage()) + val repositoryA = ScopedHealthConstraint("shared-projection", "repository-a") + val repositoryB = ScopedHealthConstraint("shared-projection", "repository-b") + val collectorA = collector(client, tokenStore, repositoryA, backgroundScope) + val collectorB = collector(client, tokenStore, repositoryB, backgroundScope) + + collectorA.collectOnce() + collectorB.collectOnce() + collectorA.collectOnce() + + assertThat(repositoryA.resyncCalls).isEqualTo(1) + assertThat(repositoryB.resyncCalls).isEqualTo(1) + assertThat(tokenStore.getState(RecordType.steps, "shared-projection", "repository-a")).isNotNull() + assertThat(tokenStore.getState(RecordType.steps, "shared-projection", "repository-b")).isNotNull() + } + + @Test + fun `stop and join fences an in-flight baseline before token reset`() = runTest { + val tokenStore = ChangesTokenStore(InMemoryLocalStorage()) + val constraint = BlockingBaselineHealthConstraint() + val collector = collector(FakeHealthConnectClient(), tokenStore, constraint, backgroundScope) + collector.startDataCollection() + constraint.entered.await() + + collector.stopDataCollectionAndJoin() + tokenStore.deleteToken(RecordType.steps, collector.collectionScopeId) + + assertThat(tokenStore.getState(RecordType.steps, collector.collectionScopeId)).isNull() + } + + private fun collector( + client: FakeHealthConnectClient, + tokenStore: ChangesTokenStore, + constraint: HealthConstraint, + scope: CoroutineScope, + predicate: ((Record) -> Boolean)? = null, + ) = HealthDataCollector( + recordType = RecordType.steps, + deliverySetting = HealthDataCollectorDeliverySetting(CollectionMode.Manual, false), + timeRange = CollectionTimeRange.NewRecords, + predicate = predicate, + tokenStore = tokenStore, + scope = scope, + healthConstraint = constraint, + client = client, + ) + + private fun stepRecord(start: String) = StepsRecord( + startTime = Instant.parse(start), + startZoneOffset = null, + endTime = Instant.parse(start).plusSeconds(60), + endZoneOffset = null, + count = 100, + metadata = Metadata.autoRecorded(testDevice()), + ) + + private class FailOnceHealthConstraint : HealthConstraint { + var newRecordCalls = 0 + + override suspend fun handleNewRecords(addedRecords: Set, type: RecordType) { + newRecordCalls += 1 + if (newRecordCalls == 1) error("Sink did not persist the page.") + } + + override suspend fun handleDeletedRecords( + deletedRecordIds: Set, + type: RecordType, + ) = Unit + + override suspend fun onFullyResyncRequired(type: RecordType) = Unit + } + + private class InsertDuringResyncHealthConstraint( + private val client: FakeHealthConnectClient, + ) : HealthConstraint { + var resyncCalls = 0 + var deliveredRecords = 0 + + override suspend fun handleNewRecords(addedRecords: Set, type: RecordType) { + deliveredRecords += addedRecords.size + } + + override suspend fun handleDeletedRecords( + deletedRecordIds: Set, + type: RecordType, + ) = Unit + + override suspend fun onFullyResyncRequired(type: RecordType) { + resyncCalls += 1 + client.insertRecords( + listOf( + StepsRecord( + startTime = Instant.parse("2026-08-19T18:00:00Z"), + startZoneOffset = null, + endTime = Instant.parse("2026-08-19T18:01:00Z"), + endZoneOffset = null, + count = 101, + metadata = Metadata.autoRecorded( + Device( + type = Device.TYPE_PHONE, + manufacturer = "Example Device Company", + model = "Test Phone", + ), + ), + ), + ), + ) + } + } + + private class OrderedHealthConstraint : HealthConstraint { + val events = mutableListOf() + var resyncCalls = 0 + + override suspend fun handleNewRecords(addedRecords: Set, type: RecordType) { + events += "upsert" + } + + override suspend fun handleDeletedRecords( + deletedRecordIds: Set, + type: RecordType, + ) { + events += "delete" + } + + override suspend fun handleExcludedRecords( + excludedRecordIds: Set, + type: RecordType, + ) { + events += "excluded" + } + + override suspend fun onFullyResyncRequired(type: RecordType) { + resyncCalls += 1 + } + } + + private class FailFirstBaselineHealthConstraint : HealthConstraint { + var resyncCalls = 0 + + override suspend fun handleNewRecords(addedRecords: Set, type: RecordType) = Unit + + override suspend fun handleDeletedRecords( + deletedRecordIds: Set, + type: RecordType, + ) = Unit + + override suspend fun onFullyResyncRequired(type: RecordType) { + resyncCalls += 1 + if (resyncCalls == 1) error("Baseline did not complete.") + } + } + + private class ScopedHealthConstraint( + private val projection: String, + private val repository: String = HealthConstraint.DEFAULT_REPOSITORY_SCOPE_ID, + ) : HealthConstraint { + var resyncCalls = 0 + + override fun collectionScopeId(type: RecordType) = projection + + override fun collectionRepositoryId(type: RecordType) = repository + + override suspend fun handleNewRecords(addedRecords: Set, type: RecordType) = Unit + + override suspend fun handleDeletedRecords( + deletedRecordIds: Set, + type: RecordType, + ) = Unit + + override suspend fun onFullyResyncRequired(type: RecordType) { + resyncCalls += 1 + } + } + + private class BlockingBaselineHealthConstraint : HealthConstraint { + val entered = CompletableDeferred() + + override suspend fun handleNewRecords(addedRecords: Set, type: RecordType) = Unit + + override suspend fun handleDeletedRecords( + deletedRecordIds: Set, + type: RecordType, + ) = Unit + + override suspend fun onFullyResyncRequired(type: RecordType) { + entered.complete(Unit) + awaitCancellation() + } + } + + private class InMemoryLocalStorage : LocalStorage { + private val values = mutableMapOf() + + override suspend fun store( + key: String, + value: T, + settings: LocalStorageSetting, + serializer: SerializationStrategy, + ) = store(key, value, settings) { kotlinx.serialization.json.Json.encodeToString(serializer, it).encodeToByteArray() } + + override suspend fun store( + key: String, + value: T, + settings: LocalStorageSetting, + encoding: (T) -> ByteArray, + ) { + values[key] = encoding(value) + } + + override suspend fun read( + key: String, + settings: LocalStorageSetting, + serializer: DeserializationStrategy, + ): T? = read(key, settings) { kotlinx.serialization.json.Json.decodeFromString(serializer, it.decodeToString()) } + + override suspend fun read( + key: String, + settings: LocalStorageSetting, + decoding: (ByteArray) -> T, + ): T? = values[key]?.let(decoding) + + override suspend fun delete(key: String) { + values.remove(key) + } + } + + private fun testDevice() = Device( + type = Device.TYPE_PHONE, + manufacturer = "Example Device Company", + model = "Test Phone", + ) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 8c8334ee..76c9f92b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -47,6 +47,7 @@ include(":core-time") include(":core-viewmodel") include(":foundation") include(":health") +include(":health-fhir") include(":markdown") include(":myheartcounts") include(":onboarding")