diff --git a/google-health-library/build.gradle b/google-health-library/build.gradle new file mode 100644 index 00000000..785104e7 --- /dev/null +++ b/google-health-library/build.gradle @@ -0,0 +1,42 @@ + +group = 'org.radarbase' +version = '0.0.1' + +apply plugin: 'maven-publish' + +repositories { + mavenCentral() +} + +dependencies { + implementation libs.kotlin.stdlib + + implementation libs.okhttp + + implementation libs.radar.schemas.commons + + implementation libs.jackson.annotations + + implementation libs.jackson.databind + + implementation libs.avro + + implementation libs.jackson.datatype.jsr310 + + testImplementation libs.kotlin.test + + testImplementation libs.kotlin.test.junit +} + +project.afterEvaluate { + publishing { + publications { + library(MavenPublication) { + setGroupId "$group" + setArtifactId "google-health-library" + version "$version" + from components.java + } + } + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/AvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/AvroConverter.kt new file mode 100644 index 00000000..7c5ed6f6 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/AvroConverter.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.converter + +import com.fasterxml.jackson.databind.JsonNode +import org.apache.avro.specific.SpecificRecord +import org.radarbase.googlehealth.user.User +import java.io.IOException + +interface AvroConverter { + + val topic: String + + @Throws(IOException::class) + fun convert( + tree: JsonNode, + user: User, + ): List> +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/DailyRestingHeartRateGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/DailyRestingHeartRateGoogleHealthAvroConverter.kt new file mode 100644 index 00000000..5b4e98f3 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/DailyRestingHeartRateGoogleHealthAvroConverter.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.converter + +import com.fasterxml.jackson.databind.JsonNode +import org.apache.avro.specific.SpecificRecord +import org.radarbase.googlehealth.user.User +import org.radarbase.googlehealth.util.restingHeartRate + +class DailyRestingHeartRateGoogleHealthAvroConverter(topic: String) : + GoogleHealthAvroConverter(topic) { + override fun convertDataPoint( + point: JsonNode, + user: User, + ): List> { + val data = point["dailyRestingHeartRate"] ?: return emptyList() + val dateNode = data["date"] ?: return emptyList() + val bpm = data["beatsPerMinute"]?.asInt() ?: return emptyList() + val isoDate = String.format( + "%04d-%02d-%02d", + dateNode["year"].asInt(), + dateNode["month"].asInt(), + dateNode["day"].asInt(), + ) + val record = restingHeartRate { + date = isoDate + timeReceived = nowEpochSeconds() + restingHeartRate = bpm + } + return listOf(user.observationKey to record) + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/DailySleepTemperatureDerivationsGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/DailySleepTemperatureDerivationsGoogleHealthAvroConverter.kt new file mode 100644 index 00000000..f4c070ba --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/DailySleepTemperatureDerivationsGoogleHealthAvroConverter.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.converter + +import com.fasterxml.jackson.databind.JsonNode +import org.apache.avro.specific.SpecificRecord +import org.radarbase.googlehealth.user.User +import org.radarbase.googlehealth.util.skinTemperature +import org.radarcns.connector.fitbit.FitbitSkinTemperatureLogType + +class DailySleepTemperatureDerivationsGoogleHealthAvroConverter(topic: String) : + GoogleHealthAvroConverter(topic) { + override fun convertDataPoint( + point: JsonNode, + user: User, + ): List> { + val data = point["dailySleepTemperatureDerivations"] ?: return emptyList() + val nightly = data["nightlyTemperatureCelsius"]?.takeIf { it.isNumber }?.floatValue() ?: return emptyList() + val baseline = data["baselineTemperatureCelsius"]?.takeIf { it.isNumber }?.floatValue() ?: return emptyList() + val time = parseDate(data) ?: return emptyList() + val record = skinTemperature { + this.time = epochSeconds(time) + timeReceived = nowEpochSeconds() + relativeTemperature = nightly - baseline + logType = FitbitSkinTemperatureLogType.UNKNOWN + } + return listOf(user.observationKey to record) + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/ExerciseGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/ExerciseGoogleHealthAvroConverter.kt new file mode 100644 index 00000000..d5a62552 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/ExerciseGoogleHealthAvroConverter.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.converter + +import com.fasterxml.jackson.databind.JsonNode +import org.apache.avro.specific.SpecificRecord +import org.radarbase.googlehealth.user.User +import org.radarbase.googlehealth.util.activityHeartRate +import org.radarbase.googlehealth.util.activityLogRecord + +class ExerciseGoogleHealthAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { + override fun convertDataPoint( + point: JsonNode, + user: User, + ): List> { + val data = point["exercise"] ?: return emptyList() + val (start, end) = parseInterval(data) ?: return emptyList() + val offsetSeconds = parseUtcOffsetSeconds( + data["interval"]?.get("startUtcOffset")?.asText(), + ) + val durationSec = (end.epochSecond - start.epochSecond).toFloat().coerceAtLeast(0.0f) + val metrics = data["metricsSummary"] + val distanceKm = metrics?.get("distanceMillimeters")?.takeIf { !it.isNull } + ?.asDouble()?.let { it.toFloat() / 1_000_000f } + val caloriesKcal = metrics?.get("caloriesKcal")?.takeIf { !it.isNull }?.asDouble() + val energyKj = caloriesKcal?.let { (it * KCAL_TO_KJ).toFloat() } + val stepCount = metrics?.get("steps")?.takeIf { !it.isNull }?.asInt() + val avgHr = metrics?.get("averageHeartRateBeatsPerMinute")?.takeIf { !it.isNull }?.asInt() + val avgHeartRate = avgHr?.let { activityHeartRate { mean = it } } + val exerciseType = data["exerciseType"]?.asText() + val activityId = (point["name"]?.asText() ?: exerciseType ?: "") + .hashCode().toLong() + val record = activityLogRecord { + time = epochSeconds(start) + timeReceived = nowEpochSeconds() + timeZoneOffset = offsetSeconds + timeLastModified = epochSeconds(end) + duration = durationSec + durationActive = durationSec + id = activityId + name = data["displayName"]?.asText() ?: exerciseType + logType = point["dataSource"]?.get("recordingMethod")?.asText() + type = null + source = null + manualDataEntry = null + energy = energyKj + levels = null + heartRate = avgHeartRate + steps = stepCount + distance = distanceKm + speed = null + } + return listOf(user.observationKey to record) + } + + companion object { + private const val KCAL_TO_KJ = 4.1868 + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthAvroConverter.kt new file mode 100644 index 00000000..290657dc --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthAvroConverter.kt @@ -0,0 +1,93 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.converter + +import com.fasterxml.jackson.databind.JsonNode +import org.apache.avro.specific.SpecificRecord +import org.radarbase.googlehealth.user.User +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.ZoneOffset + +abstract class GoogleHealthAvroConverter(override val topic: String) : AvroConverter { + + abstract fun convertDataPoint( + point: JsonNode, + user: User, + ): List> + + override fun convert(tree: JsonNode, user: User): List> { + val points = tree["dataPoints"] ?: tree["rollupDataPoints"] ?: return emptyList() + if (!points.isArray) return emptyList() + return points.flatMap { convertDataPoint(it, user) } + } + + companion object { + fun parseInterval(node: JsonNode): Pair? { + val interval = node["interval"] ?: return null + val start = runCatching { + Instant.parse(interval["startTime"]?.asText() ?: return null) + }.getOrNull() ?: return null + val end = runCatching { + Instant.parse(interval["endTime"]?.asText() ?: return null) + }.getOrNull() ?: return null + return start to end + } + + fun parseSampleTime(node: JsonNode): Instant? { + val sample = node["sampleTime"] ?: return null + val text = sample["physicalTime"]?.asText()?.takeIf { it.isNotEmpty() } ?: return null + return runCatching { Instant.parse(text) }.getOrNull() + } + + fun parseDate(node: JsonNode, utcOffsetSeconds: Int = 0): Instant? { + val dateNode = node["date"] ?: return null + val year = dateNode["year"]?.asInt() ?: return null + val month = dateNode["month"]?.asInt() ?: return null + val day = dateNode["day"]?.asInt() ?: return null + val local = runCatching { LocalDate.of(year, month, day) }.getOrNull() ?: return null + val timeNode = node["time"] + val dateTime = if (timeNode != null && !timeNode.isNull) { + LocalDateTime.of( + local, + LocalTime.of( + timeNode["hours"]?.asInt() ?: 0, + timeNode["minutes"]?.asInt() ?: 0, + timeNode["seconds"]?.asInt() ?: 0, + timeNode["nanos"]?.asInt() ?: 0, + ), + ) + } else { + local.atStartOfDay() + } + return dateTime.toInstant(ZoneOffset.ofTotalSeconds(utcOffsetSeconds)) + } + + fun parseUtcOffsetSeconds(durationText: String?): Int { + if (durationText.isNullOrEmpty()) return 0 + val trimmed = durationText.trim().removeSuffix("s") + val seconds = trimmed.toLongOrNull() ?: return 0 + return seconds.toInt() + } + + fun epochSeconds(instant: Instant): Double = instant.toEpochMilli() / 1000.0 + + fun nowEpochSeconds(): Double = Instant.now().toEpochMilli() / 1000.0 + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/HeartRateGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/HeartRateGoogleHealthAvroConverter.kt new file mode 100644 index 00000000..509f8eaa --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/HeartRateGoogleHealthAvroConverter.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.converter + +import com.fasterxml.jackson.databind.JsonNode +import org.apache.avro.specific.SpecificRecord +import org.radarbase.googlehealth.user.User +import org.radarbase.googlehealth.util.intradayHeartRate + +class HeartRateGoogleHealthAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { + override fun convertDataPoint( + point: JsonNode, + user: User, + ): List> { + val data = point["heartRate"] ?: return emptyList() + val time = parseSampleTime(data) ?: return emptyList() + val bpm = data["beatsPerMinute"]?.asInt() ?: return emptyList() + val record = intradayHeartRate { + this.time = epochSeconds(time) + timeReceived = nowEpochSeconds() + timeInterval = 1 + heartRate = bpm + } + return listOf(user.observationKey to record) + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/HeartRateVariabilityGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/HeartRateVariabilityGoogleHealthAvroConverter.kt new file mode 100644 index 00000000..4ec61852 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/HeartRateVariabilityGoogleHealthAvroConverter.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.converter + +import com.fasterxml.jackson.databind.JsonNode +import org.apache.avro.specific.SpecificRecord +import org.radarbase.googlehealth.user.User +import org.radarbase.googlehealth.util.intradayHeartRateVariability + +class HeartRateVariabilityGoogleHealthAvroConverter(topic: String) : + GoogleHealthAvroConverter(topic) { + override fun convertDataPoint( + point: JsonNode, + user: User, + ): List> { + val data = point["heartRateVariability"] ?: return emptyList() + val time = parseSampleTime(data) ?: return emptyList() + val rmssd = data["rootMeanSquareOfSuccessiveDifferencesMilliseconds"]?.floatValue() + ?: return emptyList() + val record = intradayHeartRateVariability { + this.time = epochSeconds(time) + timeReceived = nowEpochSeconds() + this.rmssd = rmssd + coverage = UNAVAILABLE + highFrequency = UNAVAILABLE + lowFrequency = UNAVAILABLE + } + return listOf(user.observationKey to record) + } + + companion object { + private const val UNAVAILABLE = 0.0f + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/OxygenSaturationGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/OxygenSaturationGoogleHealthAvroConverter.kt new file mode 100644 index 00000000..6e864218 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/OxygenSaturationGoogleHealthAvroConverter.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.converter + +import com.fasterxml.jackson.databind.JsonNode +import org.apache.avro.specific.SpecificRecord +import org.radarbase.googlehealth.user.User +import org.radarbase.googlehealth.util.intradaySpo2 + +class OxygenSaturationGoogleHealthAvroConverter(topic: String) : + GoogleHealthAvroConverter(topic) { + override fun convertDataPoint( + point: JsonNode, + user: User, + ): List> { + val data = point["oxygenSaturation"] ?: return emptyList() + val time = parseSampleTime(data) ?: return emptyList() + val pct = data["percentage"]?.floatValue() ?: return emptyList() + val record = intradaySpo2 { + this.time = epochSeconds(time) + timeReceived = nowEpochSeconds() + spo2 = pct + } + return listOf(user.observationKey to record) + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/RespiratoryRateSleepSummaryGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/RespiratoryRateSleepSummaryGoogleHealthAvroConverter.kt new file mode 100644 index 00000000..e5243b2b --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/RespiratoryRateSleepSummaryGoogleHealthAvroConverter.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.converter + +import com.fasterxml.jackson.databind.JsonNode +import org.apache.avro.specific.SpecificRecord +import org.radarbase.googlehealth.user.User +import org.radarbase.googlehealth.util.breathingRate + +class RespiratoryRateSleepSummaryGoogleHealthAvroConverter(topic: String) : + GoogleHealthAvroConverter(topic) { + override fun convertDataPoint( + point: JsonNode, + user: User, + ): List> { + val data = point["respiratoryRateSleepSummary"] ?: return emptyList() + val time = parseSampleTime(data) ?: return emptyList() + val deep = data["deepSleepStats"]?.get("breathsPerMinute")?.floatValue() ?: UNAVAILABLE + val full = data["fullSleepStats"]?.get("breathsPerMinute")?.floatValue() ?: UNAVAILABLE + val light = data["lightSleepStats"]?.get("breathsPerMinute")?.floatValue() ?: UNAVAILABLE + val rem = data["remSleepStats"]?.get("breathsPerMinute")?.floatValue() ?: UNAVAILABLE + val record = breathingRate { + this.time = epochSeconds(time) + timeReceived = nowEpochSeconds() + lightSleep = light + deepSleep = deep + remSleep = rem + fullSleep = full + } + return listOf(user.observationKey to record) + } + + companion object { + private const val UNAVAILABLE = 0.0f + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/SleepClassicGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/SleepClassicGoogleHealthAvroConverter.kt new file mode 100644 index 00000000..d7a78d24 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/SleepClassicGoogleHealthAvroConverter.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.converter + +import com.fasterxml.jackson.databind.JsonNode +import org.apache.avro.specific.SpecificRecord +import org.radarbase.googlehealth.user.User +import org.radarbase.googlehealth.util.sleepClassic +import org.radarcns.connector.fitbit.FitbitSleepClassicLevel +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +class SleepClassicGoogleHealthAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { + override fun convertDataPoint( + point: JsonNode, + user: User, + ): List> { + val data = point["sleep"] ?: return emptyList() + val stages = data["stages"]?.takeIf { it.isArray } ?: return emptyList() + val timeReceived = nowEpochSeconds() + // Route by stage-type family. This converter only emits records for CLASSIC + // stages (ASLEEP/RESTLESS). Also includes AWAKE because AWAKE appears in + // both Google's STAGES and CLASSIC enum families — we map it here ONLY when + // the session has at least one ASLEEP or RESTLESS stage (otherwise a STAGES + // session's AWAKE would incorrectly route to classic). See SleepStageGoogleHealthAvroConverter. + val isClassicSession = stages.any { s -> + s["type"]?.asText() in CLASSIC_ONLY + } + if (!isClassicSession) return emptyList() + return stages.mapNotNull { stage -> + val stageType = stage["type"]?.asText() ?: return@mapNotNull null + if (stageType !in CLASSIC_FAMILY) return@mapNotNull null + val start = stage["startTime"]?.asText() + ?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return@mapNotNull null + val end = stage["endTime"]?.asText() + ?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return@mapNotNull null + val record = sleepClassic { + dateTime = LOCAL_FMT.format(LocalDateTime.ofInstant(start, ZoneOffset.UTC)) + this.timeReceived = timeReceived + duration = (end.epochSecond - start.epochSecond).toInt().coerceAtLeast(0) + level = mapLevel(stageType) + efficiency = null + } + user.observationKey to record + } + } + + private fun mapLevel(text: String?): FitbitSleepClassicLevel = when (text) { + "ASLEEP" -> FitbitSleepClassicLevel.ASLEEP + "RESTLESS" -> FitbitSleepClassicLevel.RESTLESS + "AWAKE" -> FitbitSleepClassicLevel.AWAKE + else -> FitbitSleepClassicLevel.UNKNOWN + } + + companion object { + private val LOCAL_FMT: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME + private val CLASSIC_ONLY = setOf("ASLEEP", "RESTLESS") + private val CLASSIC_FAMILY = setOf("ASLEEP", "RESTLESS", "AWAKE") + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/SleepStageGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/SleepStageGoogleHealthAvroConverter.kt new file mode 100644 index 00000000..0b30da43 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/SleepStageGoogleHealthAvroConverter.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.converter + +import com.fasterxml.jackson.databind.JsonNode +import org.apache.avro.specific.SpecificRecord +import org.radarbase.googlehealth.user.User +import org.radarbase.googlehealth.util.sleepStage +import org.radarcns.connector.fitbit.FitbitSleepStageLevel +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +class SleepStageGoogleHealthAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { + override fun convertDataPoint( + point: JsonNode, + user: User, + ): List> { + val data = point["sleep"] ?: return emptyList() + val stages = data["stages"]?.takeIf { it.isArray } ?: return emptyList() + val timeReceived = nowEpochSeconds() + // Route by stage-type family rather than relying on a top-level `type` field, + // whose presence in list/reconcile responses is unverified — the v4 discovery + // schema does NOT list `type` on the Sleep resource. This converter only emits + // records for the STAGES family (DEEP/LIGHT/REM/AWAKE). Classic stages + // (ASLEEP/RESTLESS) are handled by SleepClassicGoogleHealthAvroConverter. + return stages.mapNotNull { stage -> + val stageType = stage["type"]?.asText() ?: return@mapNotNull null + if (stageType !in STAGES_FAMILY) return@mapNotNull null + val start = stage["startTime"]?.asText() + ?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return@mapNotNull null + val end = stage["endTime"]?.asText() + ?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return@mapNotNull null + val record = sleepStage { + dateTime = LOCAL_FMT.format(LocalDateTime.ofInstant(start, ZoneOffset.UTC)) + this.timeReceived = timeReceived + duration = (end.epochSecond - start.epochSecond).toInt().coerceAtLeast(0) + level = mapLevel(stageType) + efficiency = null + } + user.observationKey to record + } + } + + private fun mapLevel(text: String?): FitbitSleepStageLevel = when (text) { + "DEEP" -> FitbitSleepStageLevel.DEEP + "LIGHT" -> FitbitSleepStageLevel.LIGHT + "REM" -> FitbitSleepStageLevel.REM + "AWAKE" -> FitbitSleepStageLevel.AWAKE + else -> FitbitSleepStageLevel.UNKNOWN + } + + companion object { + private val LOCAL_FMT: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME + private val STAGES_FAMILY = setOf("DEEP", "LIGHT", "REM", "AWAKE") + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/StepsGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/StepsGoogleHealthAvroConverter.kt new file mode 100644 index 00000000..97c5a185 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/StepsGoogleHealthAvroConverter.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.converter + +import com.fasterxml.jackson.databind.JsonNode +import org.apache.avro.specific.SpecificRecord +import org.radarbase.googlehealth.user.User +import org.radarbase.googlehealth.util.intradaySteps + +class StepsGoogleHealthAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { + override fun convertDataPoint( + point: JsonNode, + user: User, + ): List> { + val data = point["steps"] ?: return emptyList() + val (start, end) = parseInterval(data) ?: return emptyList() + val count = data["count"]?.asInt() ?: return emptyList() + val record = intradaySteps { + time = epochSeconds(start) + timeReceived = nowEpochSeconds() + timeInterval = (end.epochSecond - start.epochSecond).toInt().coerceAtLeast(0) + steps = count + } + return listOf(user.observationKey to record) + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/TotalCaloriesGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/TotalCaloriesGoogleHealthAvroConverter.kt new file mode 100644 index 00000000..ee7dcb2f --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/TotalCaloriesGoogleHealthAvroConverter.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.converter + +import com.fasterxml.jackson.databind.JsonNode +import org.apache.avro.specific.SpecificRecord +import org.radarbase.googlehealth.user.User +import org.radarbase.googlehealth.util.intradayCalories +import java.time.Instant + +class TotalCaloriesGoogleHealthAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { + override fun convertDataPoint( + point: JsonNode, + user: User, + ): List> { + val start = point["startTime"]?.asText()?.let(Instant::parse) ?: return emptyList() + val end = point["endTime"]?.asText()?.let(Instant::parse) ?: return emptyList() + val kilocalories = point["totalCalories"]?.get("kcalSum")?.doubleValue() ?: return emptyList() + val record = intradayCalories { + time = epochSeconds(start) + timeReceived = nowEpochSeconds() + timeInterval = (end.epochSecond - start.epochSecond).toInt().coerceAtLeast(0) + calories = kilocalories + level = 0 + mets = 0.0 + } + return listOf(user.observationKey to record) + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/exception/TransientGoogleHealthException.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/exception/TransientGoogleHealthException.kt new file mode 100644 index 00000000..bf1dc037 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/exception/TransientGoogleHealthException.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.exception + +/** + * Thrown when a retryable Google Health API error (429 or 5xx) exhausts its retry budget. + * Redis cursor past a chunk that never completed, the next scheduled iteration will retry. + */ +class TransientGoogleHealthException(message: String) : RuntimeException(message) diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/exception/UserNotAuthorizedException.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/exception/UserNotAuthorizedException.kt new file mode 100644 index 00000000..e998be6c --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/exception/UserNotAuthorizedException.kt @@ -0,0 +1,7 @@ +package org.radarbase.googlehealth.exception + +import org.radarbase.googlehealth.user.User + +class UserNotAuthorizedException(message: String) : Exception(message) { + constructor(user: User) : this("User ${user.id} is not authorized") +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/model/GoogleHealthPing.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/model/GoogleHealthPing.kt new file mode 100644 index 00000000..dcc2638c --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/model/GoogleHealthPing.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.model + +data class GoogleHealthPing( + val healthUserId: String, + val operation: String, + val dataType: String, + val intervals: List, +) diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/model/PingInterval.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/model/PingInterval.kt new file mode 100644 index 00000000..161c4723 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/model/PingInterval.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.model + +import java.time.Instant + +data class PingInterval( + val physicalStartTime: Instant, + val physicalEndTime: Instant, +) diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/GoogleHealthUser.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/GoogleHealthUser.kt new file mode 100644 index 00000000..0c2a19da --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/GoogleHealthUser.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.user + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import org.radarcns.kafka.ObservationKey +import java.time.Instant + +@JsonIgnoreProperties(ignoreUnknown = true) +data class GoogleHealthUser( + @param:JsonProperty("id") override val id: String, + @param:JsonProperty("createdAt") override val createdAt: Instant, + @param:JsonProperty("projectId") override val projectId: String, + @param:JsonProperty("userId") override val userId: String, + @param:JsonProperty("humanReadableUserId") override val humanReadableUserId: String?, + @param:JsonProperty("sourceId") override val sourceId: String, + @param:JsonProperty("externalId") override val externalId: String?, + @param:JsonProperty("isAuthorized") override val isAuthorized: Boolean, + @param:JsonProperty("startDate") override val startDate: Instant, + @param:JsonProperty("endDate") override val endDate: Instant?, + @param:JsonProperty("version") override val version: String? = null, + @param:JsonProperty("serviceUserId") override val serviceUserId: String, +) : User { + override val observationKey: ObservationKey = ObservationKey(projectId, userId, sourceId) + override val versionedId: String = "$id${version?.let { "#$it" } ?: ""}" +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/GoogleHealthUserRepository.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/GoogleHealthUserRepository.kt new file mode 100644 index 00000000..2ccb4fca --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/GoogleHealthUserRepository.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.user + +import java.io.IOException + +abstract class GoogleHealthUserRepository : UserRepository { + + @Throws(NoSuchElementException::class, IOException::class) + abstract override fun findByExternalId(externalId: String): User + + @Throws(IOException::class) + abstract fun getOAuth2AccessToken(user: User): String + + abstract fun deregisterUser(serviceUserId: String) +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/GoogleHealthUsers.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/GoogleHealthUsers.kt new file mode 100644 index 00000000..c7e42487 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/GoogleHealthUsers.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.user + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty + +@JsonIgnoreProperties(ignoreUnknown = true) +data class GoogleHealthUsers(@param:JsonProperty("users") val users: List) diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/RestOauth2AccessToken.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/RestOauth2AccessToken.kt new file mode 100644 index 00000000..6c67df85 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/RestOauth2AccessToken.kt @@ -0,0 +1,11 @@ +package org.radarbase.googlehealth.user + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import java.time.Instant + +@JsonIgnoreProperties(ignoreUnknown = true) +data class RestOauth2AccessToken( + @param:JsonProperty("accessToken") val accessToken: String, + @param:JsonProperty("expiresAt") val expiresAt: Instant? = null, +) diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/User.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/User.kt new file mode 100644 index 00000000..a26be109 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/User.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.user + +import org.radarcns.kafka.ObservationKey +import java.time.Instant + +interface User { + val id: String + val projectId: String + val userId: String + val sourceId: String + val externalId: String? + val startDate: Instant + val endDate: Instant? + val createdAt: Instant + val humanReadableUserId: String? + val serviceUserId: String + val version: String? + val isAuthorized: Boolean + val observationKey: ObservationKey + val versionedId: String +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/UserRepository.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/UserRepository.kt new file mode 100644 index 00000000..a0014570 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/UserRepository.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2018 The Hyve + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package org.radarbase.googlehealth.user + +import org.radarbase.googlehealth.exception.UserNotAuthorizedException +import java.io.IOException + +/** User repository for users. */ +interface UserRepository { + @Throws(IOException::class) + operator fun get(key: String): User? + + @Throws(IOException::class) + fun stream(): Sequence + + @Throws(IOException::class, UserNotAuthorizedException::class) + fun getAccessToken(user: User): String + + @Throws(IOException::class, UserNotAuthorizedException::class) + fun getRefreshToken(user: User): String + + @Throws(NoSuchElementException::class, IOException::class) + fun findByExternalId(externalId: String): User = stream() + .firstOrNull { it.serviceUserId == externalId } + ?: throw NoSuchElementException("User not found in the User repository") + + fun hasPendingUpdates(): Boolean + + @Throws(IOException::class) + fun applyPendingUpdates() +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/util/AvroBuilders.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/util/AvroBuilders.kt new file mode 100644 index 00000000..49884887 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/util/AvroBuilders.kt @@ -0,0 +1,50 @@ +package org.radarbase.googlehealth.util + +import org.radarcns.connector.fitbit.FitbitActivityHeartRate +import org.radarcns.connector.fitbit.FitbitActivityLogRecord +import org.radarcns.connector.fitbit.FitbitBreathingRate +import org.radarcns.connector.fitbit.FitbitIntradayCalories +import org.radarcns.connector.fitbit.FitbitIntradayHeartRate +import org.radarcns.connector.fitbit.FitbitIntradayHeartRateVariability +import org.radarcns.connector.fitbit.FitbitIntradaySpo2 +import org.radarcns.connector.fitbit.FitbitIntradaySteps +import org.radarcns.connector.fitbit.FitbitRestingHeartRate +import org.radarcns.connector.fitbit.FitbitSkinTemperature +import org.radarcns.connector.fitbit.FitbitSleepClassic +import org.radarcns.connector.fitbit.FitbitSleepStage + +inline fun intradaySteps(block: FitbitIntradaySteps.Builder.() -> Unit): FitbitIntradaySteps = + FitbitIntradaySteps.newBuilder().apply(block).build() + +inline fun intradayHeartRate(block: FitbitIntradayHeartRate.Builder.() -> Unit): FitbitIntradayHeartRate = + FitbitIntradayHeartRate.newBuilder().apply(block).build() + +inline fun intradayHeartRateVariability(block: FitbitIntradayHeartRateVariability.Builder.() -> Unit): FitbitIntradayHeartRateVariability = + FitbitIntradayHeartRateVariability.newBuilder().apply(block).build() + +inline fun intradaySpo2(block: FitbitIntradaySpo2.Builder.() -> Unit): FitbitIntradaySpo2 = + FitbitIntradaySpo2.newBuilder().apply(block).build() + +inline fun restingHeartRate(block: FitbitRestingHeartRate.Builder.() -> Unit): FitbitRestingHeartRate = + FitbitRestingHeartRate.newBuilder().apply(block).build() + +inline fun breathingRate(block: FitbitBreathingRate.Builder.() -> Unit): FitbitBreathingRate = + FitbitBreathingRate.newBuilder().apply(block).build() + +inline fun skinTemperature(block: FitbitSkinTemperature.Builder.() -> Unit): FitbitSkinTemperature = + FitbitSkinTemperature.newBuilder().apply(block).build() + +inline fun sleepClassic(block: FitbitSleepClassic.Builder.() -> Unit): FitbitSleepClassic = + FitbitSleepClassic.newBuilder().apply(block).build() + +inline fun sleepStage(block: FitbitSleepStage.Builder.() -> Unit): FitbitSleepStage = + FitbitSleepStage.newBuilder().apply(block).build() + +inline fun activityLogRecord(block: FitbitActivityLogRecord.Builder.() -> Unit): FitbitActivityLogRecord = + FitbitActivityLogRecord.newBuilder().apply(block).build() + +inline fun activityHeartRate(block: FitbitActivityHeartRate.Builder.() -> Unit): FitbitActivityHeartRate = + FitbitActivityHeartRate.newBuilder().apply(block).build() + +inline fun intradayCalories(block: FitbitIntradayCalories.Builder.() -> Unit): FitbitIntradayCalories = + FitbitIntradayCalories.newBuilder().apply(block).build() diff --git a/settings.gradle.kts b/settings.gradle.kts index 64f23944..91ea6aed 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -3,6 +3,7 @@ include(":kafka-connect-fitbit-source") include(":kafka-connect-rest-source") include(":kafka-connect-oura-source") include(":oura-library") +include(":google-health-library") pluginManagement { repositories {