android-health-connect-sync-debug
Diagnose Android Health Connect reads that return zeros, "unavailable", or step counts that trail the phone's own tally — covers the LocalDateTime aggregation trap, one-denied-permission-kills-all-met
SKILL.mdskills/managed/android-health-connect-sync-debug/SKILL.md
GitHubHealth Connect sync debugging
Failure mode: the sync "succeeds", writes N rows, and every metric is zero — or the card says "unavailable" forever. The cause is almost never the code path you are staring at. Work the checklist in order; each step is a one-command check.
0. Make the sync report why, before fixing anything
Never let a read collapse into a bare count or null. A result type that carries
diagnostics turns a week of guessing into one screenshot:
data class HistoryRead(
val days: List<HealthDay> = emptyList(),
val coverage: Map<String, Int> = emptyMap(), // days each metric filled
val problems: List<String> = emptyList(), // "$metric: ${e.javaClass.simpleName} ${e.message}"
)
Surface problems verbatim in the UI. Every later step below was found this way,
not by reasoning.
1. Period aggregation rejects Instant ranges
aggregateGroupByPeriod with an Instant-based TimeRangeFilter throws:
IllegalArgumentException: Either use TimeRangeFilter with LocalDateTime or AggregateGroupByDurationRequest
Silently caught, this zeroes every day while a plain aggregate() (snapshot
totals) still works — so "today's steps" looks fine and history is empty.
val fromLocal = start.atStartOfDay() // LocalDateTime for grouped aggregation
val toLocal = end.plusDays(1).atStartOfDay()
timeRangeFilter = TimeRangeFilter.between(fromLocal, toLocal)
Keep Instant ranges for readRecords (sleep sessions, HR samples) — those are fine.
2. One denied permission kills every metric in the request
Metrics batched into a single AggregateGroupByPeriodRequest fail as a unit. A
missing calories grant therefore reports as a steps failure. Issue one request
per metric so a denial degrades only itself:
suspend fun <T : Any> daily(label: String, metric: AggregateMetric<T>): Map<LocalDate, T> =
runCatching { /* aggregateGroupByPeriod(setOf(metric), ...) */ }
.onFailure { note(label, it) }
.getOrDefault(emptyMap())
3. Manifest and request-set parity
Every record type needs BOTH, or reads return empty with no error:
<uses-permission android:name="android.permission.health.READ_X" />in the manifestHealthPermission.getReadPermission(XRecord::class)in the set passed to the permission launcher
Easy to add one and forget the other. Check parity directly:
rtk grep -n "permission.health" app/src/main/AndroidManifest.xml
rtk grep -n "getReadPermission" <the screen that launches the request>
Grant them for testing without touching the UI:
adb shell pm grant <pkg> android.permission.health.READ_DISTANCE
adb shell dumpsys package <pkg> | rtk grep "permission.health" # granted=true?
4. Two manifest entries that masquerade as "not installed"
<queries>for the provider — without itgetSdkStatus()cannot see Health Connect and always returns unavailable:
<queries>
<package android:name="com.google.android.apps.healthdata" />
<intent><action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" /></intent>
</queries>
VIEW_PERMISSION_USAGErationale activity — Android 14+ rejects every read withIllegalStateException: Incorrect health permission statewithout it, even when permissions are granted:
<activity-alias android:name="ViewPermissionUsageActivity" android:targetActivity=".MainActivity"
android:exported="true" android:permission="android.permission.START_VIEW_PERMISSION_USAGE">
<intent-filter>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
</intent-filter>
</activity-alias>
Also distinguish SDK_UNAVAILABLE from SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED
— collapsing both into "unavailable" hides a fixable state.
5. Never fabricate rows for empty days
Writing a row per requested day makes the sync claim "90 days synced" while every screen shows nothing. Keep only days with real signal, and purge legacy junk:
DELETE FROM health_days WHERE steps = 0 AND distanceKm = 0
AND activeKcal = 0 AND sleepMinutes = 0 AND restingHr IS NULL
6. Counts that trail the phone's own tally
Providers (Samsung Health especially) push in batches — a total an hour behind is upstream lag, not a bug. Prove it instead of arguing: read the raw records and report provenance.
val raw = client.readRecords(ReadRecordsRequest(StepsRecord::class, ...)).records
val newest = raw.maxOfOrNull { it.endTime } // e.g. 10:48 when it is 12:20
val sources = raw.map { it.metadata.dataOrigin.packageName }.distinct()
Then label the UI "as of HH:mm" so the number never claims to be live.
7. Missing metric = check the source before writing code
Widening a lookback window cannot invent data. If distance/calories/sleep/weight
read zero while steps work, the provider is not sharing those types. Confirm in
the Health Connect app (Data and access → the type), or ship the per-metric
coverage map and read it on-device. Direct the user to the provider's own sync
toggles; no app-side change fixes an empty source.
Device verification loop
UI dumps beat vision guesses for coordinates; navigate before driving:
adb shell uiautomator dump /sdcard/ui.xml && adb shell cat /sdcard/ui.xml > .tmp/ui.xml
# find a control by text, tap its bounds centre, re-dump, read the result text
Confirm in this order: the metric coverage line → days written → the screens that consume the data (charts, achievements). Force-stop and relaunch between installs; a tap sent during app restart is swallowed silently.