From 46925c2dde1d5e029c24f4eb64ae0b75242248eb Mon Sep 17 00:00:00 2001 From: Gideon Zenz Date: Sat, 10 Jan 2026 22:50:52 +0100 Subject: [PATCH] Add missing workout data sync: active calories, steps, and cadence - Fix calorie mapping: caloriesBurnt represents active calories, not total calories - Parse RAW_SUMMARY_DATA when SUMMARY_DATA JSON is empty (fixes Amazfit/ZeppOS devices) - Use device-specific ActivitySummaryParser to extract workout metrics from binary protobuf data - Sync ActiveCaloriesBurnedRecord separately from total calories - Add StepsRecord for step count during workouts - Add intelligent cadence syncing (StepsCadenceRecord for running/walking, CyclingPedalingCadenceRecord for cycling) - Add required Health Connect permissions to manifest - Update HealthConnectPermissionManager with all workout permissions --- app/src/main/AndroidManifest.xml | 3 + .../HealthConnectPermissionManager.kt | 4 + .../syncers/RecordedWorkoutSyncer.kt | 207 ++++++++++++++++-- 3 files changed, 195 insertions(+), 19 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3f9985bb18..9539a1e01c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -110,6 +110,7 @@ + @@ -129,6 +130,8 @@ + + , deviceName: String ) { - val caloriesPermission = HealthPermission.getWritePermission(TotalCaloriesBurnedRecord::class) + // Most fitness trackers report "caloriesBurnt" as active calories (exercise calories) + // Try dedicated active_calories field first, fall back to caloriesBurnt + val activeCaloriesPermission = HealthPermission.getWritePermission(ActiveCaloriesBurnedRecord::class) + if (activeCaloriesPermission in grantedPermissions) { + var activeCalories = summaryData.getNumber(ActivitySummaryEntries.CALORIES_ACTIVE, 0.0).toDouble() - if (caloriesPermission !in grantedPermissions) { - return + // If no dedicated active_calories field, use caloriesBurnt (which represents active calories for most devices) + if (activeCalories == 0.0) { + activeCalories = summaryData.getNumber(ActivitySummaryEntries.CALORIES_BURNT, 0.0).toDouble() + } + + if (activeCalories > 0) { + recordsToInsert.add( + ActiveCaloriesBurnedRecord( + startTime = startTime, + startZoneOffset = offset, + endTime = endTime, + endZoneOffset = offset, + energy = Energy.kilocalories(activeCalories), + metadata = metadata + ) + ) + LOG.debug("Added ActiveCaloriesBurnedRecord ({} kcal) for workout at {} for device '{}'.", activeCalories, startTime, deviceName) + } else { + LOG.debug("No active calories data in workout summary for device '{}' at {}.", deviceName, startTime) + } + } else { + LOG.debug("Permission for ActiveCaloriesBurnedRecord not granted for device '{}'.", deviceName) } - val caloriesBurnt = summaryData.getNumber(ActivitySummaryEntries.CALORIES_BURNT, 0.0) - if (caloriesBurnt.toDouble() > 0) { - recordsToInsert.add( - TotalCaloriesBurnedRecord( - startTime = startTime, - startZoneOffset = offset, - endTime = endTime, - endZoneOffset = offset, - energy = Energy.kilocalories(caloriesBurnt.toDouble()), - metadata = metadata + // Only sync TotalCaloriesBurnedRecord if we have both active AND resting calories + // Otherwise, syncing the same value as both active and total would be misleading + val totalCaloriesPermission = HealthPermission.getWritePermission(TotalCaloriesBurnedRecord::class) + if (totalCaloriesPermission in grantedPermissions) { + val activeCalories = summaryData.getNumber(ActivitySummaryEntries.CALORIES_ACTIVE, 0.0).toDouble() + val restingCalories = summaryData.getNumber(ActivitySummaryEntries.CALORIES_RESTING, 0.0).toDouble() + + if (activeCalories > 0 && restingCalories > 0) { + val totalCalories = activeCalories + restingCalories + recordsToInsert.add( + TotalCaloriesBurnedRecord( + startTime = startTime, + startZoneOffset = offset, + endTime = endTime, + endZoneOffset = offset, + energy = Energy.kilocalories(totalCalories), + metadata = metadata + ) ) - ) - LOG.debug("Added TotalCaloriesBurnedRecord ({} kcal) for workout at {} for device '{}'.", caloriesBurnt, startTime, deviceName) + LOG.debug("Added TotalCaloriesBurnedRecord ({} kcal = {} active + {} resting) for workout at {} for device '{}'.", + totalCalories, activeCalories, restingCalories, startTime, deviceName) + } else { + LOG.debug("Not syncing TotalCaloriesBurnedRecord - need both active and resting calories (have: active={}, resting={}) for device '{}'", + activeCalories, restingCalories, deviceName) + } } } @@ -687,5 +757,104 @@ internal object RecordedWorkoutSyncer { LOG.debug("Added ElevationGainedRecord ({} m) for workout at {} for device '{}'.", elevationGain, startTime, deviceName) } } + + private fun addStepsRecord( + summaryData: ActivitySummaryData, + startTime: Instant, + endTime: Instant, + offset: ZoneOffset, + metadata: Metadata, + grantedPermissions: Set, + recordsToInsert: MutableList, + deviceName: String + ) { + val stepsPermission = HealthPermission.getWritePermission(StepsRecord::class) + if (stepsPermission !in grantedPermissions) { + return + } + + val steps = summaryData.getNumber(ActivitySummaryEntries.STEPS, 0.0).toLong() + if (steps > 0) { + recordsToInsert.add( + StepsRecord( + startTime = startTime, + startZoneOffset = offset, + endTime = endTime, + endZoneOffset = offset, + count = steps, + metadata = metadata + ) + ) + LOG.debug("Added StepsRecord ({} steps) for workout at {} for device '{}'.", steps, startTime, deviceName) + } + } + + private fun addCadenceRecords( + summaryData: ActivitySummaryData, + exerciseType: Int, + startTime: Instant, + endTime: Instant, + offset: ZoneOffset, + metadata: Metadata, + grantedPermissions: Set, + recordsToInsert: MutableList, + deviceName: String + ) { + val cadenceAvg = summaryData.getNumber(ActivitySummaryEntries.CADENCE_AVG, 0.0) + if (cadenceAvg.toDouble() <= 0) { + return + } + + // Determine if this is a cycling or walking/running workout + val isCyclingWorkout = when (exerciseType) { + ExerciseSessionRecord.EXERCISE_TYPE_BIKING, + ExerciseSessionRecord.EXERCISE_TYPE_BIKING_STATIONARY -> true + else -> false + } + + if (isCyclingWorkout) { + val cyclingCadencePermission = HealthPermission.getWritePermission(CyclingPedalingCadenceRecord::class) + if (cyclingCadencePermission in grantedPermissions) { + val midTime = startTime.plusSeconds((endTime.epochSecond - startTime.epochSecond) / 2) + recordsToInsert.add( + CyclingPedalingCadenceRecord( + startTime = midTime, + startZoneOffset = offset, + endTime = midTime, + endZoneOffset = offset, + samples = listOf( + CyclingPedalingCadenceRecord.Sample( + time = midTime, + revolutionsPerMinute = cadenceAvg.toDouble() + ) + ), + metadata = metadata + ) + ) + LOG.debug("Added CyclingPedalingCadenceRecord (avg: {} rpm) for workout at {} for device '{}'.", cadenceAvg, startTime, deviceName) + } + } else { + val stepsCadencePermission = HealthPermission.getWritePermission(StepsCadenceRecord::class) + if (stepsCadencePermission in grantedPermissions) { + val midTime = startTime.plusSeconds((endTime.epochSecond - startTime.epochSecond) / 2) + recordsToInsert.add( + StepsCadenceRecord( + startTime = midTime, + startZoneOffset = offset, + endTime = midTime, + endZoneOffset = offset, + samples = listOf( + StepsCadenceRecord.Sample( + time = midTime, + rate = cadenceAvg.toDouble() + ) + ), + metadata = metadata + ) + ) + LOG.debug("Added StepsCadenceRecord (avg: {} steps/min) for workout at {} for device '{}'.", cadenceAvg, startTime, deviceName) + } + } + } }