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 + ) + } +}