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