Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
295962f
New library for google health api
this-Aditya May 1, 2026
2f64a7b
Include new library as a project sub-module
this-Aditya May 1, 2026
26828e7
Base user model class
this-Aditya May 1, 2026
3c50b79
Google health user data classes added
this-Aditya May 1, 2026
ff84725
Model class for oauth access tokens
this-Aditya May 1, 2026
9a97ecc
User repositories contract added
this-Aditya May 1, 2026
1673b9a
Data classes matching the google subscription endpoint response
this-Aditya May 1, 2026
653c687
Exception classes
this-Aditya May 1, 2026
0c24f3d
Utility classes for avro converters and builders
this-Aditya May 1, 2026
b47c0d9
Base google health avro converter classes to be implemented by all go…
this-Aditya May 1, 2026
540c919
Specific data type converter classes added
this-Aditya May 1, 2026
c5ec008
Simplifying the total calories conversion
this-Aditya May 3, 2026
99213c3
Optimize infering of sleep stage and classic data
this-Aditya Jun 5, 2026
66cc5f1
Abstract method for fetch unauthorized users
this-Aditya Jun 7, 2026
28bf02a
Make serviceUserId nullable, as googlehealth needs to fetch unauthori…
this-Aditya Jun 9, 2026
2b3de1d
Not fetching the unauthorized users and making serviceUserId non null…
this-Aditya Jun 9, 2026
48bec37
Match google-health-library build to refine-publishing central publis…
this-Aditya Jun 26, 2026
2edfa9f
ktlint format
this-Aditya Jun 26, 2026
95efc2e
Updates to pass CI
this-Aditya Jun 27, 2026
6301b49
One more try for avro builders
this-Aditya Jun 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions google-health-library/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
description = "Google Health library with utility components to be used in RADAR-Pushendpoint"

dependencies {
implementation libs.kotlin.stdlib

implementation libs.okhttp

implementation libs.radar.schemas.commons

implementation libs.jackson.annotations

implementation libs.jackson.databind

implementation libs.avro

implementation libs.jackson.datatype.jsr310

testImplementation libs.kotlin.test

testImplementation libs.kotlin.test.junit
}
Original file line number Diff line number Diff line change
@@ -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<Pair<SpecificRecord, SpecificRecord>>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* Copyright 2026 King's College London
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.radarbase.googlehealth.converter

import com.fasterxml.jackson.databind.JsonNode
import org.apache.avro.specific.SpecificRecord
import org.radarbase.googlehealth.user.User
import org.radarbase.googlehealth.util.restingHeartRate

class DailyRestingHeartRateGoogleHealthAvroConverter(topic: String) :
GoogleHealthAvroConverter(topic) {
override fun convertDataPoint(
point: JsonNode,
user: User,
): List<Pair<SpecificRecord, SpecificRecord>> {
val data = point["dailyRestingHeartRate"] ?: return emptyList()
val dateNode = data["date"] ?: return emptyList()
val bpm = data["beatsPerMinute"]?.asInt() ?: return emptyList()
val isoDate = String.format(
"%04d-%02d-%02d",
dateNode["year"].asInt(),
dateNode["month"].asInt(),
dateNode["day"].asInt(),
)
val record = restingHeartRate {
date = isoDate
timeReceived = nowEpochSeconds()
restingHeartRate = bpm
}
return listOf(user.observationKey to record)
}
}
Original file line number Diff line number Diff line change
@@ -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.skinTemperature
import org.radarcns.connector.fitbit.FitbitSkinTemperatureLogType

