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
This commit is contained in:
Gideon Zenz
2026-01-16 22:12:23 +01:00
committed by José Rebelo
parent 4ee7007c61
commit 46925c2dde
3 changed files with 195 additions and 19 deletions
+3
View File
@@ -110,6 +110,7 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- Needed for Health Connect See this for more: https://developer.android.com/health-and-fitness/guides/health-connect/plan/data-types#alpha10 -->
<uses-permission android:name="android.permission.health.WRITE_ACTIVE_CALORIES_BURNED"/>
<uses-permission android:name="android.permission.health.WRITE_HEART_RATE"/>
<uses-permission android:name="android.permission.health.WRITE_STEPS"/>
<uses-permission android:name="android.permission.health.WRITE_EXERCISE"/>
@@ -129,6 +130,8 @@
<uses-permission android:name="android.permission.health.WRITE_RESPIRATORY_RATE" />
<uses-permission android:name="android.permission.health.WRITE_RESTING_HEART_RATE" />
<uses-permission android:name="android.permission.health.WRITE_BLOOD_GLUCOSE" />
<uses-permission android:name="android.permission.health.WRITE_STEPS_CADENCE" />
<uses-permission android:name="android.permission.health.WRITE_CYCLING_PEDALING_CADENCE" />
<uses-sdk tools:overrideLibrary="androidx.health.connect.client"/>
<uses-feature
@@ -90,9 +90,13 @@ object HealthConnectPermissionManager {
HealthPermission.getWritePermission(DistanceRecord::class),
HealthPermission.getWritePermission(HeartRateRecord::class),
HealthPermission.getWritePermission(TotalCaloriesBurnedRecord::class),
HealthPermission.getWritePermission(ActiveCaloriesBurnedRecord::class),
HealthPermission.getWritePermission(ElevationGainedRecord::class),
HealthPermission.getWritePermission(SpeedRecord::class),
HealthPermission.getWritePermission(PowerRecord::class),
HealthPermission.getWritePermission(StepsRecord::class),
HealthPermission.getWritePermission(StepsCadenceRecord::class),
HealthPermission.getWritePermission(CyclingPedalingCadenceRecord::class),
PERMISSION_WRITE_EXERCISE_ROUTE
)
}
@@ -21,6 +21,8 @@ import android.content.Context
import androidx.health.connect.client.HealthConnectClient
import androidx.health.connect.client.permission.HealthPermission
import androidx.health.connect.client.permission.HealthPermission.Companion.PERMISSION_WRITE_EXERCISE_ROUTE
import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord
import androidx.health.connect.client.records.CyclingPedalingCadenceRecord
import androidx.health.connect.client.records.DistanceRecord
import androidx.health.connect.client.records.ElevationGainedRecord
import androidx.health.connect.client.records.ExerciseRoute
@@ -29,6 +31,8 @@ import androidx.health.connect.client.records.HeartRateRecord
import androidx.health.connect.client.records.PowerRecord
import androidx.health.connect.client.records.Record
import androidx.health.connect.client.records.SpeedRecord
import androidx.health.connect.client.records.StepsCadenceRecord
import androidx.health.connect.client.records.StepsRecord
import androidx.health.connect.client.records.TotalCaloriesBurnedRecord
import androidx.health.connect.client.records.metadata.Metadata
import androidx.health.connect.client.units.Energy
@@ -158,6 +162,7 @@ internal object RecordedWorkoutSyncer {
}
}
processAggregateWorkout(
gbDevice,
workout,
workoutStartInstant,
workoutEndInstant,
@@ -325,12 +330,15 @@ internal object RecordedWorkoutSyncer {
val summaryData = parseSummaryData(workout.summaryData)
if (summaryData != null) {
addDistanceRecord(summaryData, workoutStartInstant, workoutEndInstant, offset, metadata, grantedPermissions, recordsToInsert, deviceName)
addCaloriesRecord(summaryData, workoutStartInstant, workoutEndInstant, offset, metadata, grantedPermissions, recordsToInsert, deviceName)
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, exerciseType, workoutStartInstant, workoutEndInstant, offset, metadata, grantedPermissions, recordsToInsert, deviceName)
}
}
private fun processAggregateWorkout(
gbDevice: GBDevice,
workout: BaseActivitySummary,
workoutStartInstant: Instant,
workoutEndInstant: Instant,
@@ -355,13 +363,39 @@ internal object RecordedWorkoutSyncer {
)
)
val summaryData = parseSummaryData(workout.summaryData)
// Parse summary data - if JSON is empty, try to parse from raw binary data
var summaryData = parseSummaryData(workout.summaryData)
if (summaryData == null && workout.rawSummaryData != null) {
LOG.debug("SUMMARY_DATA is empty for device '{}', parsing from RAW_SUMMARY_DATA", deviceName)
try {
val coordinator = gbDevice.deviceCoordinator
val activitySummaryParser = coordinator.getActivitySummaryParser(gbDevice, context)
if (activitySummaryParser != null) {
activitySummaryParser.parseBinaryData(workout, false)
summaryData = parseSummaryData(workout.summaryData)
if (summaryData != null) {
LOG.debug("Successfully parsed RAW_SUMMARY_DATA for device '{}'", deviceName)
} else {
LOG.warn("Failed to parse RAW_SUMMARY_DATA for device '{}': summaryData still null after parsing", deviceName)
}
} else {
LOG.warn("No ActivitySummaryParser available for device '{}'", deviceName)
}
} catch (e: Exception) {
LOG.error("Error parsing RAW_SUMMARY_DATA for device '{}'", deviceName, e)
}
}
if (summaryData != null) {
addDistanceRecord(summaryData, workoutStartInstant, workoutEndInstant, offset, metadata, grantedPermissions, recordsToInsert, deviceName)
addHeartRateRecord(summaryData, workoutStartInstant, workoutEndInstant, offset, metadata, grantedPermissions, recordsToInsert, deviceName)
addSpeedRecord(summaryData, workoutStartInstant, workoutEndInstant, offset, metadata, grantedPermissions, recordsToInsert, deviceName)
addCaloriesRecord(summaryData, workoutStartInstant, workoutEndInstant, offset, metadata, grantedPermissions, recordsToInsert, deviceName)
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, exerciseType, workoutStartInstant, workoutEndInstant, offset, metadata, grantedPermissions, recordsToInsert, deviceName)
} else {
LOG.warn("No summary data available for workout on device '{}' at {}", deviceName, workoutStartInstant)
}
}
@@ -617,7 +651,7 @@ internal object RecordedWorkoutSyncer {
}
}
private fun addCaloriesRecord(
private fun addCaloriesRecords(
summaryData: ActivitySummaryData,
startTime: Instant,
endTime: Instant,
@@ -627,25 +661,61 @@ internal object RecordedWorkoutSyncer {
recordsToInsert: MutableList<Record>,
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<String>,
recordsToInsert: MutableList<Record>,
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<String>,
recordsToInsert: MutableList<Record>,
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)
}
}
}
}