diff --git a/.github/workflows/publish-snapshots.yml b/.github/workflows/publish-snapshots.yml new file mode 100644 index 00000000..d8eeb374 --- /dev/null +++ b/.github/workflows/publish-snapshots.yml @@ -0,0 +1,50 @@ +name: Publish snapshots + +on: + push: + branches: [ dev ] + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + # A release version on dev (for example while a release is prepared) skips publishing + # instead of failing the job. + - name: Check SNAPSHOT version + id: version + run: | + if ./gradlew properties --no-daemon -q | grep -q 'version: .*-SNAPSHOT'; then + echo "snapshot=true" >> "$GITHUB_OUTPUT" + else + echo "Not a SNAPSHOT version, skipping snapshot publishing." + echo "snapshot=false" >> "$GITHUB_OUTPUT" + fi + + - name: Import GPG signing key + if: steps.version.outputs.snapshot == 'true' + run: | + echo -e "${{ secrets.OSSRH_GPG_SECRET_KEY }}" | gpg --batch --import + gpg --list-secret-keys --keyid-format LONG + + - name: Publish to Sonatype snapshots + if: steps.version.outputs.snapshot == 'true' + env: + OSSRH_USER: ${{ secrets.OSSRH_USER_TOKEN_ID }} + OSSRH_PASSWORD: ${{ secrets.OSSRH_USER_TOKEN_SECRET }} + run: >- + ./gradlew + -Psigning.gnupg.keyName=${{ secrets.OSSRH_GPG_SECRET_KEY_NAME }} + -Psigning.gnupg.executable=gpg + -Psigning.gnupg.passphrase=${{ secrets.OSSRH_GPG_SECRET_KEY_PASSWORD }} + publish diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c70528ad..d26413a2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,6 +45,45 @@ jobs: files: '*/build/libs/*' repo-token: ${{ secrets.GITHUB_TOKEN }} + publish-jars: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Assert release (non-SNAPSHOT) version + run: | + if ./gradlew properties --no-daemon -q | grep -q 'version: .*-SNAPSHOT'; then + echo "Refusing to release: project version is still -SNAPSHOT" >&2 + exit 1 + fi + + - name: Import GPG signing key + run: | + echo -e "${{ secrets.OSSRH_GPG_SECRET_KEY }}" | gpg --batch --import + gpg --list-secret-keys --keyid-format LONG + + - name: Publish and close staging repository + env: + OSSRH_USER: ${{ secrets.OSSRH_USER_TOKEN_ID }} + OSSRH_PASSWORD: ${{ secrets.OSSRH_USER_TOKEN_SECRET }} + run: >- + ./gradlew + -Psigning.gnupg.keyName=${{ secrets.OSSRH_GPG_SECRET_KEY_NAME }} + -Psigning.gnupg.executable=gpg + -Psigning.gnupg.passphrase=${{ secrets.OSSRH_GPG_SECRET_KEY_PASSWORD }} + publish closeAndReleaseSonatypeStagingRepository + prepare-matrix: name: Prepare Matrix Output runs-on: ubuntu-latest diff --git a/build.gradle.kts b/build.gradle.kts index 7cd939d1..33f7b7d0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,9 +1,11 @@ import org.radarbase.gradle.plugin.radarKotlin +import org.radarbase.gradle.plugin.radarPublishing plugins { alias(libs.plugins.radar.root.project) alias(libs.plugins.radar.dependency.management) alias(libs.plugins.radar.kotlin) apply false + alias(libs.plugins.radar.publishing) apply false } repositories { @@ -17,9 +19,41 @@ radarRootProject { gradleVersion.set(libs.versions.gradle) } +val githubRepoName = "RADAR-base/RADAR-REST-Connector" +val githubProjectUrl = "https://github.com/$githubRepoName" + +val publishedSubprojects = setOf("google-health-library") + subprojects { apply(plugin = "org.radarbase.radar-kotlin") + if (name in publishedSubprojects) { + apply(plugin = "org.radarbase.radar-publishing") + radarPublishing { + githubUrl.set(githubProjectUrl) + developers { + developer { + id.set("yatharthranjan") + name.set("Yatharth Ranjan") + email.set("yatharth.ranjan@kcl.ac.uk") + organization.set("King's College London") + } + developer { + id.set("mpgxvii") + name.set("Pauline Conde") + email.set("mpgxvii@gmail.com") + organization.set("King's College London") + } + developer { + id.set("this-Aditya") + name.set("Aditya Mishra") + email.set("aditya.mishra@kcl.ac.uk") + organization.set("King's College London") + } + } + } + } + // --- Vulnerability fixes start --- dependencies { plugins.withType { @@ -30,6 +64,12 @@ subprojects { add("implementation", rootProject.libs.commons.lang3) { because("Force safe version of commons-lang3 across all modules") } + add("implementation", rootProject.libs.httpcore5) { + because("CVE-2026-54399: firebase-admin pulls in a vulnerable httpcore5") + } + add("implementation", rootProject.libs.httpcore5.h2) { + because("CVE-2026-54428: firebase-admin pulls in a vulnerable httpcore5-h2") + } } } } diff --git a/google-health-library/build.gradle b/google-health-library/build.gradle new file mode 100644 index 00000000..7f7d2861 --- /dev/null +++ b/google-health-library/build.gradle @@ -0,0 +1,17 @@ +description = "Google Health library with utility components to be used in RADAR-Pushendpoint" + +dependencies { + implementation libs.kotlin.stdlib + + api libs.radar.schemas.commons + + implementation libs.jackson.annotations + + api libs.jackson.databind + + api libs.avro + + implementation libs.jackson.datatype.jsr310 + + testImplementation libs.kotlin.test +} 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/GoogleHealthActivityLevelAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthActivityLevelAvroConverter.kt new file mode 100644 index 00000000..7335adfe --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthActivityLevelAvroConverter.kt @@ -0,0 +1,60 @@ +/* + * 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.googleHealthActivityLevel +import org.radarcns.push.googlehealth.GoogleHealthActivityLevelType + +/** + * Converts `activity-level` data points: the activity level the user sustained over an interval, + * one minute long in practice, reported for every interval of the day. The sedentary intervals are + * also reported grouped into longer periods, see [GoogleHealthSedentaryPeriodAvroConverter]. + */ +class GoogleHealthActivityLevelAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { + override fun convertDataPoint( + point: JsonNode, + user: User, + ): List> { + val data = point["activityLevel"] ?: return emptyList() + val (start, end) = parseInterval(data) ?: return emptyList() + val record = googleHealthActivityLevel { + time = epochSeconds(start) + timeReceived = nowEpochSeconds() + timeInterval = (end.epochSecond - start.epochSecond).toInt().coerceAtLeast(0) + level = mapLevel(data["activityLevelType"]?.asText()) + } + return listOf(user.observationKey to record) + } + + /** + * Google's `ACTIVITY_LEVEL_TYPE_UNSPECIFIED` is kept as its own symbol, it means Google itself + * did not classify the interval. Any other symbol, including ones Google adds later, is + * `UNKNOWN`. + */ + private fun mapLevel(text: String?): GoogleHealthActivityLevelType = when (text) { + "SEDENTARY" -> GoogleHealthActivityLevelType.SEDENTARY + "LIGHTLY_ACTIVE" -> GoogleHealthActivityLevelType.LIGHTLY_ACTIVE + "MODERATELY_ACTIVE" -> GoogleHealthActivityLevelType.MODERATELY_ACTIVE + "VERY_ACTIVE" -> GoogleHealthActivityLevelType.VERY_ACTIVE + "ACTIVITY_LEVEL_TYPE_UNSPECIFIED" -> + GoogleHealthActivityLevelType.ACTIVITY_LEVEL_TYPE_UNSPECIFIED + else -> GoogleHealthActivityLevelType.UNKNOWN + } +} 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..d66324d1 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthAvroConverter.kt @@ -0,0 +1,123 @@ +/* + * 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.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.IOException +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 { + + protected val logger: Logger = LoggerFactory.getLogger(javaClass) + + @Throws(IOException::class) + 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 { point -> + try { + convertDataPoint(point, user) + } catch (ex: Exception) { + logger.warn("Skipping data point for topic={} user={}", topic, user.versionedId, ex) + emptyList() + } + } + } + + 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)) + } + + /** Parses a Google `Duration` string (e.g. "36s", "780s") to whole seconds. */ + fun parseDurationSeconds(durationText: String?): Int { + if (durationText.isNullOrEmpty()) return 0 + val trimmed = durationText.trim().removeSuffix("s") + val seconds = trimmed.toDoubleOrNull() ?: return 0 + return seconds.toInt() + } + + /** A UTC offset is encoded as a [Duration]; returns its value in seconds. */ + fun parseUtcOffsetSeconds(offsetText: String?): Int = parseDurationSeconds(offsetText) + + fun epochSeconds(instant: Instant): Double = instant.toEpochMilli() / 1000.0 + + /** + * Midnight at the start of a Google Health local date (year, month, day), written as if + * it were UTC. It is not the true instant, it only keeps the local day for sinks that + * need a numeric time. + */ + fun localDateAsUtcEpochSeconds(dateNode: JsonNode): Double = epochSeconds( + LocalDate.of( + dateNode["year"].asInt(), + dateNode["month"].asInt(), + dateNode["day"].asInt(), + ).atStartOfDay(ZoneOffset.UTC).toInstant(), + ) + + fun nowEpochSeconds(): Double = Instant.now().toEpochMilli() / 1000.0 + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthDailyRestingHeartRateAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthDailyRestingHeartRateAvroConverter.kt new file mode 100644 index 00000000..d0b30c4f --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthDailyRestingHeartRateAvroConverter.kt @@ -0,0 +1,47 @@ +/* + * 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.googleHealthDailyRestingHeartRate + +class GoogleHealthDailyRestingHeartRateAvroConverter(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"]?.asText()?.toIntOrNull() ?: return emptyList() + val isoDate = String.format( + "%04d-%02d-%02d", + dateNode["year"].asInt(), + dateNode["month"].asInt(), + dateNode["day"].asInt(), + ) + val record = googleHealthDailyRestingHeartRate { + time = localDateAsUtcEpochSeconds(dateNode) + 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/GoogleHealthDailySleepTemperatureDerivationsAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthDailySleepTemperatureDerivationsAvroConverter.kt new file mode 100644 index 00000000..e96434ad --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthDailySleepTemperatureDerivationsAvroConverter.kt @@ -0,0 +1,61 @@ +/* + * 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.googleHealthDailySleepTemperatureDerivations + +class GoogleHealthDailySleepTemperatureDerivationsAvroConverter(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() ?: run { + logger.info( + "Skipping sleep temperature for user={}: no baselineTemperatureCelsius", + user.versionedId, + ) + return emptyList() + } + // `date` is the local date of the user the derivation is for. It is kept as a + // yyyy-MM-dd string, like DailyRestingHeartRate. `time` is midnight of that date + // written as UTC, so the local day stays the same for sinks that need a numeric time. + val dateNode = data["date"] ?: return emptyList() + val isoDate = String.format( + "%04d-%02d-%02d", + dateNode["year"].asInt(), + dateNode["month"].asInt(), + dateNode["day"].asInt(), + ) + val record = googleHealthDailySleepTemperatureDerivations { + time = localDateAsUtcEpochSeconds(dateNode) + date = isoDate + timeReceived = nowEpochSeconds() + relativeTemperature = nightly - baseline + } + return listOf(user.observationKey to record) + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthElectrocardiogramAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthElectrocardiogramAvroConverter.kt new file mode 100644 index 00000000..4f31636e --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthElectrocardiogramAvroConverter.kt @@ -0,0 +1,106 @@ +/* + * 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.googleHealthElectrocardiogram +import org.radarcns.push.googlehealth.GoogleHealthElectrocardiogramResultClassification +import java.io.IOException +import java.time.Instant +import java.util.stream.Collectors +import java.util.stream.IntStream + +/** + * Emits one record per ECG waveform sample. Sample i is timed at the reading start plus + * i / samplingFrequencyHertz seconds and carries the raw waveform value as reported by the + * device (an ADC count; divide by millivoltsScalingFactor for millivolts). The reading-level + * metadata (heart rate, sampling parameters, device info) is repeated on every sample's record, + * linked by the shared reading id. + */ +class GoogleHealthElectrocardiogramAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { + override fun convertDataPoint( + point: JsonNode, + user: User, + ): List> { + val data = point["electrocardiogram"] ?: return emptyList() + val start = data["interval"]?.get("startTime")?.asText() + ?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return emptyList() + val id = (point["name"] ?: point["dataPointName"])?.asText()?.substringAfterLast('/') + ?: throw IOException( + "Electrocardiogram data point has no name or dataPointName to derive an id from " + + "for user=${user.versionedId}", + ) + val samples = data["waveformSamples"]?.takeIf { it.isArray } ?: return emptyList() + val frequency = data["samplingFrequencyHertz"]?.asText()?.toIntOrNull() + ?.takeIf { it > 0 } + ?: run { + logger.warn( + "Skipping electrocardiogram {} for user={}: no samplingFrequencyHertz", + id, + user.versionedId, + ) + return emptyList() + } + + val device = data["medicalDeviceInfo"] + val beatsPerMinuteAvg = data["beatsPerMinuteAvg"]?.takeIf { !it.isNull }?.asText() + ?.toIntOrNull() + val scalingFactor = data["millivoltsScalingFactor"]?.asText()?.toIntOrNull() + val leadNumber = data["leadNumber"]?.asText()?.toIntOrNull() + val deviceModel = device?.get("deviceModel")?.asText() + val firmwareVersion = device?.get("firmwareVersion")?.asText() + val featureVersion = device?.get("featureVersion")?.asText() + val resultClassification = data["resultClassification"]?.takeIf { !it.isNull }?.asText() + ?.let { resultClassificationOf(it) } + + val startSec = epochSeconds(start) + val received = nowEpochSeconds() + val sampleCount = samples.size() + if (sampleCount == 0) return emptyList() + + // A single reading carries thousands of samples (~7500 at 30 s / 250 Hz), each needing + // its own record. Every iteration only reads the parsed JSON tree and builds an + // independent Avro record, so the work spreads safely over the common pool; the stream + // stays ordered, keeping the samples in acquisition order. A sample that is not a number + // is skipped, the others keep their time because it comes from the sample's index. + return IntStream.range(0, sampleCount).parallel().mapToObj { i -> + val value = samples[i].asText().toIntOrNull() ?: return@mapToObj null + val record = googleHealthElectrocardiogram { + time = startSec + i.toDouble() / frequency + timeReceived = received + this.id = id + sample = value + this.beatsPerMinuteAvg = beatsPerMinuteAvg + this.samplingFrequencyHertz = frequency + this.millivoltsScalingFactor = scalingFactor + this.leadNumber = leadNumber + this.deviceModel = deviceModel + this.firmwareVersion = firmwareVersion + this.featureVersion = featureVersion + this.resultClassification = resultClassification + } + user.observationKey to record + }.collect(Collectors.toList()).filterNotNull() + } + + /** Any value Google adds later that is not in the schema yet becomes `UNKNOWN`. */ + private fun resultClassificationOf(text: String) = runCatching { + GoogleHealthElectrocardiogramResultClassification.valueOf(text) + }.getOrDefault(GoogleHealthElectrocardiogramResultClassification.UNKNOWN) +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthExerciseAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthExerciseAvroConverter.kt new file mode 100644 index 00000000..eddf7353 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthExerciseAvroConverter.kt @@ -0,0 +1,127 @@ +/* + * 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.googleHealthExercise +import org.radarbase.googlehealth.util.googleHealthExerciseHeartRate +import org.radarbase.googlehealth.util.googleHealthSource +import java.io.IOException +import java.time.Instant + +class GoogleHealthExerciseAvroConverter(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 activeDurationSec = data["activeDuration"]?.asText() + ?.let { parseDurationSeconds(it).toFloat() } ?: durationSec + val lastModified = data["updateTime"]?.asText() + ?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: end + val metrics = data["metricsSummary"] + + val distanceKm = metrics?.get("distanceMillimeters")?.takeIf { !it.isNull } + ?.asText()?.toDoubleOrNull()?.let { it.toFloat() / 1_000_000f } + + val caloriesKcal = metrics?.get( + "caloriesKcal", + )?.takeIf { !it.isNull }?.asText()?.toDoubleOrNull() + val energyKj = caloriesKcal?.let { (it * KCAL_TO_KJ).toFloat() } + val stepCount = metrics?.get("steps")?.takeIf { !it.isNull }?.asText()?.toIntOrNull() + + // Google sends the mean pace (s/m) instead of averageSpeedMillimetersPerSecond. + val speedKmh = metrics?.get("averageSpeedMillimetersPerSecond")?.takeIf { !it.isNull } + ?.asText()?.toDoubleOrNull()?.let { it * MM_PER_S_TO_KM_PER_H } + ?: metrics?.get("averagePaceSecondsPerMeter")?.takeIf { it.isNumber } + ?.doubleValue()?.takeIf { it > 0 }?.let { M_PER_S_TO_KM_PER_H / it } + + val avgHr = metrics?.get("averageHeartRateBeatsPerMinute")?.takeIf { + !it.isNull + }?.asText()?.toIntOrNull() + val zones = metrics?.get("heartRateZoneDurations")?.takeIf { !it.isNull } + val avgHeartRate = if (avgHr != null || zones != null) { + googleHealthExerciseHeartRate { + mean = avgHr + durationLight = zones?.get("lightTime")?.asText()?.let { parseDurationSeconds(it) } + durationModerate = zones?.get("moderateTime")?.asText() + ?.let { parseDurationSeconds(it) } + durationVigorous = zones?.get("vigorousTime")?.asText() + ?.let { parseDurationSeconds(it) } + durationPeak = zones?.get("peakTime")?.asText()?.let { parseDurationSeconds(it) } + } + } else { + null + } + val exerciseType = data["exerciseType"]?.asText() + val dataSource = point["dataSource"]?.takeIf { !it.isNull } + val device = dataSource?.get("device") + val exerciseSource = dataSource?.let { + googleHealthSource { + name = device?.get("displayName")?.asText() + formFactor = device?.get("formFactor")?.asText() + manufacturer = device?.get("manufacturer")?.asText() + platform = it["platform"]?.asText() + } + } + + val idSegment = (point["name"] ?: point["dataPointName"])?.asText() + ?.substringAfterLast('/') + ?: throw IOException( + "Exercise data point has no name or dataPointName to derive a log id from " + + "for user=${user.versionedId}", + ) + val activityId = idSegment.toLongOrNull() + ?: throw IOException( + "Exercise data point log id '$idSegment' is not numeric " + + "for user=${user.versionedId}", + ) + + val record = googleHealthExercise { + time = epochSeconds(start) + timeReceived = nowEpochSeconds() + timeZoneOffset = offsetSeconds + timeLastModified = epochSeconds(lastModified) + duration = durationSec + durationActive = activeDurationSec + id = activityId + name = data["displayName"]?.asText() ?: exerciseType + logType = dataSource?.get("recordingMethod")?.asText() + type = exerciseType + source = exerciseSource + energy = energyKj + heartRate = avgHeartRate + steps = stepCount + distance = distanceKm + speed = speedKmh + } + return listOf(user.observationKey to record) + } + + companion object { + private const val KCAL_TO_KJ = 4.1868 + private const val MM_PER_S_TO_KM_PER_H = 0.0036 + private const val M_PER_S_TO_KM_PER_H = 3.6 + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthFloorsAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthFloorsAvroConverter.kt new file mode 100644 index 00000000..3b5afb01 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthFloorsAvroConverter.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.googleHealthFloors + +/** + * Converts `floors` data points: elevation gained over an interval. + * Google documents `count` as an int64, serialised as a JSON string. Reading it as text parses + * either form, and a non-numeric value drops the point instead of becoming 0. + * The type supports true zeros, so a `count` of 0 is a real observation and is kept. + */ +class GoogleHealthFloorsAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { + override fun convertDataPoint( + point: JsonNode, + user: User, + ): List> { + val data = point["floors"] ?: return emptyList() + val (start, end) = parseInterval(data) ?: return emptyList() + val count = data["count"]?.asText()?.toIntOrNull() ?: return emptyList() + val record = googleHealthFloors { + time = epochSeconds(start) + endTime = epochSeconds(end) + timeReceived = nowEpochSeconds() + floors = count + } + return listOf(user.observationKey to record) + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthHeartRateAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthHeartRateAvroConverter.kt new file mode 100644 index 00000000..94a47ecc --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthHeartRateAvroConverter.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.googleHealthHeartRate + +class GoogleHealthHeartRateAvroConverter(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"]?.asText()?.toIntOrNull() ?: return emptyList() + val record = googleHealthHeartRate { + 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/GoogleHealthHeartRateVariabilityAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthHeartRateVariabilityAvroConverter.kt new file mode 100644 index 00000000..3cb2ac79 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthHeartRateVariabilityAvroConverter.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.googleHealthHeartRateVariability + +class GoogleHealthHeartRateVariabilityAvroConverter(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"]?.takeIf { + it.isNumber + }?.floatValue() + ?: return emptyList() + val metadata = data["metadata"] + val highFrequency = metadata?.get("highFrequencyPower")?.takeIf { it.isNumber } + ?.floatValue() + val lowFrequency = metadata?.get("lowFrequencyPower")?.takeIf { it.isNumber } + ?.floatValue() + val record = googleHealthHeartRateVariability { + this.time = epochSeconds(time) + timeReceived = nowEpochSeconds() + this.rmssd = rmssd + this.highFrequency = highFrequency + this.lowFrequency = lowFrequency + } + return listOf(user.observationKey to record) + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthIrregularRhythmNotificationAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthIrregularRhythmNotificationAvroConverter.kt new file mode 100644 index 00000000..26c9e895 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthIrregularRhythmNotificationAvroConverter.kt @@ -0,0 +1,87 @@ +/* + * 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.googleHealthIrregularRhythmNotification +import java.io.IOException +import java.time.Instant + +/** + * Emits one record per heart beat of an Irregular Rhythm Notification, flattening the API's + * session -> alertWindow -> heartBeat hierarchy. Each record carries the heart beat's own time + * plus the context of its parent window (start/end, positive) and session (start), with the + * device metadata repeated, linked by the shared notification id. + */ +class GoogleHealthIrregularRhythmNotificationAvroConverter( + topic: String, +) : GoogleHealthAvroConverter(topic) { + override fun convertDataPoint( + point: JsonNode, + user: User, + ): List> { + val data = point["irregularRhythmNotification"] ?: return emptyList() + val id = (point["name"] ?: point["dataPointName"])?.asText()?.substringAfterLast('/') + ?: throw IOException( + "Irregular rhythm notification data point has no name or dataPointName to derive " + + "an id from for user=${user.versionedId}", + ) + val windows = data["alertWindows"]?.takeIf { it.isArray } ?: return emptyList() + + val device = data["medicalDeviceInfo"] + val sessionStart = data["interval"]?.get("startTime")?.asText() + ?.let { runCatching { Instant.parse(it) }.getOrNull() } + val firmwareVersion = device?.get("firmwareVersion")?.asText() + val featureVersion = device?.get("featureVersion")?.asText() + val deviceModel = device?.get("deviceModel")?.asText() + val received = nowEpochSeconds() + + return windows.flatMap { window -> + val windowStart = window["startTime"]?.asText() + ?.let { runCatching { Instant.parse(it) }.getOrNull() } + val windowEnd = window["endTime"]?.asText() + ?.let { runCatching { Instant.parse(it) }.getOrNull() } + if (windowStart == null || windowEnd == null) return@flatMap emptyList() + val positive = window["positive"]?.takeIf { !it.isNull }?.asBoolean() + val heartBeats = window["heartBeats"]?.takeIf { it.isArray } + ?: return@flatMap emptyList() + + heartBeats.mapNotNull { beat -> + val beatTime = (beat["physicalTime"] ?: beat["time"])?.asText() + ?.let { runCatching { Instant.parse(it) }.getOrNull() } + ?: return@mapNotNull null + val record = googleHealthIrregularRhythmNotification { + time = epochSeconds(beatTime) + timeReceived = received + this.id = id + sessionStartTime = sessionStart?.let { epochSeconds(it) } + windowStartTime = epochSeconds(windowStart) + windowEndTime = epochSeconds(windowEnd) + this.positive = positive + beatsPerMinute = beat["beatsPerMinute"]?.takeIf { !it.isNull }?.asText() + ?.toIntOrNull() + this.firmwareVersion = firmwareVersion + this.featureVersion = featureVersion + this.deviceModel = deviceModel + } + user.observationKey to record + } + } + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthOxygenSaturationAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthOxygenSaturationAvroConverter.kt new file mode 100644 index 00000000..b653bf30 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthOxygenSaturationAvroConverter.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.googleHealthOxygenSaturation + +class GoogleHealthOxygenSaturationAvroConverter(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"]?.takeIf { it.isNumber }?.floatValue() ?: return emptyList() + val record = googleHealthOxygenSaturation { + this.time = epochSeconds(time) + timeReceived = nowEpochSeconds() + percentage = pct + } + return listOf(user.observationKey to record) + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthRespiratoryRateSleepSummaryAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthRespiratoryRateSleepSummaryAvroConverter.kt new file mode 100644 index 00000000..8fee8307 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthRespiratoryRateSleepSummaryAvroConverter.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.googleHealthRespiratoryRateSleepSummary + +class GoogleHealthRespiratoryRateSleepSummaryAvroConverter(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") + val full = data["fullSleepStats"]?.get("breathsPerMinute") + val light = data["lightSleepStats"]?.get("breathsPerMinute") + val rem = data["remSleepStats"]?.get("breathsPerMinute") + + val stats = listOf(deep, full, light, rem) + if (stats.all { it == null } || stats.any { it != null && !it.isNumber }) return emptyList() + + val record = googleHealthRespiratoryRateSleepSummary { + this.time = epochSeconds(time) + timeReceived = nowEpochSeconds() + lightSleep = light?.floatValue() + deepSleep = deep?.floatValue() + remSleep = rem?.floatValue() + fullSleep = full?.floatValue() + } + return listOf(user.observationKey to record) + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthSedentaryPeriodAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthSedentaryPeriodAvroConverter.kt new file mode 100644 index 00000000..d21b12a9 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthSedentaryPeriodAvroConverter.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.googleHealthSedentaryPeriod + +/** + * Converts `sedentary-period` data points: stretches during which the user was not moving while + * wearing the device. Google groups each unbroken run of sedentary time into one point, so the + * points vary in length from minutes to hours and only cover the sedentary parts of the day. + * `activity-level` carries the same signal per minute, see + * [GoogleHealthActivityLevelAvroConverter]. Google documents `interval` as this type's only field, + * so the record carries the start and end of that interval alone. + */ +class GoogleHealthSedentaryPeriodAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { + override fun convertDataPoint( + point: JsonNode, + user: User, + ): List> { + val data = point["sedentaryPeriod"] ?: return emptyList() + val (start, end) = parseInterval(data) ?: return emptyList() + val record = googleHealthSedentaryPeriod { + time = epochSeconds(start) + endTime = epochSeconds(end) + timeReceived = nowEpochSeconds() + } + return listOf(user.observationKey to record) + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthSleepClassicAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthSleepClassicAvroConverter.kt new file mode 100644 index 00000000..9a86f7ab --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthSleepClassicAvroConverter.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.googleHealthSleepClassic +import org.radarcns.push.googlehealth.GoogleHealthSleepClassicLevel +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +class GoogleHealthSleepClassicAvroConverter(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() + if (SleepSession.familyOf(data) != SleepSessionFamily.CLASSIC) return emptyList() + val timeReceived = nowEpochSeconds() + 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 + // Render in the stage's own UTC offset so dateTime is the device's local wall clock + // (like Fitbit), not UTC. Google derives its civil fields the same way (physical + offset). + val startOffsetSeconds = parseUtcOffsetSeconds(stage["startUtcOffset"]?.asText()) + val startZone = ZoneOffset.ofTotalSeconds(startOffsetSeconds) + val record = googleHealthSleepClassic { + time = epochSeconds(start) + utcOffset = startOffsetSeconds + dateTime = LOCAL_FMT.format(LocalDateTime.ofInstant(start, startZone)) + this.timeReceived = timeReceived + duration = (end.epochSecond - start.epochSecond).toInt().coerceAtLeast(0) + level = mapLevel(stageType) + } + user.observationKey to record + } + } + + private fun mapLevel(text: String?): GoogleHealthSleepClassicLevel = when (text) { + "ASLEEP" -> GoogleHealthSleepClassicLevel.ASLEEP + "RESTLESS" -> GoogleHealthSleepClassicLevel.RESTLESS + "AWAKE" -> GoogleHealthSleepClassicLevel.AWAKE + else -> GoogleHealthSleepClassicLevel.UNKNOWN + } + + companion object { + private val LOCAL_FMT: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME + private val CLASSIC_FAMILY = setOf("ASLEEP", "RESTLESS", "AWAKE") + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthSleepStageAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthSleepStageAvroConverter.kt new file mode 100644 index 00000000..d619cff3 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthSleepStageAvroConverter.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.googleHealthSleepStage +import org.radarcns.push.googlehealth.GoogleHealthSleepStageLevel +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +class GoogleHealthSleepStageAvroConverter(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() + if (SleepSession.familyOf(data) != SleepSessionFamily.STAGES) return emptyList() + val timeReceived = nowEpochSeconds() + 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 startOffsetSeconds = parseUtcOffsetSeconds(stage["startUtcOffset"]?.asText()) + val startZone = ZoneOffset.ofTotalSeconds(startOffsetSeconds) + val record = googleHealthSleepStage { + time = epochSeconds(start) + utcOffset = startOffsetSeconds + dateTime = LOCAL_FMT.format(LocalDateTime.ofInstant(start, startZone)) + this.timeReceived = timeReceived + duration = (end.epochSecond - start.epochSecond).toInt().coerceAtLeast(0) + level = mapLevel(stageType) + } + user.observationKey to record + } + } + + private fun mapLevel(text: String?): GoogleHealthSleepStageLevel = when (text) { + "DEEP" -> GoogleHealthSleepStageLevel.DEEP + "LIGHT" -> GoogleHealthSleepStageLevel.LIGHT + "REM" -> GoogleHealthSleepStageLevel.REM + "AWAKE" -> GoogleHealthSleepStageLevel.AWAKE + else -> GoogleHealthSleepStageLevel.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/GoogleHealthStepsAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthStepsAvroConverter.kt new file mode 100644 index 00000000..a99c9e0a --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthStepsAvroConverter.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.googleHealthSteps + +class GoogleHealthStepsAvroConverter(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"]?.asText()?.toIntOrNull() ?: return emptyList() + val record = googleHealthSteps { + 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/GoogleHealthTotalCaloriesAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthTotalCaloriesAvroConverter.kt new file mode 100644 index 00000000..002005c7 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthTotalCaloriesAvroConverter.kt @@ -0,0 +1,42 @@ +/* + * 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.googleHealthTotalCalories +import java.time.Instant + +class GoogleHealthTotalCaloriesAvroConverter(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")?.asText()?.toDoubleOrNull() + ?: return emptyList() + val record = googleHealthTotalCalories { + time = epochSeconds(start) + timeReceived = nowEpochSeconds() + timeInterval = (end.epochSecond - start.epochSecond).toInt().coerceAtLeast(0) + calories = kilocalories + } + return listOf(user.observationKey to record) + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/SleepSessionFamily.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/SleepSessionFamily.kt new file mode 100644 index 00000000..af9065fa --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/SleepSessionFamily.kt @@ -0,0 +1,51 @@ +/* + * 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 + +internal enum class SleepSessionFamily { STAGES, CLASSIC, UNKNOWN } + +/** + * Resolves which sleep family a session belongs to so STAGES and CLASSIC records route to + * separate topics. + * + * This prefer the session-level `Sleep.type` (CLASSIC | STAGES) when present, and fall + * back to inferring from the per-stage `type` values when it is absent. + * + * AWAKE is deliberately excluded from the fallback signal: it appears in both families, so it + * cannot discriminate. Once the family is known, AWAKE routes with the rest of its session. + */ +internal object SleepSession { + private val STAGES_ONLY = setOf("DEEP", "LIGHT", "REM") + private val CLASSIC_ONLY = setOf("ASLEEP", "RESTLESS") + + fun familyOf(sleep: JsonNode): SleepSessionFamily { + when (sleep["type"]?.asText()) { + "STAGES" -> return SleepSessionFamily.STAGES + "CLASSIC" -> return SleepSessionFamily.CLASSIC + } + val stages = sleep["stages"]?.takeIf { it.isArray } ?: return SleepSessionFamily.UNKNOWN + val hasStages = stages.any { it["type"]?.asText() in STAGES_ONLY } + val hasClassic = stages.any { it["type"]?.asText() in CLASSIC_ONLY } + return when { + hasStages -> SleepSessionFamily.STAGES + hasClassic -> SleepSessionFamily.CLASSIC + else -> SleepSessionFamily.UNKNOWN + } + } +} 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..8ae1fdd6 --- /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) still fails after all retries. + * Callers should treat the request as not done and try it again later. + */ +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..0062290e --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/model/PingInterval.kt @@ -0,0 +1,34 @@ +/* + * 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 +import java.time.LocalDateTime + +/** + * One changed time interval from a Google Health notification. + * + * The physical times are UTC instants. Google leaves them out for some intervals, such as + * full-day step totals. The civil times are the user's local clock times without an offset, + * taken from `civilIso8601TimeInterval`, which Google always sends. + */ +data class PingInterval( + val physicalStartTime: Instant?, + val physicalEndTime: Instant?, + val civilStartTime: LocalDateTime? = null, + val civilEndTime: LocalDateTime? = null, +) 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..d4c9337b --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/GoogleHealthUsers.kt @@ -0,0 +1,77 @@ +/* + * 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.slf4j.LoggerFactory +import java.time.Instant + +/** + * The user list returned by the RADAR Rest Source Authorizer. Each entry is read with the + * authorizer's own nullability, and only complete users end up in [users]. One incomplete + * entry is logged and skipped instead of failing the whole list. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +class GoogleHealthUsers(@JsonProperty("users") entries: List) { + val users: List = entries.mapNotNull { it.toUserOrNull() } +} + +/** A user as the authorizer's `RestSourceUserDTO` declares it, where most fields are nullable. */ +@JsonIgnoreProperties(ignoreUnknown = true) +data class RestSourceUserDTO( + @param:JsonProperty("id") val id: String?, + @param:JsonProperty("createdAt") val createdAt: Instant?, + @param:JsonProperty("projectId") val projectId: String?, + @param:JsonProperty("userId") val userId: String?, + @param:JsonProperty("humanReadableUserId") val humanReadableUserId: String?, + @param:JsonProperty("sourceId") val sourceId: String?, + @param:JsonProperty("externalId") val externalId: String?, + @param:JsonProperty("isAuthorized") val isAuthorized: Boolean = false, + @param:JsonProperty("startDate") val startDate: Instant?, + @param:JsonProperty("endDate") val endDate: Instant?, + @param:JsonProperty("version") val version: String? = null, + @param:JsonProperty("serviceUserId") val serviceUserId: String?, +) { + /** Returns this user as a [GoogleHealthUser], or null if a required field is missing. */ + fun toUserOrNull(): GoogleHealthUser? { + if (id == null || createdAt == null || projectId == null || userId == null || + sourceId == null || startDate == null || serviceUserId == null + ) { + logger.warn("Skipping incomplete Google Health user id={} from the authorizer", id) + return null + } + return GoogleHealthUser( + id = id, + createdAt = createdAt, + projectId = projectId, + userId = userId, + humanReadableUserId = humanReadableUserId, + sourceId = sourceId, + externalId = externalId, + isAuthorized = isAuthorized, + startDate = startDate, + endDate = endDate, + version = version, + serviceUserId = serviceUserId, + ) + } + + companion object { + private val logger = LoggerFactory.getLogger(RestSourceUserDTO::class.java) + } +} 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..5a30a46d --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/util/AvroBuilders.kt @@ -0,0 +1,97 @@ +package org.radarbase.googlehealth.util + +import org.radarcns.push.googlehealth.GoogleHealthActivityLevel +import org.radarcns.push.googlehealth.GoogleHealthDailyRestingHeartRate +import org.radarcns.push.googlehealth.GoogleHealthDailySleepTemperatureDerivations +import org.radarcns.push.googlehealth.GoogleHealthElectrocardiogram +import org.radarcns.push.googlehealth.GoogleHealthExercise +import org.radarcns.push.googlehealth.GoogleHealthExerciseHeartRate +import org.radarcns.push.googlehealth.GoogleHealthFloors +import org.radarcns.push.googlehealth.GoogleHealthHeartRate +import org.radarcns.push.googlehealth.GoogleHealthHeartRateVariability +import org.radarcns.push.googlehealth.GoogleHealthIrregularRhythmNotification +import org.radarcns.push.googlehealth.GoogleHealthOxygenSaturation +import org.radarcns.push.googlehealth.GoogleHealthRespiratoryRateSleepSummary +import org.radarcns.push.googlehealth.GoogleHealthSedentaryPeriod +import org.radarcns.push.googlehealth.GoogleHealthSleepClassic +import org.radarcns.push.googlehealth.GoogleHealthSleepStage +import org.radarcns.push.googlehealth.GoogleHealthSource +import org.radarcns.push.googlehealth.GoogleHealthSteps +import org.radarcns.push.googlehealth.GoogleHealthTotalCalories + +inline fun googleHealthSource( + block: GoogleHealthSource.Builder.() -> Unit, +): GoogleHealthSource = GoogleHealthSource.newBuilder().apply(block).build() + +inline fun googleHealthElectrocardiogram( + block: GoogleHealthElectrocardiogram.Builder.() -> Unit, +): GoogleHealthElectrocardiogram = GoogleHealthElectrocardiogram.newBuilder().apply(block).build() + +inline fun googleHealthIrregularRhythmNotification( + block: GoogleHealthIrregularRhythmNotification.Builder.() -> Unit, +): GoogleHealthIrregularRhythmNotification = + GoogleHealthIrregularRhythmNotification.newBuilder().apply(block).build() + +inline fun googleHealthSteps( + block: GoogleHealthSteps.Builder.() -> Unit, +): GoogleHealthSteps = GoogleHealthSteps.newBuilder().apply(block).build() + +inline fun googleHealthFloors( + block: GoogleHealthFloors.Builder.() -> Unit, +): GoogleHealthFloors = GoogleHealthFloors.newBuilder().apply(block).build() + +inline fun googleHealthSedentaryPeriod( + block: GoogleHealthSedentaryPeriod.Builder.() -> Unit, +): GoogleHealthSedentaryPeriod = GoogleHealthSedentaryPeriod.newBuilder().apply(block).build() + +inline fun googleHealthActivityLevel( + block: GoogleHealthActivityLevel.Builder.() -> Unit, +): GoogleHealthActivityLevel = GoogleHealthActivityLevel.newBuilder().apply(block).build() + +inline fun googleHealthHeartRate( + block: GoogleHealthHeartRate.Builder.() -> Unit, +): GoogleHealthHeartRate = GoogleHealthHeartRate.newBuilder().apply(block).build() + +inline fun googleHealthHeartRateVariability( + block: GoogleHealthHeartRateVariability.Builder.() -> Unit, +): GoogleHealthHeartRateVariability = + GoogleHealthHeartRateVariability.newBuilder().apply(block).build() + +inline fun googleHealthOxygenSaturation( + block: GoogleHealthOxygenSaturation.Builder.() -> Unit, +): GoogleHealthOxygenSaturation = GoogleHealthOxygenSaturation.newBuilder().apply(block).build() + +inline fun googleHealthDailyRestingHeartRate( + block: GoogleHealthDailyRestingHeartRate.Builder.() -> Unit, +): GoogleHealthDailyRestingHeartRate = + GoogleHealthDailyRestingHeartRate.newBuilder().apply(block).build() + +inline fun googleHealthRespiratoryRateSleepSummary( + block: GoogleHealthRespiratoryRateSleepSummary.Builder.() -> Unit, +): GoogleHealthRespiratoryRateSleepSummary = + GoogleHealthRespiratoryRateSleepSummary.newBuilder().apply(block).build() + +inline fun googleHealthDailySleepTemperatureDerivations( + block: GoogleHealthDailySleepTemperatureDerivations.Builder.() -> Unit, +): GoogleHealthDailySleepTemperatureDerivations = + GoogleHealthDailySleepTemperatureDerivations.newBuilder().apply(block).build() + +inline fun googleHealthSleepClassic( + block: GoogleHealthSleepClassic.Builder.() -> Unit, +): GoogleHealthSleepClassic = GoogleHealthSleepClassic.newBuilder().apply(block).build() + +inline fun googleHealthSleepStage( + block: GoogleHealthSleepStage.Builder.() -> Unit, +): GoogleHealthSleepStage = GoogleHealthSleepStage.newBuilder().apply(block).build() + +inline fun googleHealthExercise( + block: GoogleHealthExercise.Builder.() -> Unit, +): GoogleHealthExercise = GoogleHealthExercise.newBuilder().apply(block).build() + +inline fun googleHealthExerciseHeartRate( + block: GoogleHealthExerciseHeartRate.Builder.() -> Unit, +): GoogleHealthExerciseHeartRate = GoogleHealthExerciseHeartRate.newBuilder().apply(block).build() + +inline fun googleHealthTotalCalories( + block: GoogleHealthTotalCalories.Builder.() -> Unit, +): GoogleHealthTotalCalories = GoogleHealthTotalCalories.newBuilder().apply(block).build() diff --git a/google-health-library/src/test/kotlin/org/radarbase/googlehealth/converter/GoogleHealthConverterRobustnessTest.kt b/google-health-library/src/test/kotlin/org/radarbase/googlehealth/converter/GoogleHealthConverterRobustnessTest.kt new file mode 100644 index 00000000..64e2fa59 --- /dev/null +++ b/google-health-library/src/test/kotlin/org/radarbase/googlehealth/converter/GoogleHealthConverterRobustnessTest.kt @@ -0,0 +1,222 @@ +/* + * 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 com.fasterxml.jackson.databind.ObjectMapper +import org.radarbase.googlehealth.user.GoogleHealthUser +import org.radarcns.push.googlehealth.GoogleHealthDailyRestingHeartRate +import org.radarcns.push.googlehealth.GoogleHealthElectrocardiogram +import org.radarcns.push.googlehealth.GoogleHealthExercise +import org.radarcns.push.googlehealth.GoogleHealthFloors +import org.radarcns.push.googlehealth.GoogleHealthHeartRate +import org.radarcns.push.googlehealth.GoogleHealthSteps +import org.radarcns.push.googlehealth.GoogleHealthTotalCalories +import java.time.Instant +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** Made-up data in the shapes the Google Health API returns. */ +class GoogleHealthConverterRobustnessTest { + private val mapper = ObjectMapper() + private val irnName = "users/me/dataTypes/irregular-rhythm-notification/dataPoints/9" + private val user = GoogleHealthUser( + id = "1", + createdAt = Instant.parse("2026-10-01T00:00:00Z"), + projectId = "test-project", + userId = "test-user", + humanReadableUserId = null, + sourceId = "test-source", + externalId = null, + isAuthorized = true, + startDate = Instant.parse("2026-10-01T00:00:00Z"), + endDate = null, + serviceUserId = "1234567890", + ) + + private fun page(vararg points: String): JsonNode = + mapper.readTree("""{"dataPoints": [${points.joinToString(",")}]}""") + + private fun exercise(name: String?): String { + val nameField = name?.let { "\"name\": \"$EXERCISE_PREFIX$it\"," } ?: "" + return """ + {$nameField "exercise": { + "interval": {"startTime": "2026-10-20T10:00:00Z", "endTime": "2026-10-20T10:30:00Z", + "startUtcOffset": "3600s"}, + "exerciseType": "WALKING", "activeDuration": "1500s", + "metricsSummary": {"caloriesKcal": 100, "steps": "2500"}}} + """ + } + + private fun ecg(name: String?): String { + val nameField = name?.let { "\"name\": \"$ECG_PREFIX$it\"," } ?: "" + return """ + {$nameField "electrocardiogram": { + "interval": {"startTime": "2026-10-20T10:00:00Z"}, + "waveformSamples": [1, 2, 3], "samplingFrequencyHertz": 250}} + """ + } + + private fun steps(count: String) = """ + {"steps": {"interval": {"startTime": "2026-10-20T10:00:00Z", + "endTime": "2026-10-20T10:01:00Z"}, "count": $count}} + """ + + @Test + fun exercisePointWithIdAboveLongMaxIsSkippedNotThePage() { + val tree = page(exercise("1111"), exercise("18000000000000000000"), exercise("3333")) + val ids = GoogleHealthExerciseAvroConverter("t").convert(tree, user) + .map { (it.second as GoogleHealthExercise).id } + assertEquals(listOf(1111L, 3333L), ids) + } + + @Test + fun exercisePointWithoutNameIsSkippedNotThePage() { + val tree = page(exercise(null), exercise("2222")) + assertEquals(1, GoogleHealthExerciseAvroConverter("t").convert(tree, user).size) + } + + @Test + fun ecgReadingWithoutNameIsSkippedNotThePage() { + val tree = page(ecg(null), ecg("2026-10-20T10:00:00.000")) + val records = GoogleHealthElectrocardiogramAvroConverter("t").convert(tree, user) + assertEquals(3, records.size) + assertEquals( + "2026-10-20T10:00:00.000", + (records.first().second as GoogleHealthElectrocardiogram).id, + ) + } + + @Test + fun irregularRhythmPointWithoutNameIsSkippedNotThePage() { + val irn = { name: String? -> + val nameField = name?.let { "\"name\": \"$it\"," } ?: "" + """ + {$nameField "irregularRhythmNotification": {"alertWindows": [ + {"startTime": "2026-10-20T10:00:00Z", "endTime": "2026-10-20T10:01:00Z", + "heartBeats": [ + {"physicalTime": "2026-10-20T10:00:01Z", "beatsPerMinute": "80"}]}]}} + """ + } + val tree = page(irn(null), irn(irnName)) + val converter = GoogleHealthIrregularRhythmNotificationAvroConverter("t") + assertEquals(1, converter.convert(tree, user).size) + } + + @Test + fun nonNumericStepCountIsDroppedNotZero() { + val tree = page(steps("\"24\""), steps("24"), steps("\"abc\""), steps("null")) + val counts = GoogleHealthStepsAvroConverter("t").convert(tree, user) + .map { (it.second as GoogleHealthSteps).steps } + assertEquals(listOf(24, 24), counts) + } + + @Test + fun nonNumericFloorCountIsDroppedNotZero() { + val floors = { count: String -> + """ + {"floors": {"interval": {"startTime": "2026-10-20T10:00:00Z", + "endTime": "2026-10-20T10:01:00Z"}, "count": $count}} + """ + } + val tree = page(floors("\"1\""), floors("\"abc\"")) + val counts = GoogleHealthFloorsAvroConverter("t").convert(tree, user) + .map { (it.second as GoogleHealthFloors).floors } + assertEquals(listOf(1), counts) + } + + @Test + fun kcalSumAsStringIsReadNotZero() { + val bucket = { kcal: String -> + """ + {"startTime": "2026-10-20T10:00:00Z", "endTime": "2026-10-20T10:01:00Z", + "totalCalories": {"kcalSum": $kcal}} + """ + } + val buckets = listOf(bucket("1.5"), bucket("\"1.25\""), bucket("\"abc\"")) + val tree = mapper.readTree("""{"rollupDataPoints": [${buckets.joinToString(",")}]}""") + val calories = GoogleHealthTotalCaloriesAvroConverter("t").convert(tree, user) + .map { (it.second as GoogleHealthTotalCalories).calories } + assertEquals(listOf(1.5, 1.25), calories) + } + + @Test + fun respiratorySummaryWithoutStatsIsDropped() { + val summary = { stats: String -> + """ + {"respiratoryRateSleepSummary": {"sampleTime": + {"physicalTime": "2026-10-20T06:00:00Z"} $stats}} + """ + } + val tree = page(summary(""), summary(""", "fullSleepStats": {"breathsPerMinute": 14.2}""")) + val converter = GoogleHealthRespiratoryRateSleepSummaryAvroConverter("t") + assertEquals(1, converter.convert(tree, user).size) + } + + @Test + fun nullOrNonNumericHeartRateIsDroppedNotZero() { + val heartRate = { bpm: String -> + """ + {"heartRate": {"sampleTime": {"physicalTime": "2026-10-20T10:00:00Z"}, + "beatsPerMinute": $bpm}} + """ + } + val tree = page(heartRate("\"59\""), heartRate("60"), heartRate("null"), heartRate("\"x\"")) + val values = GoogleHealthHeartRateAvroConverter("t").convert(tree, user) + .map { (it.second as GoogleHealthHeartRate).heartRate } + assertEquals(listOf(59, 60), values) + } + + @Test + fun nullOrNonNumericRestingHeartRateIsDroppedNotZero() { + val resting = { bpm: String -> + """ + {"dailyRestingHeartRate": {"date": {"year": 2026, "month": 10, "day": 20}, + "beatsPerMinute": $bpm}} + """ + } + val tree = page(resting("\"55\""), resting("null"), resting("\"x\"")) + val values = GoogleHealthDailyRestingHeartRateAvroConverter("t").convert(tree, user) + .map { (it.second as GoogleHealthDailyRestingHeartRate).restingHeartRate } + assertEquals(listOf(55), values) + } + + @Test + fun nullOrNonNumericEcgSampleIsSkippedAndOthersKeepTheirTime() { + val tree = page( + """ + {"name": "${ECG_PREFIX}r1", "electrocardiogram": { + "interval": {"startTime": "2026-10-20T10:00:00Z"}, + "waveformSamples": [10, null, "x", 40], "samplingFrequencyHertz": 250, + "millivoltsScalingFactor": "x", "leadNumber": null}} + """, + ) + val records = GoogleHealthElectrocardiogramAvroConverter("t").convert(tree, user) + .map { it.second as GoogleHealthElectrocardiogram } + assertEquals(listOf(10, 40), records.map { it.sample }) + val start = Instant.parse("2026-10-20T10:00:00Z").epochSecond.toDouble() + assertEquals(listOf(start, start + 3.0 / 250), records.map { it.time }) + assertNull(records.first().millivoltsScalingFactor) + assertNull(records.first().leadNumber) + } + + companion object { + private const val EXERCISE_PREFIX = "users/1234567890/dataTypes/exercise/dataPoints/" + private const val ECG_PREFIX = "users/me/dataTypes/electrocardiogram/dataPoints/" + } +} diff --git a/google-health-library/src/test/kotlin/org/radarbase/googlehealth/user/GoogleHealthUsersTest.kt b/google-health-library/src/test/kotlin/org/radarbase/googlehealth/user/GoogleHealthUsersTest.kt new file mode 100644 index 00000000..b5dc5814 --- /dev/null +++ b/google-health-library/src/test/kotlin/org/radarbase/googlehealth/user/GoogleHealthUsersTest.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.user + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import kotlin.test.Test +import kotlin.test.assertEquals + +/** Made-up users in the shape the authorizer's `users` endpoint returns. */ +class GoogleHealthUsersTest { + private val mapper = ObjectMapper().registerModule(JavaTimeModule()) + + private fun user(id: String, serviceUserId: String?) = """ + {"id": "$id", "createdAt": "2026-10-01T00:00:00Z", "projectId": "p", "userId": "u$id", + "sourceId": "s$id", "startDate": "2026-10-01T00:00:00Z", "sourceType": "GoogleHealth", + "isAuthorized": true, "hasValidToken": true, "timesReset": 0, + "serviceUserId": ${serviceUserId?.let { "\"$it\"" } ?: "null"}} + """ + + @Test + fun incompleteUserIsSkippedNotTheWholeList() { + val json = """{"users": [${user("1", "111")}, ${user("2", null)}, ${user("3", "333")}]}""" + val users = mapper.readValue(json, GoogleHealthUsers::class.java).users + assertEquals(listOf("1", "3"), users.map { it.id }) + assertEquals(listOf("111", "333"), users.map { it.serviceUserId }) + assertEquals("p", users.first().observationKey.projectId) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1bad2750..b68672b6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,28 +1,29 @@ [versions] -project = "0.7.5" -gradle = "8.14" -kotlin = "1.9.24" -radarCommons = "1.2.6" +project = "0.7.6" +gradle = "9.6.0" +kotlin = "2.3.20" +radarCommons = "1.2.8" confluent = "7.8.1" avro = "1.12.1" -# @pin Upgrade to 2.21.x requires kotlin v2 minimum -jackson = "2.20.2" -jacksonAnnotations = "2.20" -log4j2 = "2.23.1" +jackson = "2.21.7" +jacksonAnnotations = "2.21" +log4j2 = "2.25.5" slf4j = "2.0.17" sentryLog4j = "1.7.30" sentryOpenTelemetryAgent = "8.36.0" # @pin Upgrade to 5.x.x requires kotlin v2 minimum okhttp = "4.12.0" -firebaseAdmin = "9.8.0" -radarSchemas = "0.8.16" +firebaseAdmin = "9.11.0" +radarSchemas = "0.9.4" # @pin Upgrade to 3.x.x requires kotlin v2 minimum ktor = "2.3.13" -wiremock = "3.0.1" +wiremock = "3.13.2" mockito = "5.23.0" -netty = "4.2.10.Final" +netty = "4.2.17.Final" +# Forced transitive override for firebase-admin, see build.gradle.kts. +httpcore5 = "5.4.3" commonsLang3 = "3.20.0" -lz4 = "1.10.1" +lz4 = "1.11.4" [libraries] lz4 = { module = "at.yawk.lz4:lz4-java", version.ref = "lz4" } @@ -52,7 +53,9 @@ netty-handler-proxy = { module = "io.netty:netty-handler-proxy", version.ref = " netty-handler = { module = "io.netty:netty-handler", version.ref = "netty" } commons-lang3 = { module = "org.apache.commons:commons-lang3", version.ref = "commonsLang3" } mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" } -wiremock = { module = "com.github.tomakehurst:wiremock", version.ref = "wiremock" } +wiremock = { module = "org.wiremock:wiremock", version.ref = "wiremock" } +httpcore5 = { module = "org.apache.httpcomponents.core5:httpcore5", version.ref = "httpcore5" } +httpcore5-h2 = { module = "org.apache.httpcomponents.core5:httpcore5-h2", version.ref = "httpcore5" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } @@ -60,3 +63,4 @@ kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version radar-root-project = { id = "org.radarbase.radar-root-project", version.ref = "radarCommons" } radar-dependency-management = { id = "org.radarbase.radar-dependency-management", version.ref = "radarCommons" } radar-kotlin = { id = "org.radarbase.radar-kotlin", version.ref = "radarCommons" } +radar-publishing = { id = "org.radarbase.radar-publishing", version.ref = "radarCommons" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 7f93135c..b1b8ef56 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ca025c83..eb84db68 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,9 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip networkTimeout=10000 +retries=0 +retryBackOffMs=500 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 1aa94a42..249efbb0 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,10 +15,12 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # -# Gradle start up script for POSIX generated by Gradle. +# gradlew start up script for POSIX generated by Gradle. # # Important for running: # @@ -27,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: @@ -55,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -84,7 +86,7 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -112,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -170,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -203,15 +203,14 @@ fi DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/gradlew.bat b/gradlew.bat index 6689b85b..8508ef68 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,16 +13,18 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem Gradle startup script for Windows +@rem gradlew startup script for Windows @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @@ -43,13 +45,13 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -57,36 +59,24 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% -:mainEnd -if "%OS%"=="Windows_NT" endlocal +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel -:omega +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/kafka-connect-fitbit-source/Dockerfile b/kafka-connect-fitbit-source/Dockerfile index ca5fe3e7..425d3f25 100644 --- a/kafka-connect-fitbit-source/Dockerfile +++ b/kafka-connect-fitbit-source/Dockerfile @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -FROM --platform=$BUILDPLATFORM gradle:8.14-jdk17 AS builder +FROM --platform=$BUILDPLATFORM gradle:9.6.0-jdk17 AS builder RUN mkdir /code WORKDIR /code @@ -25,6 +25,9 @@ COPY ./build.gradle.kts ./settings.gradle.kts ./gradle.properties /code/ COPY kafka-connect-rest-source/build.gradle.kts /code/kafka-connect-rest-source/ COPY kafka-connect-fitbit-source/build.gradle.kts /code/kafka-connect-fitbit-source/ +# Gradle 9 requires the directory of every included project to exist. +RUN mkdir -p /code/kafka-connect-oura-source /code/oura-library /code/google-health-library + RUN gradle downloadDependencies copyDependencies COPY ./kafka-connect-rest-source/src/ /code/kafka-connect-rest-source/src diff --git a/kafka-connect-oura-source/Dockerfile b/kafka-connect-oura-source/Dockerfile index b5d87718..455fde2d 100644 --- a/kafka-connect-oura-source/Dockerfile +++ b/kafka-connect-oura-source/Dockerfile @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -FROM --platform=$BUILDPLATFORM gradle:8.14-jdk17 AS builder +FROM --platform=$BUILDPLATFORM gradle:9.6.0-jdk17 AS builder RUN mkdir /code WORKDIR /code @@ -25,6 +25,9 @@ COPY ./build.gradle.kts ./settings.gradle.kts ./gradle.properties /code/ COPY kafka-connect-oura-source/build.gradle.kts /code/kafka-connect-oura-source/ COPY oura-library/build.gradle /code/oura-library/ +# Gradle 9 requires the directory of every included project to exist. +RUN mkdir -p /code/kafka-connect-fitbit-source /code/kafka-connect-rest-source /code/google-health-library + RUN gradle downloadDependencies copyDependencies COPY ./kafka-connect-oura-source/src/ /code/kafka-connect-oura-source/src diff --git a/oura-library/build.gradle b/oura-library/build.gradle index 6d0cc80a..b4a160ba 100644 --- a/oura-library/build.gradle +++ b/oura-library/build.gradle @@ -1,17 +1,6 @@ - -group = 'org.radarbase' -version = '0.0.1' - -apply plugin: 'maven-publish' - -repositories { - // Use jcenter for resolving dependencies. - // You can declare any Maven/Ivy/file repository here. - mavenCentral() -} +description = "Library for converting Oura data into RADAR-base Avro records" dependencies { - // Use the Kotlin JDK 8 standard library. implementation libs.kotlin.stdlib implementation libs.okhttp @@ -26,22 +15,5 @@ dependencies { implementation libs.jackson.datatype.jsr310 - // Use the Kotlin test library. testImplementation libs.kotlin.test - - // Use the Kotlin JUnit integration. - testImplementation libs.kotlin.test.junit -} - -project.afterEvaluate { - publishing { - publications { - library(MavenPublication) { - setGroupId "$group" - setArtifactId "oura-library" - version "$version" - from components.java - } - } - } } diff --git a/scripts/REDCAP-FITBIT-AUTH-AUTO/requirements.txt b/scripts/REDCAP-FITBIT-AUTH-AUTO/requirements.txt index 4e31460f..b65c2b66 100644 --- a/scripts/REDCAP-FITBIT-AUTH-AUTO/requirements.txt +++ b/scripts/REDCAP-FITBIT-AUTH-AUTO/requirements.txt @@ -2,8 +2,8 @@ certifi==2018.10.15 chardet==3.0.4 idna==2.7 PyCap==1.0.2 -PyYAML==5.3.1 -requests==2.22.0 +PyYAML==5.4 +requests==2.25.1 selenium==3.14.1 semantic-version==2.3.1 -urllib3==1.25.9 +urllib3==1.26.20 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 {