feat(apple): hourly step import (v26) — per-hour iPhone steps with 90-day backfill#369
feat(apple): hourly step import (v26) — per-hour iPhone steps with 90-day backfill#369vishk23 wants to merge 4 commits into
Conversation
…-day backfill The Apple Health importer flattened steps to one daily total, so a phone that died mid-hike just read as a low/zero day instead of showing which hours had no data. HealthKit retains hourly statistics historically, so this adds a per-hour store (appleStepHour, PK deviceId+ts) and wires an hourly HKStatisticsCollectionQuery alongside the existing daily collector, with a one-time 90-day backfill on first run so past days answer retroactively too. A transient HealthKit query error now throws instead of silently succeeding, so the backfill flag is only set after a real collection completes — an HK error retries on the next sync instead of permanently losing the 90-day window. Migration registers as v26-apple-step-hour (the next open slot on this repo's migrator) with options: [.ifNotExists] on the table create — forks/sideloads that already carry this table under a different migration identifier converge cleanly instead of failing the migrator.
|
Really clean instrument-first PR — additive One robustness nit on the backfill flag — it closes the error path but not the arguably-more-common empty path: This bridge treats a successful auth request as if !hourlySteps.isEmpty { try await store.upsertAppleStepHours(hourlySteps, …) }
if !hourlyStepsBackfilled { UserDefaults.standard.set(true, forKey: …hourlyStepsBackfilledKey) } // sets even when emptySo: first sync with step-read declined → empty → flag set → user later grants step access → the 90-day hourly backfill is permanently skipped (forward data only). Users grant Health scopes incrementally, so this deny-then-grant path is easy to hit — and it silently loses exactly the historical hours the backfill exists to capture. Suggested 1-line guard — only mark backfilled once real hourly data actually lands: if !hourlySteps.isEmpty {
try await store.upsertAppleStepHours(hourlySteps, deviceId: appleDeviceId)
if !hourlyStepsBackfilled { UserDefaults.standard.set(true, forKey: HealthKitBridge.hourlyStepsBackfilledKey) }
}Trade-off: a genuinely step-less 90-day HealthKit (strap-only user who never carries a phone) would re-scan each sync until some step exists — cheap, bounded, and self-healing, and far better than losing the backfill for deny-then-grant. Since the flag lives in app-target HealthKit code (not unit-testable, rests on the on-device run), erring conservative is worth it. Not blocking given it's instrumentation with no consumer yet — but worth folding in now or as a fast follow, since the whole value of the one-time widen is the historical hours. |
HealthKit returns empty (not an error) when step read-access is denied, so setting the flag unconditionally after a no-throw collection burned the one-time 90-day backfill in a deny-then-grant sequence. Gate the flag-set inside the non-empty branch: a later grant still gets the historical widen, and a genuinely step-less user just re-scans each sync — cheap, bounded, self-healing.
|
Folded in (ff42c13). The flag-set now lives inside the Verified compile-only (this is app-target code CI doesn't build): |
Adds the Room migration (v18 -> v19) + @entity for the appleStepHour table, the Android twin of the Swift WhoopStore v26-apple-step-hour GRDB migration. Column order (deviceId, ts, steps) and PK (deviceId, ts) match the GRDB schema so the .noopbak schema stays byte-identical, pinned by AppleStepHourMigrationTest. SCHEMA-ONLY: the hourly-step import is Apple-Health-sourced (iOS-only; HealthKit has no Android analogue), so no import code is ported — Android carries the table for backup parity but no importer writes to it.
…ream Upstream took every slot this branch had claimed, on both platforms. Four conflicts, all schema-numbering, resolved by moving this PR's migration to the end of each chain — the table itself, its SQL, and its tests are unchanged. - GRDB: `v26-apple-step-hour` → **`v31-apple-step-hour`**. Upstream now holds v26 (efficiency-heal), v27 (ppg-waveform), v28 (raw-imu), v29 (score-input-provenance), v30 (rr-ord). - Room: `MIGRATION_18_19` → **`MIGRATION_24_25`**, database `version` 19 → 25. Upstream is at version 24 / `MIGRATION_23_24`; `MIGRATION_24_25` is appended to `addMigrations(...)` and `AppleStepHour::class` re-added to the entity list alongside upstream's `PpgWaveformSampleEntity` / `RawImuSampleEntity`. - `DeviceRegistryStore.deviceScopedTables` and `Entities.kt` were additive collisions — upstream's new tables and `appleStepHour` all kept. - `AppleStepHourMigrationTest` version-pair assertions updated to 24→25. NOTE: PR #475 currently also claims GRDB v31 (`v31-daily-avg-sdnn`). GRDB keys migrations by name so both would apply correctly in either order, but whichever of the two lands second should bump to v32 to keep the numbering honest. Verified: WhoopStore 299/299 (incl. the 3 AppleStepHourStore tests); Android `./gradlew testFullDebugUnitTest` 3064 tests, only the pre-existing `SyncChipStateTest.lastSyncedAt_takesPriorityOverHistorySync` failure (it fails on clean main too).
|
Rebuilt on current
Heads-up for whoever lands these: #475 currently also claims GRDB v31 ( The review nit from July — only setting the backfill flag once hourly rows actually land, so a deny-then-grant HealthKit sequence keeps the 90-day widen — is in the branch already ( Verified: |
What. The Apple Health import currently flattens iPhone steps to one daily total. This adds an hourly step import alongside it: migration
v26-apple-step-hour(additive tableappleStepHour(deviceId, ts hour-start, steps)), anHKStatisticsCollectionQueryat hour interval over the same sync window (same anchor, auth checks, and error tolerance as the existing dailycollect()), and a one-time 90-day historical backfill (UserDefaults-gated, set only after a successful collection — a HealthKit query error throws into the existing catch and retries next sync, so a transient failure can't permanently skip the backfill).Why. Intra-day phone-vs-strap comparison answers questions daily totals can't: when was the phone actually recording (dead battery windows show as missing hours), how much of a wrist-count gap is arm-motion inflation vs. the phone simply not being carried, and hour-level activity timelines generally. Verified on a real device: a 90-day backfill landed, and an hour-by-hour overlay against strap step ticks cleanly isolated a mid-hike dead-phone window and quantified wrist-vs-pocket divergence per hour.
Scope. Plumbing + tests only — no UI reads the table yet (same precedent as the WHOOP 5 event-log capture, #346). Store API (
upsertAppleStepHours/appleStepHours) mirrors theOuraRawStoreextension style;appleStepHouris listed indeviceScopedTables(wiped with the device's data). Android: Kotlin schema twin included + gradle-tested. The Room migration (v18→v19) +AppleStepHour@entity mirror theappleStepHourGRDB table (columnsdeviceId, ts, steps; PK(deviceId, ts); column order == entity field order) so the.noopbakschema stays byte-identical, pinned byAppleStepHourMigrationTest. No import code is ported: the hourly-step collection is the iOS-only live-HealthKit path (HealthKit has no Android analogue) and the file-based importer both platforms share is untouched../gradlew :app:testFullDebugUnitTestgreen.Tests/verification. WhoopStore package suite green including 3 new store tests (idempotent upsert by natural key, range read) and the device-scoped-tables invariant; both app targets build. The backfill-flag error path is the second commit — a review caught that a swallowed HealthKit error could set the done-flag with zero rows persisted;
collectHourlyStepsnow throws on a real query error so the flag only sets after success.Note: the migration uses ifNotExists so existing sideload databases that already carry this table converge cleanly.