class DailySleepTemperatureDerivationsGoogleHealthAvroConverter(topic: String) :
GoogleHealthAvroConverter(topic) {
override fun convertDataPoint(
point: JsonNode,
user: User,
): List<Pair<SpecificRecord, SpecificRecord>> {
val data = point["dailySleepTemperatureDerivations"] ?: return emptyList()
val nightly = data["nightlyTemperatureCelsius"]?.takeIf {
it.isNumber
}?.floatValue() ?: return emptyList()
val baseline = data["baselineTemperatureCelsius"]?.takeIf {
it.isNumber
}?.floatValue() ?: return emptyList()
val time = parseDate(data) ?: return emptyList()
val record = skinTemperature {
this.time = epochSeconds(time)
timeReceived = nowEpochSeconds()
relativeTemperature = nightly - baseline
logType = FitbitSkinTemperatureLogType.UNKNOWN
}
return listOf(user.observationKey to record)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* Copyright 2026 King's College London
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.radarbase.googlehealth.converter

import com.fasterxml.jackson.databind.JsonNode
import org.apache.avro.specific.SpecificRecord
import org.radarbase.googlehealth.user.User
import org.radarbase.googlehealth.util.activityHeartRate
import org.radarbase.googlehealth.util.activityLogRecord

class ExerciseGoogleHealthAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) {
override fun convertDataPoint(
point: JsonNode,
user: User,
): List<Pair<SpecificRecord, SpecificRecord>> {
val data = point["exercise"] ?: return emptyList()
val (start, end) = parseInterval(data) ?: return emptyList()
val offsetSeconds = parseUtcOffsetSeconds(
data["interval"]?.get("startUtcOffset")?.asText(),
)
val durationSec = (end.epochSecond - start.epochSecond).toFloat().coerceAtLeast(0.0f)
val metrics = data["metricsSummary"]
val distanceKm = metrics?.get("distanceMillimeters")?.takeIf { !it.isNull }
?.asDouble()?.let { it.toFloat() / 1_000_000f }
val caloriesKcal = metrics?.get("caloriesKcal")?.takeIf { !it.isNull }?.asDouble()
val energyKj = caloriesKcal?.let { (it * KCAL_TO_KJ).toFloat() }
val stepCount = metrics?.get("steps")?.takeIf { !it.isNull }?.asInt()
val avgHr = metrics?.get("averageHeartRateBeatsPerMinute")?.takeIf { !it.isNull }?.asInt()
val avgHeartRate = avgHr?.let { activityHeartRate { mean = it } }
val exerciseType = data["exerciseType"]?.asText()
val activityId = (point["name"]?.asText() ?: exerciseType ?: "")
.hashCode().toLong()
val record = activityLogRecord {
time = epochSeconds(start)
timeReceived = nowEpochSeconds()
timeZoneOffset = offsetSeconds
timeLastModified = epochSeconds(end)
duration = durationSec
durationActive = durationSec
id = activityId
name = data["displayName"]?.asText() ?: exerciseType
logType = point["dataSource"]?.get("recordingMethod")?.asText()
type = null
source = null
manualDataEntry = null
energy = energyKj
levels = null
heartRate = avgHeartRate
steps = stepCount
distance = distanceKm
speed = null
}
return listOf(user.observationKey to record)
}

companion object {
private const val KCAL_TO_KJ = 4.1868
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/*
* Copyright 2026 King's College London
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.radarbase.googlehealth.converter

import com.fasterxml.jackson.databind.JsonNode
import org.apache.avro.specific.SpecificRecord
import org.radarbase.googlehealth.user.User
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneOffset

abstract class GoogleHealthAvroConverter(override val topic: String) : AvroConverter {

abstract fun convertDataPoint(
point: JsonNode,
user: User,
): List<Pair<SpecificRecord, SpecificRecord>>

override fun convert(tree: JsonNode, user: User): List<Pair<SpecificRecord, SpecificRecord>> {
val points = tree["dataPoints"] ?: tree["rollupDataPoints"] ?: return emptyList()
if (!points.isArray) return emptyList()
return points.flatMap { convertDataPoint(it, user) }
}

companion object {
fun parseInterval(node: JsonNode): Pair<Instant, Instant>? {
val interval = node["interval"] ?: return null
val start = runCatching {
Instant.parse(interval["startTime"]?.asText() ?: return null)
}.getOrNull() ?: return null
val end = runCatching {
Instant.parse(interval["endTime"]?.asText() ?: return null)
}.getOrNull() ?: return null
return start to end
}

fun parseSampleTime(node: JsonNode): Instant? {
val sample = node["sampleTime"] ?: return null
val text = sample["physicalTime"]?.asText()?.takeIf { it.isNotEmpty() } ?: return null
return runCatching { Instant.parse(text) }.getOrNull()
}

fun parseDate(node: JsonNode, utcOffsetSeconds: Int = 0): Instant? {
val dateNode = node["date"] ?: return null
val year = dateNode["year"]?.asInt() ?: return null
val month = dateNode["month"]?.asInt() ?: return null
val day = dateNode["day"]?.asInt() ?: return null
val local = runCatching { LocalDate.of(year, month, day) }.getOrNull() ?: return null
val timeNode = node["time"]
val dateTime = if (timeNode != null && !timeNode.isNull) {
LocalDateTime.of(
local,
LocalTime.of(
timeNode["hours"]?.asInt() ?: 0,
timeNode["minutes"]?.asInt() ?: 0,
timeNode["seconds"]?.asInt() ?: 0,
timeNode["nanos"]?.asInt() ?: 0,
),
)
} else {
local.atStartOfDay()
}
return dateTime.toInstant(ZoneOffset.ofTotalSeconds(utcOffsetSeconds))
}

fun parseUtcOffsetSeconds(durationText: String?): Int {
if (durationText.isNullOrEmpty()) return 0
val trimmed = durationText.trim().removeSuffix("s")
val seconds = trimmed.toLongOrNull() ?: return 0
return seconds.toInt()
}

fun epochSeconds(instant: Instant): Double = instant.toEpochMilli() / 1000.0

fun nowEpochSeconds(): Double = Instant.now().toEpochMilli() / 1000.0
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* Copyright 2026 King's College London
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.radarbase.googlehealth.converter

import com.fasterxml.jackson.databind.JsonNode
import org.apache.avro.specific.SpecificRecord
import org.radarbase.googlehealth.user.User
import org.radarbase.googlehealth.util.intradayHeartRate

class HeartRateGoogleHealthAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) {
override fun convertDataPoint(
point: JsonNode,
user: User,
): List<Pair<SpecificRecord, SpecificRecord>> {
val data = point["heartRate"] ?: return emptyList()
val time = parseSampleTime(data) ?: return emptyList()
val bpm = data["beatsPerMinute"]?.asInt() ?: return emptyList()
val record = intradayHeartRate {
this.time = epochSeconds(time)
timeReceived = nowEpochSeconds()
timeInterval = 1
heartRate = bpm
}
return listOf(user.observationKey to record)
}
}
Loading
Loading