From 8c575535b21ae75a7114300c927b195c1885bd56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Rebelo?= Date: Mon, 26 Jan 2026 23:32:47 +0000 Subject: [PATCH] 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. --- .../util/healthconnect/HealthConnectUtils.kt | 6 + .../syncers/AbstractActivitySampleSyncer.kt | 120 ++++++++++++++++++ .../syncers/ActiveCaloriesSyncer.kt | 57 +++++++++ .../syncers/RecordedWorkoutSyncer.kt | 11 +- .../util/healthconnect/syncers/StepsSync.kt | 106 ---------------- .../util/healthconnect/syncers/StepsSyncer.kt | 56 ++++++++ 6 files changed, 247 insertions(+), 109 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/AbstractActivitySampleSyncer.kt create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/ActiveCaloriesSyncer.kt delete mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/StepsSync.kt create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/StepsSyncer.kt diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/HealthConnectUtils.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/HealthConnectUtils.kt index abd0df242b..82185fe3be 100755 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/HealthConnectUtils.kt +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/HealthConnectUtils.kt @@ -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 -> { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/AbstractActivitySampleSyncer.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/AbstractActivitySampleSyncer.kt new file mode 100644 index 0000000000..91f43480d5 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/AbstractActivitySampleSyncer.kt @@ -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 . */ +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 : ActivitySampleSyncer { + protected abstract val logger: Logger + protected abstract val recordClass: KClass + + 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, + deviceSamples: List + ): 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() + 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 + ) + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/ActiveCaloriesSyncer.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/ActiveCaloriesSyncer.kt new file mode 100644 index 0000000000..a1c0fb4bb3 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/ActiveCaloriesSyncer.kt @@ -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 . */ +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() { + override val logger: Logger = LoggerFactory.getLogger(StepsSyncer::class.java) + override val recordClass: KClass = 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 + ) + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/RecordedWorkoutSyncer.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/RecordedWorkoutSyncer.kt index da8fb50c88..c93a29e65a 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/RecordedWorkoutSyncer.kt +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/RecordedWorkoutSyncer.kt @@ -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, recordsToInsert: MutableList, - 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) diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/StepsSync.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/StepsSync.kt deleted file mode 100644 index 7970b6ee09..0000000000 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/StepsSync.kt +++ /dev/null @@ -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 . */ -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, - deviceSamples: List - ): 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() - 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") - } -} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/StepsSyncer.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/StepsSyncer.kt new file mode 100644 index 0000000000..0ab3b2a530 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/healthconnect/syncers/StepsSyncer.kt @@ -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 . */ +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() { + override val logger: Logger = LoggerFactory.getLogger(StepsSyncer::class.java) + override val recordClass: KClass = 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 + ) + } +}