Health Connect: Prefer granular active calories if available

If the device supports active calories in activity samples prioritize
those, as they are more granular and capture activities that may not
have been explicitly recorded.
This commit is contained in:
José Rebelo
2026-02-02 21:22:41 +00:00
parent 02bc594674
commit 8c575535b2
6 changed files with 247 additions and 109 deletions
@@ -573,6 +573,12 @@ class HealthConnectUtils {
healthConnectClient, gbDevice, metadata, offset,
currentSliceStartTs, currentSliceEndTs, grantedPermissions, activityBasedSamples
))
if (gbDevice.deviceCoordinator.supportsActiveCalories(gbDevice)) {
sliceStats.add(ActiveCaloriesSyncer.sync(
healthConnectClient, gbDevice, metadata, offset,
currentSliceStartTs, currentSliceEndTs, grantedPermissions, activityBasedSamples
))
}
}
}
HealthConnectPermissionManager.HealthConnectDataType.SLEEP -> {
@@ -0,0 +1,120 @@
/* Copyright (C) 2025 Gideon Zenz
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.util.healthconnect.syncers
import androidx.health.connect.client.HealthConnectClient
import androidx.health.connect.client.permission.HealthPermission
import androidx.health.connect.client.records.Record
import androidx.health.connect.client.records.metadata.Metadata
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample
import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.HealthConnectUtils
import org.slf4j.Logger
import java.time.Instant
import java.time.ZoneOffset
import java.time.temporal.ChronoUnit
import kotlin.reflect.KClass
internal abstract class AbstractActivitySampleSyncer<TRecord : Record> : ActivitySampleSyncer {
protected abstract val logger: Logger
protected abstract val recordClass: KClass<TRecord>
protected abstract fun convertSample(
sample: ActivitySample,
offset: ZoneOffset,
metadata: Metadata,
deviceName: String
): TRecord?
override suspend fun sync(
healthConnectClient: HealthConnectClient,
gbDevice: GBDevice,
metadata: Metadata,
offset: ZoneOffset,
sliceStartBoundary: Instant,
sliceEndBoundary: Instant,
grantedPermissions: Set<String>,
deviceSamples: List<ActivitySample>
): SyncerStatistics {
val deviceName = gbDevice.aliasOrName
val recordTypeName = recordClass.simpleName ?: "Unknown"
// 1. Permission Check
if (HealthPermission.getWritePermission(recordClass) !in grantedPermissions) {
logger.info("Skipping $recordTypeName sync for device '$deviceName'; $recordTypeName permission not granted.")
return SyncerStatistics(recordType = recordTypeName)
}
// 2. Relevant Input Data Check
val relevantSamples = deviceSamples.sortedBy { it.timestamp }
if (relevantSamples.isEmpty()) {
logger.info("No relevant step samples (>0) for device '$deviceName' in the provided deviceSamples for slice $sliceStartBoundary to $sliceEndBoundary.")
return SyncerStatistics(recordType = recordTypeName)
}
logger.info("Processing ${relevantSamples.size} samples for steps for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.")
val recordsToInsert = mutableListOf<Record>()
var skippedCount = 0
relevantSamples.forEach { currentSample ->
val endTs = Instant.ofEpochSecond(currentSample.timestamp.toLong())
val startTs = endTs.minus(1, ChronoUnit.MINUTES)
// Use inclusive boundaries [sliceStart, sliceEnd] for the slice
// Overlap check: interval overlaps if endTs > sliceStart AND startTs <= sliceEnd
if (endTs.isBefore(sliceStartBoundary) || startTs.isAfter(sliceEndBoundary)) {
logger.debug(
"Skipping {} for device '{}' for sample at {} (interval {} to {}) as its interval is outside the slice {} - {}.",
recordTypeName,
deviceName,
endTs,
startTs,
endTs,
sliceStartBoundary,
sliceEndBoundary
)
return@forEach
}
val record = convertSample(sample = currentSample, offset, metadata, deviceName)
if (record == null) {
skippedCount++
return@forEach
}
recordsToInsert.add(record)
}
// 3. No Valid Records to Insert
if (recordsToInsert.isEmpty()) {
logger.info("No valid $recordTypeName created for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary after processing ${relevantSamples.size} samples.")
return SyncerStatistics(recordsSkipped = skippedCount, recordType = recordTypeName)
}
// 4. Insertion (with chunking)
logger.info("Attempting to insert ${recordsToInsert.size} $recordTypeName(s) for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.")
for (chunk in recordsToInsert.chunked(HealthConnectUtils.CHUNK_SIZE)) {
HealthConnectUtils.insertRecords(chunk, healthConnectClient)
}
logger.info("Successfully inserted ${recordsToInsert.size} $recordTypeName(s) for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.")
return SyncerStatistics(
recordsSynced = recordsToInsert.size,
recordsSkipped = skippedCount,
recordType = recordTypeName
)
}
}
@@ -0,0 +1,57 @@
/* Copyright (C) 2025 Gideon Zenz
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.util.healthconnect.syncers
import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord
import androidx.health.connect.client.records.metadata.Metadata
import androidx.health.connect.client.units.Energy
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.time.Instant
import java.time.ZoneOffset
import java.time.temporal.ChronoUnit
import kotlin.reflect.KClass
internal object ActiveCaloriesSyncer : AbstractActivitySampleSyncer<ActiveCaloriesBurnedRecord>() {
override val logger: Logger = LoggerFactory.getLogger(StepsSyncer::class.java)
override val recordClass: KClass<ActiveCaloriesBurnedRecord> = ActiveCaloriesBurnedRecord::class
override fun convertSample(
sample: ActivitySample,
offset: ZoneOffset,
metadata: Metadata,
deviceName: String
): ActiveCaloriesBurnedRecord? {
val caloriesInMinute = sample.activeCalories
if (caloriesInMinute <= 0) {
return null
}
val endTs = Instant.ofEpochSecond(sample.timestamp.toLong())
val startTs = endTs.minus(1, ChronoUnit.MINUTES)
return ActiveCaloriesBurnedRecord(
startTs,
offset,
endTs,
offset,
Energy.kilocalories(caloriesInMinute.toDouble()),
metadata
)
}
}
@@ -150,7 +150,7 @@ internal object RecordedWorkoutSyncer {
metadata,
grantedPermissions,
recordsToInsert,
deviceName,
gbDevice,
activityKind,
exerciseType,
context
@@ -301,11 +301,13 @@ internal object RecordedWorkoutSyncer {
metadata: Metadata,
grantedPermissions: Set<String>,
recordsToInsert: MutableList<Record>,
deviceName: String,
device: GBDevice,
activityKind: ActivityKind,
exerciseType: Int,
context: Context
) {
val deviceName = device.aliasOrName
// Build GPS route if location data is available and permission is granted
val exerciseRoute = if (PERMISSION_WRITE_EXERCISE_ROUTE in grantedPermissions) {
val locationPoints = activityPoints
@@ -356,7 +358,10 @@ internal object RecordedWorkoutSyncer {
val summaryData = parseSummaryData(workout.summaryData)
if (summaryData != null) {
addDistanceRecord(summaryData, workoutStartInstant, workoutEndInstant, offset, metadata, grantedPermissions, recordsToInsert, deviceName)
addCaloriesRecords(summaryData, workoutStartInstant, workoutEndInstant, offset, metadata, grantedPermissions, recordsToInsert, deviceName)
// Active calories are synced more granularly for these devices in ActiveCaloriesSyncer
if (!device.deviceCoordinator.supportsActiveCalories(device)) {
addCaloriesRecords(summaryData, workoutStartInstant, workoutEndInstant, offset, metadata, grantedPermissions, recordsToInsert, deviceName)
}
addElevationGainedRecord(summaryData, workoutStartInstant, workoutEndInstant, offset, metadata, grantedPermissions, recordsToInsert, deviceName)
addStepsRecord(summaryData, workoutStartInstant, workoutEndInstant, offset, metadata, grantedPermissions, recordsToInsert, deviceName)
addCadenceRecords(summaryData, activityKind, workoutStartInstant, workoutEndInstant, offset, metadata, grantedPermissions, recordsToInsert, deviceName)
@@ -1,106 +0,0 @@
/* Copyright (C) 2025 Gideon Zenz
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.util.healthconnect.syncers
import androidx.health.connect.client.HealthConnectClient
import androidx.health.connect.client.permission.HealthPermission
import androidx.health.connect.client.records.Record
import androidx.health.connect.client.records.StepsRecord
import androidx.health.connect.client.records.metadata.Metadata
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample
import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.HealthConnectUtils
import org.slf4j.LoggerFactory
import java.time.Instant
import java.time.ZoneOffset
import java.time.temporal.ChronoUnit
private val LOG = LoggerFactory.getLogger("StepsSyncer")
internal object StepsSyncer : ActivitySampleSyncer {
override suspend fun sync(
healthConnectClient: HealthConnectClient,
gbDevice: GBDevice,
metadata: Metadata,
offset: ZoneOffset,
sliceStartBoundary: Instant,
sliceEndBoundary: Instant,
grantedPermissions: Set<String>,
deviceSamples: List<ActivitySample>
): SyncerStatistics {
val deviceName = gbDevice.aliasOrName
// 1. Permission Check
if (HealthPermission.getWritePermission(StepsRecord::class) !in grantedPermissions) {
LOG.info("Skipping Steps sync for device '$deviceName'; StepsRecord permission not granted.")
return SyncerStatistics(recordType = "Steps")
}
// 2. Relevant Input Data Check
val relevantSamples = deviceSamples.filter { it.steps > 0 }.sortedBy { it.timestamp }
if (relevantSamples.isEmpty()) {
LOG.info("No relevant step samples (>0) for device '$deviceName' in the provided deviceSamples for slice $sliceStartBoundary to $sliceEndBoundary.")
return SyncerStatistics(recordType = "Steps")
}
LOG.info("Processing ${relevantSamples.size} samples for steps for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.")
val stepsRecordList = mutableListOf<Record>()
var skippedCount = 0
relevantSamples.forEach { currentSample ->
val stepsInMinute = currentSample.steps.toLong()
if (stepsInMinute > 0) {
val endTs = Instant.ofEpochSecond(currentSample.timestamp.toLong())
val startTs = endTs.minus(1, ChronoUnit.MINUTES)
// Use inclusive boundaries [sliceStart, sliceEnd] for the slice
// Overlap check: interval overlaps if endTs > sliceStart AND startTs <= sliceEnd
if (endTs.isAfter(sliceStartBoundary) && !startTs.isAfter(sliceEndBoundary)) {
stepsRecordList.add(StepsRecord(startTs, offset, endTs, offset, stepsInMinute, metadata))
} else {
skippedCount++
LOG.debug(
"Skipping steps for device '{}' for sample at {} (interval {} to {}) as its interval is outside the slice {} - {}.",
deviceName,
endTs,
startTs,
endTs,
sliceStartBoundary,
sliceEndBoundary
)
}
}
}
// 3. No Valid Records to Insert
if (stepsRecordList.isEmpty()) {
LOG.info("No valid StepsRecord created for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary after processing ${relevantSamples.size} samples.")
return SyncerStatistics(recordsSkipped = skippedCount, recordType = "Steps")
}
// 4. Insertion (with chunking)
LOG.info("Attempting to insert ${stepsRecordList.size} StepsRecord(s) for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.")
for (chunk in stepsRecordList.chunked(HealthConnectUtils.CHUNK_SIZE)) {
HealthConnectUtils.insertRecords(chunk, healthConnectClient)
}
LOG.info("Successfully inserted ${stepsRecordList.size} StepsRecord(s) for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.")
return SyncerStatistics(recordsSynced = stepsRecordList.size, recordsSkipped = skippedCount, recordType = "Steps")
}
}
@@ -0,0 +1,56 @@
/* Copyright (C) 2025 Gideon Zenz
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.util.healthconnect.syncers
import androidx.health.connect.client.records.StepsRecord
import androidx.health.connect.client.records.metadata.Metadata
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.time.Instant
import java.time.ZoneOffset
import java.time.temporal.ChronoUnit
import kotlin.reflect.KClass
internal object StepsSyncer : AbstractActivitySampleSyncer<StepsRecord>() {
override val logger: Logger = LoggerFactory.getLogger(StepsSyncer::class.java)
override val recordClass: KClass<StepsRecord> = StepsRecord::class
override fun convertSample(
sample: ActivitySample,
offset: ZoneOffset,
metadata: Metadata,
deviceName: String
): StepsRecord? {
val stepsInMinute = sample.steps.toLong()
if (stepsInMinute <= 0) {
return null
}
val endTs = Instant.ofEpochSecond(sample.timestamp.toLong())
val startTs = endTs.minus(1, ChronoUnit.MINUTES)
return StepsRecord(
startTs,
offset,
endTs,
offset,
stepsInMinute,
metadata
)
}
}