From 2b7ee55e512d2275dda091e3657710fcae9f794b Mon Sep 17 00:00:00 2001 From: vishk23 <119831996+vishk23@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:35:21 -0700 Subject: [PATCH 1/3] =?UTF-8?q?feat(apple):=20hourly=20step=20import=20(v2?= =?UTF-8?q?6)=20=E2=80=94=20per-hour=20iPhone=20steps=20with=2090-day=20ba?= =?UTF-8?q?ckfill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../WhoopStore/AppleStepHourStore.swift | 42 +++++++++++++ .../Sources/WhoopStore/Database.swift | 22 +++++++ .../WhoopStore/DeviceRegistryStore.swift | 4 ++ .../AppleStepHourStoreTests.swift | 56 +++++++++++++++++ StrandiOS/Health/HealthKitBridge.swift | 60 +++++++++++++++++++ 5 files changed, 184 insertions(+) create mode 100644 Packages/WhoopStore/Sources/WhoopStore/AppleStepHourStore.swift create mode 100644 Packages/WhoopStore/Tests/WhoopStoreTests/AppleStepHourStoreTests.swift diff --git a/Packages/WhoopStore/Sources/WhoopStore/AppleStepHourStore.swift b/Packages/WhoopStore/Sources/WhoopStore/AppleStepHourStore.swift new file mode 100644 index 000000000..13cf745a8 --- /dev/null +++ b/Packages/WhoopStore/Sources/WhoopStore/AppleStepHourStore.swift @@ -0,0 +1,42 @@ +import Foundation +import GRDB + +// MARK: - v27 store: hourly Apple Health step counts +// Mirrors OuraRawStore: an idempotent ON CONFLICT upsert keyed by the natural key (deviceId, ts), and +// a range read — all GRDB work via syncWrite/syncRead. `appleDaily.steps` already answers "how many +// steps that day"; this table answers "which HOURS were recorded", so the UI can show retroactively +// when the iPhone was off/dead/left behind (e.g. mid-hike) instead of a single flattened daily total. + +extension WhoopStore { + + /// Upsert hourly Apple Health step counts. Idempotent by (deviceId, ts): re-importing the same + /// hour overwrites its count rather than duplicating. Returns rows changed. + @discardableResult + public func upsertAppleStepHours(_ rows: [(ts: Int, steps: Int)], deviceId: String) async throws -> Int { + try syncWrite { db in + var n = 0 + for r in rows { + try db.execute(sql: """ + INSERT INTO appleStepHour (deviceId, ts, steps) + VALUES (?, ?, ?) + ON CONFLICT(deviceId, ts) DO UPDATE SET + steps = excluded.steps + """, arguments: [deviceId, r.ts, r.steps]) + n += db.changesCount + } + return n + } + } + + /// Hourly step counts for a device over `[fromTs, toTs]` inclusive, oldest hour first. + public func appleStepHours(deviceId: String, fromTs: Int, toTs: Int) async throws -> [(ts: Int, steps: Int)] { + try syncRead { db in + try Row.fetchAll(db, sql: """ + SELECT ts, steps FROM appleStepHour + WHERE deviceId = ? AND ts >= ? AND ts <= ? + ORDER BY ts ASC + """, arguments: [deviceId, fromTs, toTs]) + .map { (ts: $0["ts"], steps: $0["steps"]) } + } + } +} diff --git a/Packages/WhoopStore/Sources/WhoopStore/Database.swift b/Packages/WhoopStore/Sources/WhoopStore/Database.swift index 58d8e0272..39a25db44 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/Database.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/Database.swift @@ -500,6 +500,28 @@ extension WhoopStore { try db.create(index: "idx_ouraRaw_device_endpoint_day", on: "ouraRaw", columns: ["deviceId", "endpoint", "day"]) } + + // v26-apple-step-hour: hourly Apple Health step counts. The daily `collect(.stepCount, …)` path + // in HealthKitBridge flattens a whole day to one `appleDaily.steps` total, so a dead/absent phone + // for part of a day (e.g. the phone died mid-hike) is invisible — steps just read low for the + // WHOLE day instead of showing exactly which hours had no recording. HealthKit retains hourly + // statistics historically, so this table lets a one-time backfill answer PAST days + // retroactively, not just from the day this migration ships. `ts` is the hour-BUCKET START + // (unix seconds), aligned to local-clock hour boundaries by the `HKStatisticsCollectionQuery` + // anchored at local midnight (see HealthKitBridge); `steps` is the cumulative sum within that + // hour. PK (deviceId, ts) mirrors every other per-sample table (hrSample, stepSample, …) and + // makes the hourly upsert idempotent. Additive only — a NEW table, no existing row touched, old + // readers unaffected. + migrator.registerMigration("v26-apple-step-hour") { db in + // ifNotExists: forks/sideloads that already carry this table under a different migration + // identifier converge cleanly instead of failing the migrator. + try db.create(table: "appleStepHour", options: [.ifNotExists]) { t in + t.column("deviceId", .text).notNull() // "apple-health" + t.column("ts", .integer).notNull() // hour-start unix seconds (local-hour aligned by HK) + t.column("steps", .integer).notNull() + t.primaryKey(["deviceId", "ts"]) + } + } return migrator } } diff --git a/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift b/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift index 2bd7e1edc..67335ba6f 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift @@ -90,6 +90,10 @@ public struct DeviceRegistryStore: Sendable { // v25-oura-raw: the opt-in Oura cloud-import raw archive is deviceId-keyed too, so "delete this // device's data" must clear it — else an imported Oura source's payloads would survive deletion. "ouraRaw", + // v26-apple-step-hour: per-hour Apple Health step counts are deviceId-keyed (apple-health) and + // are real per-device recordings like appleDaily/stepSample, not tombstone-class bookkeeping — + // a device-data wipe / "Remove Apple Health data" must clear them too. + "appleStepHour", ] /// Permanently delete every recorded sample/derived row belonging to one device, across all diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/AppleStepHourStoreTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/AppleStepHourStoreTests.swift new file mode 100644 index 000000000..a3bedb49c --- /dev/null +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/AppleStepHourStoreTests.swift @@ -0,0 +1,56 @@ +import XCTest +import GRDB +@testable import WhoopStore + +final class AppleStepHourStoreTests: XCTestCase { + func testUpsertIsIdempotentByNaturalKey() async throws { + let store = try await WhoopStore.inMemory() + let n1 = try await store.upsertAppleStepHours([(ts: 1_000, steps: 120)], deviceId: "apple-health") + XCTAssertEqual(n1, 1) + + // Re-import the same hour with a revised count → overwrite in place, still one row. + let n2 = try await store.upsertAppleStepHours([(ts: 1_000, steps: 250)], deviceId: "apple-health") + XCTAssertEqual(n2, 1) + + let rows = try await store.appleStepHours(deviceId: "apple-health", fromTs: 0, toTs: 10_000) + XCTAssertEqual(rows.count, 1) + XCTAssertEqual(rows.first?.ts, 1_000) + XCTAssertEqual(rows.first?.steps, 250) + } + + func testRangeReadReturnsOldestFirstWithinBoundsInclusive() async throws { + let store = try await WhoopStore.inMemory() + _ = try await store.upsertAppleStepHours([ + (ts: 3_600, steps: 100), // hour 1 + (ts: 7_200, steps: 200), // hour 2 + (ts: 10_800, steps: 300), // hour 3 + ], deviceId: "apple-health") + + // Inclusive bounds: both edges of the range are included. + let rows = try await store.appleStepHours(deviceId: "apple-health", fromTs: 3_600, toTs: 10_800) + XCTAssertEqual(rows.map(\.ts), [3_600, 7_200, 10_800]) + XCTAssertEqual(rows.map(\.steps), [100, 200, 300]) + + // A narrower window excludes rows outside it. + let narrow = try await store.appleStepHours(deviceId: "apple-health", fromTs: 3_601, toTs: 10_799) + XCTAssertEqual(narrow.map(\.ts), [7_200]) + + // A different device sees no rows. + let other = try await store.appleStepHours(deviceId: "my-whoop", fromTs: 0, toTs: 100_000) + XCTAssertTrue(other.isEmpty) + } + + func testUpsertPartitionsByDevice() async throws { + let store = try await WhoopStore.inMemory() + _ = try await store.upsertAppleStepHours([(ts: 1_000, steps: 50)], deviceId: "apple-health") + _ = try await store.upsertAppleStepHours([(ts: 1_000, steps: 999)], deviceId: "other-device") + + let mine = try await store.appleStepHours(deviceId: "apple-health", fromTs: 0, toTs: 10_000) + XCTAssertEqual(mine.count, 1) + XCTAssertEqual(mine.first?.steps, 50) + + let others = try await store.appleStepHours(deviceId: "other-device", fromTs: 0, toTs: 10_000) + XCTAssertEqual(others.count, 1) + XCTAssertEqual(others.first?.steps, 999) + } +} diff --git a/StrandiOS/Health/HealthKitBridge.swift b/StrandiOS/Health/HealthKitBridge.swift index a3fd7996e..9f11975c1 100644 --- a/StrandiOS/Health/HealthKitBridge.swift +++ b/StrandiOS/Health/HealthKitBridge.swift @@ -298,6 +298,11 @@ final class HealthKitBridge: ObservableObject { // MARK: - Read → store + /// UserDefaults key gating the one-time 90-day hourly-step backfill (see the hourly collection + /// below). Set once the first hourly-step HealthKit query completes; every later `sync()` only + /// walks the same window the daily collectors already use. + private static let hourlyStepsBackfilledKey = "applehealth.hourlySteps.backfilled" + /// Pull the last `days` of Apple Health into the on-device store under the `apple-health` source, /// then write NOOP's own computed metrics back into Health. Safe to call repeatedly (idempotent /// upserts keyed by day). @@ -369,6 +374,16 @@ final class HealthKitBridge: ObservableObject { byDay[day] = a } + // Hourly step counts: `appleDaily.steps` above flattens a whole day to one total, so a + // dead/absent phone for part of a day (e.g. the phone died mid-hike) is invisible. Walk the + // same window at HOURLY granularity into `appleStepHour` so a UI can show exactly which hours + // were recorded. First run ever (UserDefaults flag unset) widens the window to 90 days back — + // HealthKit retains hourly statistics historically, so this answers PAST days retroactively; + // every later sync only re-covers the normal start...end window like the daily collectors above. + let hourlyStepsBackfilled = UserDefaults.standard.bool(forKey: HealthKitBridge.hourlyStepsBackfilledKey) + let hourlyStepsStart: Date = hourlyStepsBackfilled ? start + : (cal.date(byAdding: .day, value: -90, to: cal.startOfDay(for: end)) ?? start) + // Build + upsert the store rows under the apple-health source. let appleRows = byDay.map { (day, a) in AppleDaily(day: day, steps: a.steps.map { Int($0) }, @@ -428,10 +443,18 @@ final class HealthKitBridge: ObservableObject { // lastSync — a false "success", and the next delta sync skipped the window. (Reimplemented // from @vulnix0x4's PR #375.) do { + // Collect hourly steps here (not before) so a transient HealthKit error throws to the + // catch block and prevents the backfill flag from permanently locking out the 90-day window. + let hourlySteps = try await collectHourlySteps(start: hourlyStepsStart, end: end) + try await store.upsertAppleDaily(appleRows, deviceId: appleDeviceId) try await store.upsertDailyMetrics(dmRows, deviceId: appleDeviceId) try await store.upsertMetricSeries(points, deviceId: appleDeviceId) if !workoutRows.isEmpty { try await store.upsertWorkouts(workoutRows, deviceId: appleDeviceId) } + if !hourlySteps.isEmpty { try await store.upsertAppleStepHours(hourlySteps, deviceId: appleDeviceId) } + if !hourlyStepsBackfilled { + UserDefaults.standard.set(true, forKey: HealthKitBridge.hourlyStepsBackfilledKey) + } try await writeBack(whoopStore: store) lastSync = Date() lastError = nil @@ -870,6 +893,43 @@ final class HealthKitBridge: ObservableObject { } } + /// Hourly cumulative step counts over `[start, end)`, mirroring `collect()`'s + /// `HKStatisticsCollectionQuery` shape but bucketed by HOUR instead of day so a backfill can show + /// exactly which hours the phone was recording. `anchorDate` is local midnight — same anchor + /// `collect()` uses for its daily buckets — so hour buckets fall on local-clock hour boundaries, + /// not UTC ones. Throws on a HealthKit query error (distinguished from a genuinely-empty 90-day + /// window, which returns zero rows); a transient error will propagate to the sync's existing + /// `catch` block, preventing the backfill flag from permanently locking out the 90-day window. + private func collectHourlySteps(start: Date, end: Date) async throws -> [(ts: Int, steps: Int)] { + guard let type = HKQuantityType.quantityType(forIdentifier: .stepCount) else { return [] } + let cal = Calendar.current + let anchor = cal.startOfDay(for: start) + let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + HKQuery.predicateForSamples(withStart: start, end: end, options: .strictStartDate), + Self.notNoopAuthored, + ]) + return try await withCheckedThrowingContinuation { (cont: CheckedContinuation<[(ts: Int, steps: Int)], Error>) in + let q = HKStatisticsCollectionQuery(quantityType: type, quantitySamplePredicate: predicate, + options: .cumulativeSum, anchorDate: anchor, + intervalComponents: DateComponents(hour: 1)) + q.initialResultsHandler = { _, results, error in + if let error { + cont.resume(throwing: error) + return + } + var rows: [(ts: Int, steps: Int)] = [] + results?.enumerateStatistics(from: start, to: end) { stats, _ in + if let sum = stats.sumQuantity() { + let steps = Int(sum.doubleValue(for: .count()).rounded()) + rows.append((ts: Int(stats.startDate.timeIntervalSince1970), steps: steps)) + } + } + cont.resume(returning: rows) + } + store.execute(q) + } + } + // MARK: - Workouts (#835) /// Read the workouts the user logged in Apple Health over `[start, end)` and map each to a From ff42c136dc8c53b2d0e3d95c50fef3d6ef0a2a1a Mon Sep 17 00:00:00 2001 From: vishk23 <119831996+vishk23@users.noreply.github.com> Date: Mon, 13 Jul 2026 08:41:05 -0700 Subject: [PATCH 2/3] fix(ios): only set hourly-steps backfill flag when backfill returns data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- StrandiOS/Health/HealthKitBridge.swift | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/StrandiOS/Health/HealthKitBridge.swift b/StrandiOS/Health/HealthKitBridge.swift index 9f11975c1..6641dabf8 100644 --- a/StrandiOS/Health/HealthKitBridge.swift +++ b/StrandiOS/Health/HealthKitBridge.swift @@ -451,9 +451,16 @@ final class HealthKitBridge: ObservableObject { try await store.upsertDailyMetrics(dmRows, deviceId: appleDeviceId) try await store.upsertMetricSeries(points, deviceId: appleDeviceId) if !workoutRows.isEmpty { try await store.upsertWorkouts(workoutRows, deviceId: appleDeviceId) } - if !hourlySteps.isEmpty { try await store.upsertAppleStepHours(hourlySteps, deviceId: appleDeviceId) } - if !hourlyStepsBackfilled { - UserDefaults.standard.set(true, forKey: HealthKitBridge.hourlyStepsBackfilledKey) + // Only mark the backfill done once hourly data actually lands. HealthKit returns EMPTY + // (not an error) when step read-access is denied, and users grant Health scopes + // incrementally — so gating on non-empty, not just no-throw, stops a deny-then-grant + // sequence from burning the one-time 90-day widen before the user ever authorizes steps. + // A genuinely step-less window just re-scans next sync: cheap, bounded, self-healing. + if !hourlySteps.isEmpty { + try await store.upsertAppleStepHours(hourlySteps, deviceId: appleDeviceId) + if !hourlyStepsBackfilled { + UserDefaults.standard.set(true, forKey: HealthKitBridge.hourlyStepsBackfilledKey) + } } try await writeBack(whoopStore: store) lastSync = Date() From c3d7e9eaddf93791185d5ea10954d599d3ba198d Mon Sep 17 00:00:00 2001 From: vishk23 <119831996+vishk23@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:34:45 -0700 Subject: [PATCH 3/3] feat(android): Kotlin twin of appleStepHour schema (parity with PR #369) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/main/java/com/noop/data/Entities.kt | 21 ++++++ .../main/java/com/noop/data/WhoopDatabase.kt | 30 ++++++++- .../noop/data/AppleStepHourMigrationTest.kt | 65 +++++++++++++++++++ 3 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 android/app/src/test/java/com/noop/data/AppleStepHourMigrationTest.kt diff --git a/android/app/src/main/java/com/noop/data/Entities.kt b/android/app/src/main/java/com/noop/data/Entities.kt index abd17cb9a..869318d26 100644 --- a/android/app/src/main/java/com/noop/data/Entities.kt +++ b/android/app/src/main/java/com/noop/data/Entities.kt @@ -478,6 +478,27 @@ data class AppleDaily( val weightKg: Double? = null, ) +/** + * Cached hourly Apple-Health step count (v26 / MIGRATION_18_19). Swift `appleStepHour` + * (WhoopStore Database.swift v26-apple-step-hour migration). `ts` is the hour-BUCKET START + * (wall-clock unix seconds, local-hour aligned by the HealthKit collection query); `steps` is the + * cumulative step sum within that hour. Natural key (deviceId, ts) mirrors every other per-sample + * table so the hourly upsert is idempotent. `appleDaily.steps` answers "how many steps that day"; + * this table answers "which HOURS were recorded", so a dead/absent phone for part of a day is + * visible instead of a single flattened daily total. + * + * Fields are declared in the SAME order as the Swift GRDB schema (deviceId, ts, steps) so the + * migration's CREATE TABLE column order matches Room's generated shape. The Apple-Health IMPORT that + * populates it is iOS-only (HealthKit has no Android analogue), so Android carries the SCHEMA for + * .noopbak byte-parity but no importer writes to it. + */ +@Entity(tableName = "appleStepHour", primaryKeys = ["deviceId", "ts"]) +data class AppleStepHour( + val deviceId: String, + val ts: Long, + val steps: Int, +) + /** * One Live Session (silent guardian) record (v22 / MIGRATION_15_16). Natural key (deviceId, startTs). * `endTs` is null while the session is still in progress. Fields are declared in the SAME order as the diff --git a/android/app/src/main/java/com/noop/data/WhoopDatabase.kt b/android/app/src/main/java/com/noop/data/WhoopDatabase.kt index 332d4c3dd..ee2c5f4f9 100644 --- a/android/app/src/main/java/com/noop/data/WhoopDatabase.kt +++ b/android/app/src/main/java/com/noop/data/WhoopDatabase.kt @@ -47,8 +47,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase DayOwnershipRow::class, LabMarkerRow::class, LiveSessionRow::class, + AppleStepHour::class, ], - version = 18, + version = 19, exportSchema = false, ) abstract class WhoopDatabase : RoomDatabase() { @@ -480,6 +481,32 @@ abstract class WhoopDatabase : RoomDatabase() { } } + /** + * v18 -> v19: ADDITIVE, adds the `appleStepHour` table, the Android twin of the Swift WhoopStore + * `v26-apple-step-hour` migration. Hourly Apple-Health step counts: `appleDaily.steps` flattens a + * whole day to one total, so a dead/absent phone for part of a day is invisible; this per-hour table + * records exactly which hours had a recording. `ts` is the hour-bucket start (unix seconds), `steps` + * the cumulative sum within that hour, composite PK (deviceId, ts) so the hourly upsert is idempotent. + * + * CREATE TABLE only (no existing data touched), so already-offloaded raw streams survive. The SQL MUST + * match Room's generated schema for [AppleStepHour] exactly: deviceId TEXT NOT NULL, ts INTEGER NOT NULL, + * steps INTEGER NOT NULL (all Kotlin non-null, no SQL DEFAULT), composite PRIMARY KEY (deviceId, ts) in + * declaration order. SCHEMA-ONLY twin: the Apple-Health IMPORT that fills it is iOS-only (HealthKit has + * no Android analogue), so Android carries the table for .noopbak byte-parity but no importer writes it. + * No destructive fallback (see the class doc). Exposed as [APPLE_STEP_HOUR_MIGRATION_SQL] so a plain-JVM + * unit test can pin the shape without Robolectric. + */ + internal val APPLE_STEP_HOUR_MIGRATION_SQL: List = listOf( + "CREATE TABLE IF NOT EXISTS `appleStepHour` (`deviceId` TEXT NOT NULL, " + + "`ts` INTEGER NOT NULL, `steps` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `ts`))", + ) + + internal val MIGRATION_18_19 = object : Migration(18, 19) { + override fun migrate(db: SupportSQLiteDatabase) { + for (stmt in APPLE_STEP_HOUR_MIGRATION_SQL) db.execSQL(stmt) + } + } + private fun build(appContext: Context): WhoopDatabase = Room.databaseBuilder(appContext, WhoopDatabase::class.java, DB_NAME) // #1014: replace ONLY the corruption handling of the default open-helper. The @@ -495,6 +522,7 @@ abstract class WhoopDatabase : RoomDatabase() { MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17, MIGRATION_17_18, + MIGRATION_18_19, ) // #1037: a FRESH install builds the schema straight at the current version and runs NO // migrations, so the MIGRATION_7_8 "my-whoop" registry seed never fires and the WHOOP, diff --git a/android/app/src/test/java/com/noop/data/AppleStepHourMigrationTest.kt b/android/app/src/test/java/com/noop/data/AppleStepHourMigrationTest.kt new file mode 100644 index 000000000..ab3ee6c5f --- /dev/null +++ b/android/app/src/test/java/com/noop/data/AppleStepHourMigrationTest.kt @@ -0,0 +1,65 @@ +package com.noop.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Guards the additive v18 -> v19 Room migration (the `appleStepHour` table), the Android twin of the Swift + * WhoopStore `v26-apple-step-hour` migration (PR #369). This environment has no Robolectric / Room-testing, + * so the migration's SQL is exposed as an internal constant ([WhoopDatabase.APPLE_STEP_HOUR_MIGRATION_SQL]) + * and pinned here to Room's generated shape for [AppleStepHour]: + * + * - one CREATE TABLE IF NOT EXISTS statement — deviceId TEXT NOT NULL, ts INTEGER NOT NULL, steps INTEGER + * NOT NULL, composite PRIMARY KEY (deviceId, ts) in declaration order. + * - ADDITIVE: CREATE TABLE only; no DROP/DELETE/UPDATE/INSERT/ALTER on existing data. + * + * SCHEMA-ONLY parity: the Apple-Health hourly-step IMPORT that populates this table is iOS-only (HealthKit + * has no Android analogue), so Android carries the table for .noopbak byte-parity but no importer writes it. + * The column set + order + PK must match the GRDB migration in Packages/WhoopStore/Sources/WhoopStore/ + * Database.swift so the two schemas agree. + */ +class AppleStepHourMigrationTest { + + @Test + fun migration_isAdditive_onlyCreateTable() { + val sql = WhoopDatabase.APPLE_STEP_HOUR_MIGRATION_SQL + assertEquals("one CREATE TABLE statement", 1, sql.size) + for (s in sql) { + val up = s.trimStart().uppercase() + assertTrue("only CREATE TABLE allowed, got: $s", up.startsWith("CREATE TABLE")) + for (banned in listOf("DROP ", "DELETE ", "UPDATE ", "INSERT ", "ALTER ")) { + assertTrue("additive migration must not contain '$banned': $s", !up.contains(banned)) + } + } + } + + @Test + fun migration_createsExactTable() { + assertEquals( + listOf( + "CREATE TABLE IF NOT EXISTS `appleStepHour` (`deviceId` TEXT NOT NULL, " + + "`ts` INTEGER NOT NULL, `steps` INTEGER NOT NULL, PRIMARY KEY(`deviceId`, `ts`))", + ), + WhoopDatabase.APPLE_STEP_HOUR_MIGRATION_SQL, + ) + } + + @Test + fun migration_versionPair_is18to19() { + assertEquals(18, WhoopDatabase.MIGRATION_18_19.startVersion) + assertEquals(19, WhoopDatabase.MIGRATION_18_19.endVersion) + } + + /** + * The entity carries the hour-bucket start ts + the cumulative step sum verbatim; the natural key + * (deviceId, ts) makes the hourly upsert idempotent, exactly like the GRDB ON CONFLICT(deviceId, ts). + */ + @Test + fun appleStepHour_entity_shape() { + val row = AppleStepHour(deviceId = "apple-health", ts = 1_780_917_200L, steps = 250) + assertEquals("apple-health", row.deviceId) + assertEquals(1_780_917_200L, row.ts) + assertEquals(250, row.steps) + } +}