diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 6c78356c5..20365a2d8 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -22,6 +22,8 @@ firebaseStorageKtx = "21.0.0"
foundation = "1.6.7"
googleGmsGoogleServices = "4.4.2"
googleid = "1.1.0"
+hapiFhirVersion = "5.7.9"
+healthConnectClient = "1.1.0-alpha07"
hiltNavigation = "1.2.0"
hiltVersion = "2.51"
junit = "4.13.2"
@@ -46,6 +48,7 @@ android-gradle = { group = "com.android.tools.build", name = "gradle", version.r
android-tools-common = { group = "com.android.tools", name = "common", version.ref = "androidTools" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
+androidx-health-connect-client = { module = "androidx.health.connect:connect-client", version.ref = "healthConnectClient" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-core-testing = { group = "androidx.arch.core", name = "core-testing", version.ref = "coreTestingVersion" }
androidx-credentials-play-services-auth = { module = "androidx.credentials:credentials-play-services-auth", version.ref = "credentialsPlayServicesAuth" }
@@ -73,6 +76,7 @@ firebase-functions-ktx = { group = "com.google.firebase", name = "firebase-funct
firebase-storage-ktx = { group = "com.google.firebase", name = "firebase-storage-ktx", version.ref = "firebaseStorageKtx" }
google-truth = { group = "com.google.truth", name = "truth", version.ref = "truth" }
googleid = { module = "com.google.android.libraries.identity.googleid:googleid", version.ref = "googleid" }
+hapi-fhir-structures-r4 = { module = "ca.uhn.hapi.fhir:hapi-fhir-structures-r4", version.ref = "hapiFhirVersion"}
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hiltVersion" }
hilt-core = { group = "com.google.dagger", name = "hilt-android", version.ref = "hiltVersion" }
hilt-gradle = { group = "com.google.dagger", name = "hilt-android-gradle-plugin", version.ref = "hiltVersion" }
diff --git a/modules/contact/src/androidTest/AndroidManifest.xml b/modules/contact/src/androidTest/AndroidManifest.xml
deleted file mode 100644
index 69ba7552f..000000000
--- a/modules/contact/src/androidTest/AndroidManifest.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/modules/contact/src/main/AndroidManifest.xml b/modules/contact/src/main/AndroidManifest.xml
index 44008a433..568741e54 100644
--- a/modules/contact/src/main/AndroidManifest.xml
+++ b/modules/contact/src/main/AndroidManifest.xml
@@ -1,4 +1,2 @@
-
-
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/modules/healthconnectonfhir/.gitignore b/modules/healthconnectonfhir/.gitignore
new file mode 100644
index 000000000..42afabfd2
--- /dev/null
+++ b/modules/healthconnectonfhir/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/modules/healthconnectonfhir/README.md b/modules/healthconnectonfhir/README.md
new file mode 100644
index 000000000..eb29720e4
--- /dev/null
+++ b/modules/healthconnectonfhir/README.md
@@ -0,0 +1,83 @@
+# Module healthconnectonfhir
+
+The HealthConnectOnFHIR library provides a mapper that converts supported [Android Health Connect](https://health.google/health-connect-android/) Records to corresponding [HL7® FHIR® R4 Observations](https://hl7.org/fhir/r4/observation.html) with standardized codes (e.g. [LOINC](https://loinc.org/)).
+
+For more information, please refer to the API documentation.
+
+## Mapping Table
+
+| Health Connect Record | FHIR Observation Category | LOINC Code | Unit | Display |
+|------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------|------------|------------|--------------------------------------------|
+| [ActiveCaloriesBurnedRecord](https://developer.android.com/reference/kotlin/androidx/health/connect/client/records/ActiveCaloriesBurnedRecord) | Activity | 41981-2 | kcal | Calories burned |
+| [BloodGlucoseRecord](https://developer.android.com/reference/kotlin/androidx/health/connect/client/records/BloodGlucoseRecord) | | 41653-7 | mg/dL | Glucose Glucometer (BldC) [Mass/Vol] |
+| [BloodPressureRecord](https://developer.android.com/reference/kotlin/androidx/health/connect/client/records/BloodPressureRecord) | Vital Signs | 85354-9 | mmHg | Blood pressure panel with all children optional |
+| [BodyFatRecord](https://developer.android.com/reference/kotlin/androidx/health/connect/client/records/BodyFatRecord) | | 41982-0 | % | Percentage of body fat Measured |
+| [BodyTemperatureRecord](https://developer.android.com/reference/kotlin/androidx/health/connect/client/records/BodyTemperatureRecord) | Vital Signs | 8310-5 | Cel | Body temperature |
+| [HeartRateRecord](https://developer.android.com/reference/kotlin/androidx/health/connect/client/records/HeartRateRecord) | Vital Signs | 8867-4 | /min | Heart rate |
+| [HeightRecord](https://developer.android.com/reference/kotlin/androidx/health/connect/client/records/HeightRecord) | Vital Signs | 8302-2 | m | Body height |
+| [OxygenSaturationRecord](https://developer.android.com/reference/kotlin/androidx/health/connect/client/records/OxygenSaturationRecord) | Vital Signs | 59408-5 | % | Oxygen saturation in Arterial blood by Pulse oximetry |
+| [RespiratoryRateRecord](https://developer.android.com/reference/kotlin/androidx/health/connect/client/records/RespiratoryRateRecord) | Vital Signs | 9279-1 | /min | Respiratory rate |
+| [StepsRecord](https://developer.android.com/reference/kotlin/androidx/health/connect/client/records/StepsRecord) | Activity | 55423-8 | steps | Number of steps |
+| [WeightRecord](https://developer.android.com/reference/kotlin/androidx/health/connect/client/records/WeightRecord) | Vital Signs | 29463-7 | kg | Body weight |
+
+
+## Installation
+
+HealthConnectOnFHIR can be installed into your Android Studio project [via Jitpack](https://jitpack.io/#StanfordSpezi/SpeziKt/healthconnectonfhir-maven).
+
+## Usage
+
+```kotlin
+// Initialize the mapper
+val mapper = RecordToObservationMapperImpl()
+
+// Query a `Record` from Health Connect
+val record = // ..
+
+// Map the record to an HL7 FHIR Observation
+val observation = mapper.map(record)
+```
+
+## Example
+
+First, you will need to configure your application to use Android Health Connect. For more information, please see the [official documentation](https://developer.android.com/health-and-fitness/guides/health-connect).
+
+```kotlin
+// Initialize a `HealthConnectClient` (see Health Connect docs for full details)
+val healthConnectClient = HealthConnectClient.getOrCreate(context)
+
+// Define a time range for the query
+val startTime = Instant.parse("2023-05-01T00:00:00Z")
+val endTime = Instant.parse("2023-06-01T00:00:00Z")
+
+// Query a list of `WeightRecord`s from Health Connect
+val result = healthConnectClient.readRecords(
+ ReadRecordsRequest(
+ recordType = WeightRecord::class,
+ timeRangeFilter = TimeRangeFilter.between(startTime, endTime)
+ )
+).records
+
+// Initialize the mapper
+val mapper = RecordToObservationMapperImpl()
+
+// Convert each weight record to a FHIR Observation
+result.forEach { weightRecord ->
+ val observations = mapper.map(weightRecord)
+ observations.forEach { observation ->
+ // Do something with the observation
+ }
+}
+```
+
+## License
+
+This project is licensed under the MIT license.
+
+## Contributors
+
+This project is developed as a part of the Stanford Biodesign for Digital Health projects at Stanford. See CONTRIBUTORS.md for a full list of all HealthConnectOnFHIR contributors.
+
+## Notices
+
+Health Connect is a registered trademark of Google. FHIR is a registered trademark of Health Level Seven International.
\ No newline at end of file
diff --git a/modules/healthconnectonfhir/build.gradle.kts b/modules/healthconnectonfhir/build.gradle.kts
new file mode 100644
index 000000000..cd299f4d1
--- /dev/null
+++ b/modules/healthconnectonfhir/build.gradle.kts
@@ -0,0 +1,13 @@
+plugins {
+ alias(libs.plugins.spezi.library)
+ alias(libs.plugins.spezi.hilt)
+}
+
+android {
+ namespace = "edu.stanford.spezi.modules.healthconnectonfhir"
+}
+
+dependencies {
+ api(libs.androidx.health.connect.client)
+ api(libs.hapi.fhir.structures.r4)
+}
diff --git a/modules/healthconnectonfhir/src/main/AndroidManifest.xml b/modules/healthconnectonfhir/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..a5918e68a
--- /dev/null
+++ b/modules/healthconnectonfhir/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/modules/healthconnectonfhir/src/main/java/edu/stanford/healthconnectonfhir/MappedUnit.kt b/modules/healthconnectonfhir/src/main/java/edu/stanford/healthconnectonfhir/MappedUnit.kt
new file mode 100644
index 000000000..5d0180015
--- /dev/null
+++ b/modules/healthconnectonfhir/src/main/java/edu/stanford/healthconnectonfhir/MappedUnit.kt
@@ -0,0 +1,3 @@
+package edu.stanford.healthconnectonfhir
+
+data class MappedUnit(val unit: String, val system: String, val code: String)
diff --git a/modules/healthconnectonfhir/src/main/java/edu/stanford/healthconnectonfhir/RecordToObservationMapper.kt b/modules/healthconnectonfhir/src/main/java/edu/stanford/healthconnectonfhir/RecordToObservationMapper.kt
new file mode 100644
index 000000000..3e382ad6a
--- /dev/null
+++ b/modules/healthconnectonfhir/src/main/java/edu/stanford/healthconnectonfhir/RecordToObservationMapper.kt
@@ -0,0 +1,15 @@
+package edu.stanford.healthconnectonfhir
+
+import androidx.health.connect.client.records.Record
+import org.hl7.fhir.r4.model.Observation
+
+interface RecordToObservationMapper {
+ /**
+ * Maps a given Health Connect record to a list of HL7 FHIR Observations
+ *
+ * @param T the type of the Health Connect record, extending from `Record`
+ * @param record the record to be mapped
+ * @return a list of `Observation` objects derived from the provided health record
+ */
+ fun map(record: T): List
+}
diff --git a/modules/healthconnectonfhir/src/main/java/edu/stanford/healthconnectonfhir/RecordToObservationMapperImpl.kt b/modules/healthconnectonfhir/src/main/java/edu/stanford/healthconnectonfhir/RecordToObservationMapperImpl.kt
new file mode 100644
index 000000000..48de42529
--- /dev/null
+++ b/modules/healthconnectonfhir/src/main/java/edu/stanford/healthconnectonfhir/RecordToObservationMapperImpl.kt
@@ -0,0 +1,450 @@
+package edu.stanford.healthconnectonfhir
+
+import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord
+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.HeartRateRecord
+import androidx.health.connect.client.records.HeightRecord
+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.StepsRecord
+import androidx.health.connect.client.records.WeightRecord
+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.Identifier
+import org.hl7.fhir.r4.model.Observation
+import org.hl7.fhir.r4.model.Period
+import org.hl7.fhir.r4.model.Quantity
+import java.util.Date
+import javax.inject.Inject
+
+class RecordToObservationMapperImpl @Inject constructor() : RecordToObservationMapper {
+ /**
+ * Maps a given Health Connect record to a list of HL7 FHIR Observations.
+ *
+ * @param T the type of the health record, extending from Record
+ * @param record the health record to be mapped
+ * @return a list of Observation objects derived from the provided health record
+ * @throws IllegalArgumentException if the record type is unsupported
+ */
+ override fun map(record: T): List {
+ return when (record) {
+ is ActiveCaloriesBurnedRecord -> listOf(mapActiveCaloriesBurnedRecord(record))
+ is BodyFatRecord -> listOf(mapBodyFatRecord(record))
+ is BodyTemperatureRecord -> listOf(mapBodyTemperatureRecord(record))
+ is BloodGlucoseRecord -> listOf(mapBloodGlucoseRecord(record))
+ is BloodPressureRecord -> listOf(mapBloodPressureRecord(record))
+ is HeartRateRecord -> mapHeartRateRecord(record)
+ is HeightRecord -> listOf(mapHeightRecord(record))
+ is OxygenSaturationRecord -> listOf(mapOxygenSaturationRecord(record))
+ is RespiratoryRateRecord -> listOf(mapRespiratoryRateRecord(record))
+ is StepsRecord -> listOf(mapStepsRecord(record))
+ is WeightRecord -> listOf(mapWeightRecord(record))
+ else -> throw IllegalArgumentException("Unsupported record type ${record.javaClass.name}")
+ }
+ }
+
+ /**
+ * Maps an ActiveCaloriesBurnedRecord to a FHIR Observation.
+ *
+ * @param record the ActiveCaloriesBurnedRecord to be mapped
+ * @return an Observation object derived from the provided ActiveCaloriesBurnedRecord
+ */
+ private fun mapActiveCaloriesBurnedRecord(record: ActiveCaloriesBurnedRecord) = record.createObservation(
+ categories = listOf(
+ Coding()
+ .setSystem("http://terminology.hl7.org/CodeSystem/observation-category")
+ .setCode("activity")
+ .setDisplay("Activity")
+ ),
+ codings = listOf(
+ Coding()
+ .setSystem("http://loinc.org")
+ .setCode("41981-2")
+ .setDisplay("Calories burned")
+ ),
+ unit = MappedUnit(
+ code = "kcal",
+ unit = "kcal",
+ system = "http://unitsofmeasure.org"
+ ),
+ valueExtractor = { energy.inCalories },
+ periodExtractor = { Date.from(startTime) to Date.from(endTime) }
+ )
+
+ /**
+ * Maps a BloodGlucoseRecord to a FHIR Observation.
+ *
+ * @param record the BloodGlucoseRecord to be mapped
+ * @return an Observation object derived from the provided BloodGlucoseRecord
+ */
+ private fun mapBloodGlucoseRecord(record: BloodGlucoseRecord) = record.createObservation(
+ codings = listOf(
+ Coding()
+ .setSystem("http://loinc.org")
+ .setCode("41653-7")
+ .setDisplay("Glucose Glucometer (BldC) [Mass/Vol]")
+ ),
+ unit = MappedUnit(
+ code = "mg/dL",
+ unit = "mg/dL",
+ system = "http://unitsofmeasure.org"
+ ),
+ valueExtractor = { level.inMilligramsPerDeciliter },
+ periodExtractor = { Date.from(time) to Date.from(time) }
+ )
+
+ /**
+ * Maps a BloodPressureRecord to a FHIR Observation.
+ *
+ * @param record the BloodPressureRecord to be mapped
+ * @return an Observation object derived from the provided BloodPressureRecord in which
+ * the systolic and diastolic blood pressure values are represented as separate components.
+ */
+ private fun mapBloodPressureRecord(record: BloodPressureRecord): Observation {
+ val observation = Observation()
+
+ observation.addCommonElements()
+
+ observation.identifier = listOf(
+ Identifier().apply {
+ this.setId(record.metadata.id)
+ }
+ )
+
+ observation.category = listOf(
+ CodeableConcept().addCoding(
+ Coding()
+ .setSystem("http://terminology.hl7.org/CodeSystem/observation-category")
+ .setCode("vital-signs")
+ .setDisplay("Vital Signs")
+ )
+ )
+
+ observation.code = CodeableConcept().addCoding(
+ Coding()
+ .setSystem("http://loinc.org")
+ .setCode("85354-9")
+ .setDisplay("Blood pressure panel with all children optional")
+ )
+
+ val dateTime = DateTimeType(Date.from(record.time))
+ observation.effective = dateTime
+
+ val systolicComponent = Observation.ObservationComponentComponent()
+ systolicComponent.code = CodeableConcept().addCoding(
+ Coding()
+ .setSystem("http://loinc.org")
+ .setCode("8480-6")
+ .setDisplay("Systolic blood pressure")
+ )
+ systolicComponent.value = Quantity()
+ .setValue(record.systolic.inMillimetersOfMercury)
+ .setUnit("mmHg")
+ .setCode("mm[Hg]")
+ .setSystem("http://unitsofmeasure.org")
+
+ val diastolicComponent = Observation.ObservationComponentComponent()
+ diastolicComponent.code = CodeableConcept().addCoding(
+ Coding()
+ .setSystem("http://loinc.org")
+ .setCode("8462-4")
+ .setDisplay("Diastolic blood pressure")
+ )
+ diastolicComponent.value = Quantity()
+ .setValue(record.diastolic.inMillimetersOfMercury)
+ .setUnit("mmHg")
+ .setCode("mm[Hg]")
+ .setSystem("http://unitsofmeasure.org")
+
+ observation.addComponent(systolicComponent)
+ observation.addComponent(diastolicComponent)
+
+ return observation
+ }
+
+ /**
+ * Maps a BodyFatRecord to a FHIR Observation.
+ *
+ * @param record the BodyFatRecord to be mapped
+ * @return an Observation object derived from the provided BodyFatRecord
+ */
+ private fun mapBodyFatRecord(record: BodyFatRecord) = record.createObservation(
+ codings = listOf(
+ Coding()
+ .setSystem("http://loinc.org")
+ .setCode("41982-0")
+ .setDisplay("Percentage of body fat Measured")
+ ),
+ unit = MappedUnit(
+ code = "%",
+ system = "http://unitsofmeasure.org",
+ unit = "%"
+ ),
+ valueExtractor = { percentage.value },
+ periodExtractor = { Date.from(time) to Date.from(time) }
+ )
+
+ /**
+ * Maps a BodyTemperatureRecord to a FHIR Observation.
+ *
+ * @param record the BodyTemperatureRecord to be mapped
+ * @return an Observation object derived from the provided BodyTemperatureRecord
+ */
+ private fun mapBodyTemperatureRecord(record: BodyTemperatureRecord) = record.createObservation(
+ categories = listOf(
+ Coding()
+ .setSystem("http://terminology.hl7.org/CodeSystem/observation-category")
+ .setCode("vital-signs")
+ .setDisplay("Vital Signs")
+ ),
+ codings = listOf(
+ Coding()
+ .setSystem("http://loinc.org")
+ .setCode("8310-5")
+ .setDisplay("Body temperature")
+ ),
+ unit = MappedUnit(
+ code = "Cel",
+ system = "http://unitsofmeasure.org",
+ unit = "C"
+ ),
+ valueExtractor = { temperature.inCelsius },
+ periodExtractor = { Date.from(time) to Date.from(time) }
+ )
+
+ /**
+ * Maps a HeartRateRecord to a list of FHIR Observations.
+ *
+ * @param record the HeartRateRecord to be mapped
+ * @return a list of Observation objects derived from the provided HeartRateRecord.
+ * Each object represents a single sample from the HeartRateRecord.
+ */
+ private fun mapHeartRateRecord(record: HeartRateRecord): List {
+ return record.samples.map { sample ->
+ val observation = Observation()
+
+ observation.addCommonElements()
+
+ observation.category = listOf(
+ CodeableConcept().addCoding(
+ Coding()
+ .setSystem("http://terminology.hl7.org/CodeSystem/observation-category")
+ .setCode("vital-signs")
+ .setDisplay("Vital Signs")
+ )
+ )
+
+ observation.code = CodeableConcept().addCoding(
+ Coding()
+ .setSystem("http://loinc.org")
+ .setCode("8867-4")
+ .setDisplay("Heart rate")
+ )
+
+ val dateTime = DateTimeType(Date.from(sample.time))
+ observation.effective = dateTime
+
+ observation.value = Quantity()
+ .setValue(sample.beatsPerMinute)
+ .setUnit("beats/minute")
+ .setCode("/min")
+ .setSystem("http://unitsofmeasure.org")
+
+ observation
+ }
+ }
+
+ /**
+ * Maps a HeightRecord to a FHIR Observation.
+ *
+ * @param record the HeightRecord to be mapped
+ * @return an Observation object derived from the provided HeightRecord
+ */
+ private fun mapHeightRecord(record: HeightRecord) = record.createObservation(
+ categories = listOf(
+ Coding()
+ .setSystem("http://terminology.hl7.org/CodeSystem/observation-category")
+ .setCode("vital-signs")
+ .setDisplay("Vital Signs")
+ ),
+ codings = listOf(
+ Coding()
+ .setSystem("http://loinc.org")
+ .setCode("8302-2")
+ .setDisplay("Body height")
+ ),
+ unit = MappedUnit(
+ code = "m",
+ system = "http://unitsofmeasure.org",
+ unit = "m"
+ ),
+ valueExtractor = { height.inMeters },
+ periodExtractor = { Date.from(time) to Date.from(time) }
+ )
+
+ /**
+ * Maps an OxygenSaturationRecord to a FHIR Observation.
+ *
+ * @param record the OxygenSaturationRecord to be mapped
+ * @return an Observation object derived from the provided OxygenSaturationRecord
+ */
+ private fun mapOxygenSaturationRecord(record: OxygenSaturationRecord) = record.createObservation(
+ categories = listOf(
+ Coding()
+ .setSystem("http://terminology.hl7.org/CodeSystem/observation-category")
+ .setCode("vital-signs")
+ .setDisplay("Vital Signs")
+ ),
+ codings = listOf(
+ Coding()
+ .setSystem("http://loinc.org")
+ .setCode("59408-5")
+ .setDisplay("Oxygen saturation in Arterial blood by Pulse oximetry")
+ ),
+ unit = MappedUnit(
+ code = "%",
+ system = "http://unitsofmeasure.org",
+ unit = "%"
+ ),
+ valueExtractor = { percentage.value },
+ periodExtractor = { Date.from(time) to Date.from(time) }
+ )
+
+ /**
+ * Maps a RespiratoryRateRecord to a FHIR Observation.
+ *
+ * @param record the RespiratoryRateRecord to be mapped
+ * @return an Observation object derived from the provided RespiratoryRateRecord
+ */
+ private fun mapRespiratoryRateRecord(record: RespiratoryRateRecord) = record.createObservation(
+ categories = listOf(
+ Coding()
+ .setSystem("http://terminology.hl7.org/CodeSystem/observation-category")
+ .setCode("vital-signs")
+ .setDisplay("Vital Signs")
+ ),
+ codings = listOf(
+ Coding()
+ .setSystem("http://loinc.org")
+ .setCode("9279-1")
+ .setDisplay("Respiratory rate")
+ ),
+ unit = MappedUnit(
+ code = "/min",
+ system = "http://unitsofmeasure.org",
+ unit = "breaths/minute"
+ ),
+ valueExtractor = { rate },
+ periodExtractor = { Date.from(time) to Date.from(time) }
+ )
+
+ /**
+ * Maps a StepsRecord to a FHIR Observation.
+ *
+ * @param record the StepsRecord to be mapped
+ * @return an Observation object derived from the provided StepsRecord
+ */
+ private fun mapStepsRecord(record: StepsRecord) = record.createObservation(
+ categories = listOf(
+ Coding()
+ .setSystem("http://terminology.hl7.org/CodeSystem/observation-category")
+ .setCode("activity")
+ .setDisplay("Activity")
+ ),
+ codings = listOf(
+ Coding()
+ .setSystem("http://loinc.org")
+ .setCode("55423-8")
+ .setDisplay("Number of steps")
+ ),
+ unit = MappedUnit(
+ unit = "steps",
+ code = "",
+ system = ""
+ ),
+ valueExtractor = { count.toDouble() },
+ periodExtractor = { Date.from(startTime) to Date.from(endTime) }
+ )
+
+ /**
+ * Maps a WeightRecord to a FHIR Observation.
+ *
+ * @param record the WeightRecord to be mapped
+ * @return an Observation object derived from the provided WeightRecord
+ */
+ private fun mapWeightRecord(record: WeightRecord) = record.createObservation(
+ categories = listOf(
+ Coding()
+ .setSystem("http://terminology.hl7.org/CodeSystem/observation-category")
+ .setCode("vital-signs")
+ .setDisplay("Vital Signs")
+ ),
+ codings = listOf(
+ Coding()
+ .setSystem("http://loinc.org")
+ .setCode("29463-7")
+ .setDisplay("Body weight")
+ ),
+ unit = MappedUnit(
+ code = "kg",
+ system = "http://unitsofmeasure.org",
+ unit = "kg"
+ ),
+ valueExtractor = { weight.inKilograms },
+ periodExtractor = { Date.from(time) to Date.from(time) }
+ )
+
+ private fun Observation.addCommonElements() {
+ this.setStatus(Observation.ObservationStatus.FINAL)
+ this.setIssued(Date())
+ }
+
+ private fun T.createObservation(
+ categories: List = listOf(),
+ codings: List,
+ unit: MappedUnit,
+ valueExtractor: T.() -> Double,
+ periodExtractor: T.() -> Pair,
+ ): Observation {
+ return Observation().apply {
+ addCommonElements()
+
+ identifier = listOf(Identifier().apply {
+ this.setId(this@createObservation.metadata.id)
+ })
+
+ category = listOf(CodeableConcept().apply {
+ categories.forEach { addCoding(it) }
+ })
+
+ code = CodeableConcept().apply {
+ codings.forEach { addCoding(it) }
+ }
+
+ val (start, end) = periodExtractor()
+
+ if (start == end) {
+ effective = DateTimeType().apply {
+ this.value = start
+ }
+ } else {
+ effective = Period().apply {
+ this.start = start
+ this.end = end
+ }
+ }
+
+ value = Quantity().apply {
+ this.value = valueExtractor().toBigDecimal()
+ this.unit = unit.unit
+ this.code = unit.code
+ this.system = unit.system
+ }
+ }
+ }
+}
diff --git a/modules/healthconnectonfhir/src/main/java/edu/stanford/healthconnectonfhir/di/RecordToObservationModule.kt b/modules/healthconnectonfhir/src/main/java/edu/stanford/healthconnectonfhir/di/RecordToObservationModule.kt
new file mode 100644
index 000000000..8146df80c
--- /dev/null
+++ b/modules/healthconnectonfhir/src/main/java/edu/stanford/healthconnectonfhir/di/RecordToObservationModule.kt
@@ -0,0 +1,18 @@
+package edu.stanford.healthconnectonfhir.di
+
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import edu.stanford.healthconnectonfhir.RecordToObservationMapper
+import edu.stanford.healthconnectonfhir.RecordToObservationMapperImpl
+
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class RecordToObservationModule {
+
+ @Binds
+ abstract fun bindRecordToObservationMapper(
+ impl: RecordToObservationMapperImpl,
+ ): RecordToObservationMapper
+}
diff --git a/modules/healthconnectonfhir/src/test/java/edu/stanford/healthconnectonfhir/RecordToObservationMapperTests.kt b/modules/healthconnectonfhir/src/test/java/edu/stanford/healthconnectonfhir/RecordToObservationMapperTests.kt
new file mode 100644
index 000000000..e1df89d0d
--- /dev/null
+++ b/modules/healthconnectonfhir/src/test/java/edu/stanford/healthconnectonfhir/RecordToObservationMapperTests.kt
@@ -0,0 +1,325 @@
+package edu.stanford.healthconnectonfhir
+
+import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord
+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.FloorsClimbedRecord
+import androidx.health.connect.client.records.HeartRateRecord
+import androidx.health.connect.client.records.HeightRecord
+import androidx.health.connect.client.records.OxygenSaturationRecord
+import androidx.health.connect.client.records.RespiratoryRateRecord
+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.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 com.google.common.truth.Truth.assertThat
+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.Quantity
+import org.junit.Test
+import java.time.Instant
+import java.time.ZoneOffset
+import java.util.Date
+
+class RecordToObservationMapperTests {
+ private val mapper = RecordToObservationMapperImpl()
+
+ @Test
+ fun `activeCaloriesBurnedRecord toObservation isCorrect`() {
+ val activeCaloriesBurnedRecord = ActiveCaloriesBurnedRecord(
+ metadata = Metadata(id = "123456"),
+ energy = Energy.calories(250.0),
+ startTime = Instant.parse("2023-05-18T10:15:30.00Z"),
+ endTime = Instant.parse("2023-05-18T11:15:30.00Z"),
+ startZoneOffset = ZoneOffset.UTC,
+ endZoneOffset = ZoneOffset.UTC
+ )
+
+ val observation = mapper.map(activeCaloriesBurnedRecord).first()
+
+ assertThat(observation.status).isEqualTo(Observation.ObservationStatus.FINAL)
+ assertThat(observation.identifier.first().id).isEqualTo("123456")
+ assertThat(observation.issued.time).isAtMost(Date().time)
+ assertThat(observation.code.codingFirstRep.code).isEqualTo("41981-2")
+ assertThat(observation.categoryFirstRep.codingFirstRep.code).isEqualTo("activity")
+ assertThat((observation.effective as Period).start).isEqualTo(Date.from(Instant.parse("2023-05-18T10:15:30.00Z")))
+ assertThat((observation.effective as Period).end).isEqualTo(Date.from(Instant.parse("2023-05-18T11:15:30.00Z")))
+ assertThat((observation.value as Quantity).value.toDouble()).isEqualTo(250.0)
+ assertThat((observation.value as Quantity).unit).isEqualTo("kcal")
+ assertThat((observation.value as Quantity).code).isEqualTo("kcal")
+ assertThat((observation.value as Quantity).system).isEqualTo("http://unitsofmeasure.org")
+ }
+
+ @Test
+ fun `bloodPressureRecord toObservation isCorrect`() {
+ val bloodPressureRecord = BloodPressureRecord(
+ metadata = Metadata(id = "123456"),
+ time = Instant.parse("2023-05-18T10:15:30.00Z"),
+ systolic = Pressure.millimetersOfMercury(120.0),
+ diastolic = Pressure.millimetersOfMercury(80.0),
+ zoneOffset = ZoneOffset.UTC
+ )
+
+ val observation = mapper.map(bloodPressureRecord).first()
+
+ assertThat(observation.status).isEqualTo(Observation.ObservationStatus.FINAL)
+ assertThat(observation.identifier.first().id).isEqualTo("123456")
+ assertThat(observation.issued.time).isAtMost(Date().time)
+ assertThat(observation.code.codingFirstRep.code).isEqualTo("85354-9")
+ assertThat(observation.categoryFirstRep.codingFirstRep.code).isEqualTo("vital-signs")
+ assertThat((observation.effective as DateTimeType).value).isEqualTo(Date.from(Instant.parse("2023-05-18T10:15:30.00Z")))
+ assertThat((observation.component[0].value as Quantity).value.toDouble()).isEqualTo(120.0)
+ assertThat((observation.component[0].value as Quantity).unit).isEqualTo("mmHg")
+ assertThat((observation.component[0].value as Quantity).code).isEqualTo("mm[Hg]")
+ assertThat((observation.component[0].value as Quantity).system).isEqualTo("http://unitsofmeasure.org")
+ assertThat((observation.component[1].value as Quantity).value.toDouble()).isEqualTo(80.0)
+ assertThat((observation.component[1].value as Quantity).unit).isEqualTo("mmHg")
+ assertThat((observation.component[1].value as Quantity).code).isEqualTo("mm[Hg]")
+ assertThat((observation.component[1].value as Quantity).system).isEqualTo("http://unitsofmeasure.org")
+ }
+
+ @Test
+ fun `bloodGlucoseRecord toObservation isCorrect`() {
+ val bloodGlucoseRecord = BloodGlucoseRecord(
+ metadata = Metadata(id = "123456"),
+ time = Instant.parse("2023-05-18T10:15:30.00Z"),
+ level = BloodGlucose.milligramsPerDeciliter(90.0),
+ zoneOffset = ZoneOffset.UTC
+ )
+
+ val observation = mapper.map(bloodGlucoseRecord).first()
+
+ assertThat(observation.status).isEqualTo(Observation.ObservationStatus.FINAL)
+ assertThat(observation.identifier.first().id).isEqualTo("123456")
+ assertThat(observation.issued.time).isAtMost(Date().time)
+ assertThat(observation.code.codingFirstRep.code).isEqualTo("41653-7")
+ assertThat((observation.effective as DateTimeType).value).isEqualTo(Date.from(Instant.parse("2023-05-18T10:15:30.00Z")))
+ assertThat((observation.value as Quantity).value.toDouble()).isEqualTo(90.0)
+ assertThat((observation.value as Quantity).unit).isEqualTo("mg/dL")
+ assertThat((observation.value as Quantity).code).isEqualTo("mg/dL")
+ assertThat((observation.value as Quantity).system).isEqualTo("http://unitsofmeasure.org")
+ }
+
+ @Test
+ fun `bodyFatRecord toObservation isCorrect`() {
+ val bodyFatRecord = BodyFatRecord(
+ metadata = Metadata(id = "123456"),
+ time = Instant.parse("2023-05-18T10:15:30.00Z"),
+ percentage = Percentage(10.0),
+ zoneOffset = ZoneOffset.UTC
+ )
+
+ val observation = mapper.map(bodyFatRecord).first()
+
+ assertThat(observation.status).isEqualTo(Observation.ObservationStatus.FINAL)
+ assertThat(observation.identifier.first().id).isEqualTo("123456")
+ assertThat(observation.issued.time).isAtMost(Date().time)
+ assertThat(observation.code.codingFirstRep.code).isEqualTo("41982-0")
+ assertThat((observation.effective as DateTimeType).value).isEqualTo(Date.from(Instant.parse("2023-05-18T10:15:30.00Z")))
+ assertThat((observation.value as Quantity).value.toDouble()).isEqualTo(10.0)
+ assertThat((observation.value as Quantity).unit).isEqualTo("%")
+ assertThat((observation.value as Quantity).code).isEqualTo("%")
+ assertThat((observation.value as Quantity).system).isEqualTo("http://unitsofmeasure.org")
+ }
+
+ @Test
+ fun `bodyTemperatureRecord toObservation isCorrect`() {
+ val bodyTemperatureRecord = BodyTemperatureRecord(
+ metadata = Metadata(id = "123456"),
+ time = Instant.parse("2023-05-18T10:15:30.00Z"),
+ temperature = Temperature.celsius(37.5),
+ zoneOffset = ZoneOffset.UTC
+ )
+
+ val observation = mapper.map(bodyTemperatureRecord).first()
+
+ assertThat(observation.status).isEqualTo(Observation.ObservationStatus.FINAL)
+ assertThat(observation.identifier.first().id).isEqualTo("123456")
+ assertThat(observation.issued.time).isAtMost(Date().time)
+ assertThat(observation.code.codingFirstRep.code).isEqualTo("8310-5")
+ assertThat(observation.categoryFirstRep.codingFirstRep.code).isEqualTo("vital-signs")
+ assertThat((observation.effective as DateTimeType).value).isEqualTo(Date.from(Instant.parse("2023-05-18T10:15:30.00Z")))
+ assertThat((observation.value as Quantity).value.toDouble()).isEqualTo(37.5)
+ assertThat((observation.value as Quantity).unit).isEqualTo("C")
+ assertThat((observation.value as Quantity).code).isEqualTo("Cel")
+ assertThat((observation.value as Quantity).system).isEqualTo("http://unitsofmeasure.org")
+ }
+
+ @Test
+ fun `heartRateRecord toObservations isCorrect`() {
+ val heartRateRecord = HeartRateRecord(
+ samples = listOf(
+ HeartRateRecord.Sample(
+ time = Instant.parse("2023-05-18T10:15:30.00Z"),
+ beatsPerMinute = 72L
+ ),
+ HeartRateRecord.Sample(
+ time = Instant.parse("2023-05-18T10:16:30.00Z"),
+ beatsPerMinute = 75L
+ )
+ ),
+ startTime = Instant.parse("2023-05-18T10:15:30.00Z"),
+ endTime = Instant.parse("2023-05-18T10:17:30.00Z"),
+ startZoneOffset = ZoneOffset.UTC,
+ endZoneOffset = ZoneOffset.UTC
+ )
+
+ val observations = mapper.map(heartRateRecord)
+
+ observations.forEach { observation ->
+ assertThat(observation.status).isEqualTo(Observation.ObservationStatus.FINAL)
+ assertThat(observation.issued.time).isAtMost(Date().time)
+ assertThat(observation.code.codingFirstRep.code).isEqualTo("8867-4")
+ assertThat(observation.categoryFirstRep.codingFirstRep.code).isEqualTo("vital-signs")
+ }
+
+ assertThat((observations[0].effective as DateTimeType).value).isEqualTo(Date.from(Instant.parse("2023-05-18T10:15:30.00Z")))
+ assertThat((observations[0].value as Quantity).value.toDouble()).isEqualTo(72.0)
+ assertThat((observations[0].value as Quantity).unit).isEqualTo("beats/minute")
+
+ assertThat((observations[1].effective as DateTimeType).value).isEqualTo(Date.from(Instant.parse("2023-05-18T10:16:30.00Z")))
+ assertThat((observations[1].value as Quantity).value.toDouble()).isEqualTo(75.0)
+ assertThat((observations[1].value as Quantity).unit).isEqualTo("beats/minute")
+ assertThat((observations[1].value as Quantity).code).isEqualTo("/min")
+ assertThat((observations[1].value as Quantity).system).isEqualTo("http://unitsofmeasure.org")
+ }
+
+ @Test
+ fun `heightRecord toObservation isCorrect`() {
+ val heightRecord = HeightRecord(
+ metadata = Metadata(id = "123456"),
+ time = Instant.parse("2023-05-18T10:15:30.00Z"),
+ height = Length.meters(1.5),
+ zoneOffset = ZoneOffset.UTC
+ )
+
+ val observation = mapper.map(heightRecord).first()
+
+ assertThat(observation.status).isEqualTo(Observation.ObservationStatus.FINAL)
+ assertThat(observation.identifier.first().id).isEqualTo("123456")
+ assertThat(observation.issued.time).isAtMost(Date().time)
+ assertThat(observation.code.codingFirstRep.code).isEqualTo("8302-2")
+ assertThat(observation.categoryFirstRep.codingFirstRep.code).isEqualTo("vital-signs")
+ assertThat((observation.effective as DateTimeType).value).isEqualTo(Date.from(Instant.parse("2023-05-18T10:15:30.00Z")))
+ assertThat((observation.value as Quantity).value.toDouble()).isEqualTo(1.5)
+ assertThat((observation.value as Quantity).unit).isEqualTo("m")
+ assertThat((observation.value as Quantity).code).isEqualTo("m")
+ assertThat((observation.value as Quantity).system).isEqualTo("http://unitsofmeasure.org")
+ }
+
+ @Test
+ fun `oxygenSaturationRecord toObservation isCorrect`() {
+ val oxygenSaturationRecord = OxygenSaturationRecord(
+ metadata = Metadata(id = "123456"),
+ time = Instant.parse("2023-05-18T10:15:30.00Z"),
+ percentage = Percentage(99.0),
+ zoneOffset = ZoneOffset.UTC
+ )
+
+ val observation = mapper.map(oxygenSaturationRecord).first()
+
+ assertThat(observation.status).isEqualTo(Observation.ObservationStatus.FINAL)
+ assertThat(observation.identifier.first().id).isEqualTo("123456")
+ assertThat(observation.issued.time).isAtMost(Date().time)
+ assertThat(observation.categoryFirstRep.codingFirstRep.code).isEqualTo("vital-signs")
+ assertThat(observation.code.codingFirstRep.code).isEqualTo("59408-5")
+ assertThat((observation.effective as DateTimeType).value).isEqualTo(Date.from(Instant.parse("2023-05-18T10:15:30.00Z")))
+ assertThat((observation.value as Quantity).value.toDouble()).isEqualTo(99.0)
+ assertThat((observation.value as Quantity).unit).isEqualTo("%")
+ assertThat((observation.value as Quantity).code).isEqualTo("%")
+ assertThat((observation.value as Quantity).system).isEqualTo("http://unitsofmeasure.org")
+ }
+
+ @Test
+ fun `respiratoryRate toObservation isCorrect`() {
+ val respiratoryRateRecord = RespiratoryRateRecord(
+ metadata = Metadata(id = "123456"),
+ time = Instant.parse("2023-05-18T10:15:30.00Z"),
+ rate = 18.0,
+ zoneOffset = ZoneOffset.UTC
+ )
+
+ val observation = mapper.map(respiratoryRateRecord).first()
+
+ assertThat(observation.status).isEqualTo(Observation.ObservationStatus.FINAL)
+ assertThat(observation.identifier.first().id).isEqualTo("123456")
+ assertThat(observation.issued.time).isAtMost(Date().time)
+ assertThat(observation.categoryFirstRep.codingFirstRep.code).isEqualTo("vital-signs")
+ assertThat(observation.code.codingFirstRep.code).isEqualTo("9279-1")
+ assertThat((observation.effective as DateTimeType).value).isEqualTo(Date.from(Instant.parse("2023-05-18T10:15:30.00Z")))
+ assertThat((observation.value as Quantity).value.toDouble()).isEqualTo(18.0)
+ assertThat((observation.value as Quantity).unit).isEqualTo("breaths/minute")
+ assertThat((observation.value as Quantity).code).isEqualTo("/min")
+ assertThat((observation.value as Quantity).system).isEqualTo("http://unitsofmeasure.org")
+ }
+
+ @Test
+ fun `stepsRecord toObservation isCorrect`() {
+ val stepsRecord = StepsRecord(
+ metadata = Metadata(id = "123456"),
+ count = 1000,
+ startTime = Instant.parse("2023-05-18T10:15:30.00Z"),
+ endTime = Instant.parse("2023-05-18T11:15:30.00Z"),
+ startZoneOffset = ZoneOffset.UTC,
+ endZoneOffset = ZoneOffset.UTC
+ )
+
+ val observation = mapper.map(stepsRecord).first()
+
+ assertThat(observation.status).isEqualTo(Observation.ObservationStatus.FINAL)
+ assertThat(observation.identifier.first().id).isEqualTo("123456")
+ assertThat(observation.issued.time).isAtMost(Date().time)
+ assertThat(observation.code.codingFirstRep.code).isEqualTo("55423-8")
+ assertThat(observation.categoryFirstRep.codingFirstRep.code).isEqualTo("activity")
+ assertThat((observation.effective as Period).start).isEqualTo(Date.from(Instant.parse("2023-05-18T10:15:30.00Z")))
+ assertThat((observation.effective as Period).end).isEqualTo(Date.from(Instant.parse("2023-05-18T11:15:30.00Z")))
+ assertThat((observation.value as Quantity).value.toDouble()).isEqualTo(1000.0)
+ assertThat((observation.value as Quantity).unit).isEqualTo("steps")
+ }
+
+ @Test
+ fun `weightRecord toObservation isCorrect`() {
+ val weightRecord = WeightRecord(
+ metadata = Metadata(id = "123456"),
+ weight = Mass.kilograms(75.0),
+ time = Instant.parse("2023-05-18T10:15:30.00Z"),
+ zoneOffset = ZoneOffset.UTC
+ )
+
+ val observation = mapper.map(weightRecord).first()
+
+ assertThat(observation.status).isEqualTo(Observation.ObservationStatus.FINAL)
+ assertThat(observation.identifier.first().id).isEqualTo("123456")
+ assertThat(observation.issued.time).isAtMost(Date().time)
+ assertThat(observation.code.codingFirstRep.code).isEqualTo("29463-7")
+ assertThat(observation.categoryFirstRep.codingFirstRep.code).isEqualTo("vital-signs")
+ assertThat((observation.effective as DateTimeType).value).isEqualTo(Date.from(Instant.parse("2023-05-18T10:15:30.00Z")))
+ assertThat((observation.value as Quantity).value.toDouble()).isEqualTo(75.0)
+ assertThat((observation.value as Quantity).unit).isEqualTo("kg")
+ assertThat((observation.value as Quantity).code).isEqualTo("kg")
+ assertThat((observation.value as Quantity).system).isEqualTo("http://unitsofmeasure.org")
+ }
+
+ @Test(expected = IllegalArgumentException::class)
+ fun `map throws IllegalArgumentException for unsupported record type`() {
+ val record = FloorsClimbedRecord(
+ metadata = Metadata(id = "123456"),
+ startTime = Instant.parse("2023-05-18T10:15:30.00Z"),
+ endTime = Instant.parse("2023-05-18T11:15:30.00Z"),
+ startZoneOffset = ZoneOffset.UTC,
+ endZoneOffset = ZoneOffset.UTC,
+ floors = 2.0
+ )
+
+ mapper.map(record)
+ }
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 0d228923a..c0875daed 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -36,6 +36,7 @@ include(":core:logging")
include(":core:testing")
include(":core:utils")
include(":modules:contact")
+include(":modules:healthconnectonfhir")
include(":modules:onboarding")
include(":modules:account")
include(":core:navigation")