diff --git a/.gitignore b/.gitignore index be5387b..5e4da49 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .gradle/ local.properties build/ +dist/ *.keystore *.jks *.keystore diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 014dc84..ec34d34 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -10,7 +10,9 @@ plugins { android { namespace = "com.pulseloop" - compileSdk = 35 + // compileSdk 36 (required by androidx.health.connect:connect-client:1.1.0); targetSdk + // stays 35 — compileSdk gates available APIs, targetSdk gates runtime behavior. + compileSdk = 36 defaultConfig { applicationId = "com.pulseloop" @@ -19,7 +21,7 @@ android { // versionCode/versionName are overridable from Gradle properties so the release CI // can drive them straight from the git tag (e.g. -PappVersionCode=5 -PappVersionName=1.0.0). // Local builds fall back to the literals below. - versionCode = (project.findProperty("appVersionCode") as String?)?.toIntOrNull() ?: 36 + versionCode = (project.findProperty("appVersionCode") as String?)?.toIntOrNull() ?: 37 versionName = (project.findProperty("appVersionName") as String?) ?: "2.5.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" @@ -165,6 +167,11 @@ dependencies { implementation("androidx.work:work-runtime-ktx:2.9.1") implementation("androidx.security:security-crypto:1.1.0-alpha06") + // Phase 8: Health Connect export (write-only mirror of the iOS HealthKit export). + // 1.1.0 is the current stable (verified 2026-08-14); the official guide targets the 1.1.0 + // series, so it is a faithful API reference for this pin. + implementation("androidx.health.connect:connect-client:1.1.0") + debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-test-manifest") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bf62e99..99f6ff4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ - + @@ -32,6 +33,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/pulseloop/MainActivity.kt b/app/src/main/java/com/pulseloop/MainActivity.kt index 2f8e9a2..a6dbaa1 100644 --- a/app/src/main/java/com/pulseloop/MainActivity.kt +++ b/app/src/main/java/com/pulseloop/MainActivity.kt @@ -10,6 +10,7 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.ContextCompat +import com.pulseloop.health.HealthConnectPermissionReconcile import com.pulseloop.notifications.CoachNotifications import com.pulseloop.strava.StravaAuth import com.pulseloop.strava.StravaTokenStore @@ -70,6 +71,12 @@ class MainActivity : ComponentActivity() { if (hasAllBlePermissions() && hasNotificationPermission()) { CoachNotifications.schedule(this) } + // Health Connect Phase 6: detect out-of-band grant/revocation on every foreground return + // and reset the affected export watermarks (auto on grow; a full revocation is surfaced on + // the settings screen). No-op unless the export is enabled — deliberately NOT gated on a + // stored grant, so a re-grant made after a full revocation (stored set then empty) is still + // seen as a grow and its watermark reset backfills the re-granted types. + HealthConnectPermissionReconcile.onAppStart(this, lifecycleScope) } override fun onNewIntent(intent: Intent) { diff --git a/app/src/main/java/com/pulseloop/data/DataArchive.kt b/app/src/main/java/com/pulseloop/data/DataArchive.kt index 4cb033f..094aed6 100644 --- a/app/src/main/java/com/pulseloop/data/DataArchive.kt +++ b/app/src/main/java/com/pulseloop/data/DataArchive.kt @@ -231,6 +231,9 @@ data class PulseArchive( val quantity: Double = 1.0, val confidenceRaw: String = "medium", val userEdited: Boolean = false, val notes: String? = null, val loggedByCoach: Boolean = false, val createdAt: Long, + // Phase 6: exported so an in-place-edited meal's updatedAt survives an archive round-trip. + // Old archives lack it (deserializes to 0) -> restore backfills from createdAt. + val updatedAt: Long = 0L, ) @Serializable data class CachedFoodProductDTO( diff --git a/app/src/main/java/com/pulseloop/data/DataArchiveService.kt b/app/src/main/java/com/pulseloop/data/DataArchiveService.kt index daeaa96..156b816 100644 --- a/app/src/main/java/com/pulseloop/data/DataArchiveService.kt +++ b/app/src/main/java/com/pulseloop/data/DataArchiveService.kt @@ -10,6 +10,9 @@ import androidx.room.withTransaction import androidx.sqlite.db.SimpleSQLiteQuery import com.pulseloop.BuildConfig import com.pulseloop.data.entity.* +import com.pulseloop.health.HealthConnectPrefs +import com.pulseloop.health.HealthConnectPrefsStore +import com.pulseloop.health.HealthConnectWatermarks import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json @@ -291,7 +294,7 @@ object DataArchiveService { servingGrams = c.dblOrNull("servingGrams"), quantity = c.dbl("quantity"), confidenceRaw = c.str("confidenceRaw"), userEdited = c.bool("userEdited"), notes = c.strOrNull("notes"), loggedByCoach = c.bool("loggedByCoach"), - createdAt = c.long("createdAt"), + createdAt = c.long("createdAt"), updatedAt = c.long("updatedAt"), ) }, foodProducts = collect("food_products") { c -> @@ -568,6 +571,7 @@ object DataArchiveService { servingDescription = m.servingDescription, servingGrams = m.servingGrams, quantity = m.quantity, confidenceRaw = m.confidenceRaw, userEdited = m.userEdited, notes = m.notes, loggedByCoach = m.loggedByCoach, createdAt = m.createdAt, + updatedAt = if (m.updatedAt > 0) m.updatedAt else m.createdAt, )) } for (fp in archive.foodProducts) { @@ -583,6 +587,22 @@ object DataArchiveService { } } + // Phase 6 (iOS DataArchiveService.refreshSharedStores parity): importing history must not + // trigger a surprise full re-export into Health Connect. If the export is enabled, stamp + // every group's watermark to now so only data logged AFTER the restore is exported. The + // Health Connect prefs themselves are not in the archive (they live in SharedPreferences, + // not Room), so the enabled flag / grants are this device's own. + // Gated on the backfill choice having been made as well (review pass 5): with + // enabled = true but NOT_ASKED the first-enable dialog is still up, and EXPORT_ALL means + // "backfill from epoch" purely by way of null watermarks — nothing resets them. Stamping + // here would silently turn a subsequent "Sync all history" into a no-op. + val hcStore = HealthConnectPrefsStore.get(context) + val hcPrefs = hcStore.current + if (hcPrefs.enabled && hcPrefs.backfillChoice != HealthConnectPrefs.BackfillChoice.NOT_ASKED) { + val now = System.currentTimeMillis() + HealthConnectWatermarks.Key.values().forEach { hcStore.setWatermark(it, now) } + } + archive } diff --git a/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt b/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt index 72e13a9..accfbaa 100644 --- a/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt +++ b/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt @@ -43,7 +43,7 @@ import com.pulseloop.data.entity.* MealEntryEntity::class, CachedFoodProductEntity::class, ], - version = 20, + version = 21, exportSchema = false, ) abstract class PulseLoopDatabase : RoomDatabase() { @@ -391,6 +391,23 @@ abstract class PulseLoopDatabase : RoomDatabase() { } } + /** + * v20 -> v21: `meal_entries.updatedAt` (Phase 6). The Health Connect nutrition export + * watermarks on and versions by [com.pulseloop.data.entity.MealEntryEntity.updatedAt] so + * a future in-place meal edit re-exports under the same `pl-meal-` clientRecordId. + * Rows are insert-once today, so backfilling `updatedAt = createdAt` is exactly + * lossless — every existing row's two stamps are already equal. Added with a temporary + * `DEFAULT 0` (SQLite requires one for a NOT NULL ADD COLUMN) then backfilled in a + * single statement; both run inside Room's onUpgrade transaction, so an interrupted + * upgrade rolls back to v20 and re-runs. + */ + private val MIGRATION_20_21 = object : Migration(20, 21) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `meal_entries` ADD COLUMN `updatedAt` INTEGER NOT NULL DEFAULT 0") + db.execSQL("UPDATE `meal_entries` SET `updatedAt` = `createdAt`") + } + } + private fun adoptStableMeasurementIdentities(db: SupportSQLiteDatabase) { db.execSQL("DROP INDEX IF EXISTS `index_measurements_kindRaw_timestamp_sourceRaw`") db.execSQL( @@ -477,6 +494,7 @@ abstract class PulseLoopDatabase : RoomDatabase() { MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20, + MIGRATION_20_21, ) // Downgrades only (sideloading an older APK). A blanket destructive // fallback would silently wipe every measurement, sleep session, and diff --git a/app/src/main/java/com/pulseloop/data/dao/Daos.kt b/app/src/main/java/com/pulseloop/data/dao/Daos.kt index bf9c40d..e2645f0 100644 --- a/app/src/main/java/com/pulseloop/data/dao/Daos.kt +++ b/app/src/main/java/com/pulseloop/data/dao/Daos.kt @@ -96,6 +96,23 @@ interface MeasurementDao { GROUP BY bucket ORDER BY bucket ASC """) suspend fun hourlyAggregates(kind: String, start: Long, end: Long): List + + /** + * Health Connect export selection: rows committed after [watermark], by `createdAt` — + * deliberately NOT the sample timestamp, so late-arriving ring history is still picked up + * (plan §3). Demo/mock-seeded rows never reach Health Connect (mirrors iOS). There was + * previously no `createdAt`-based query at all — only [range] by sample timestamp. + */ + @Query("SELECT * FROM measurements WHERE kindRaw = :kind AND createdAt > :watermark AND sourceRaw NOT IN ('demo','mock') ORDER BY createdAt ASC") + suspend fun createdSince(kind: String, watermark: Long): List + + /** + * Full window re-read by sample timestamp for the heart-rate hour rebuild (plan Phase 1): + * after [createdSince] tells us which local hours are touched, each hour is re-read in full + * and its series records rebuilt from scratch. Demo/mock excluded, same as [createdSince]. + */ + @Query("SELECT * FROM measurements WHERE kindRaw = :kind AND timestamp BETWEEN :start AND :end AND sourceRaw NOT IN ('demo','mock') ORDER BY timestamp ASC") + suspend fun rangeReal(kind: String, start: Long, end: Long): List } @Dao @@ -120,6 +137,21 @@ interface ActivityDailyDao { @Query("DELETE FROM activity_daily WHERE source = 'demo'") suspend fun clearDemo() + + // Health Connect export (Phase 3): days whose totals changed since the watermark. Demo/mock + // rows never leave the app — note this table names the column `source`, not the `sourceRaw` + // the measurement/sleep tables use. + // ORDER BY updatedAt, NOT date: healthConnectInsertChunked advances the watermark to the max + // high water of the last *successful* chunk, which is only safe if the records arrive in + // high-water order. A history re-sync restamps an OLD day's updatedAt, so ordering by date + // would put the newest watermark value in the first chunk and let a later chunk's failure + // strand days below an already-advanced watermark (iOS sorts on updatedAt too — + // HealthSyncService.swift:257). `date` only breaks ties, for deterministic output. + @Query( + "SELECT * FROM activity_daily WHERE updatedAt > :watermark " + + "AND source NOT IN ('demo','mock') ORDER BY updatedAt ASC, date ASC", + ) + suspend fun updatedSince(watermark: Long): List } @Dao @@ -181,6 +213,29 @@ interface ActivitySessionDao { @Query("SELECT * FROM activity_sessions WHERE statusRaw = 'finished' AND endedAt >= :cutoff") suspend fun finishedSince(cutoff: Long): List + // Health Connect export (Phase 3): finished workouts that STARTED in [from, to) — the netting + // set for the daily aggregates (iOS keys netting on startedAt, so a session crossing midnight + // nets entirely against the day it began). + @Query( + "SELECT * FROM activity_sessions WHERE statusRaw = 'finished' AND endedAt IS NOT NULL " + + "AND startedAt >= :from AND startedAt < :to ORDER BY startedAt ASC", + ) + suspend fun finishedStartedBetween(from: Long, to: Long): List + + // Health Connect export selection (Phase 4): finished sessions committed after [watermark], + // by updatedAt — a workout is a mutable group (a post-finish edit or vitals backfill bumps it), + // so the watermark follows the row's last update and a changed session re-upserts the SAME + // pl-wk- records in place. ORDER BY updatedAt, not startedAt: healthConnectInsertChunked + // advances the watermark to the max high water of the last successful chunk, which is only + // safe when records arrive in high-water order (the Phase 2/3 fix). + // No source filter: this table has no source column, and the demo seeder never creates + // sessions — rows here are recorded or archive-restored workouts. + @Query( + "SELECT * FROM activity_sessions WHERE statusRaw = 'finished' AND endedAt IS NOT NULL " + + "AND updatedAt > :watermark ORDER BY updatedAt ASC", + ) + suspend fun finishedUpdatedSince(watermark: Long): List + @Upsert suspend fun upsert(session: ActivitySessionEntity) } @@ -190,6 +245,14 @@ interface ActivityGpsPointDao { @Query("SELECT * FROM activity_gps_points WHERE sessionId = :sessionId ORDER BY timestamp ASC") suspend fun forSession(sessionId: String): List + /** All fixes for the given sessions in one query — the Phase 4 export backfill can span + * years of rows, so one query over the whole pending set rather than one per session. */ + @Query( + "SELECT * FROM activity_gps_points WHERE sessionId IN (:sessionIds) " + + "ORDER BY sessionId ASC, timestamp ASC", + ) + suspend fun forSessions(sessionIds: List): List + @Insert suspend fun insert(point: ActivityGpsPointEntity) } @@ -227,6 +290,19 @@ interface SleepSessionDao { @Query("SELECT * FROM sleep_sessions WHERE date BETWEEN :start AND :end ORDER BY date ASC") suspend fun inRange(start: Long, end: Long): List + /** + * Health Connect export selection (Phase 2): sessions committed after [watermark], by + * `updatedAt` — sleep is a mutable group (plan §3): a re-synced night must re-upsert the SAME + * record in place, so the watermark follows the row's last update, not its sample span. + * Demo rows never reach Health Connect (mirrors iOS; sleep's sourceRaw is "ring" | "demo"). + */ + // Ordered by updatedAt for the same reason as ActivityDailyDao.updatedSince: the chunked + // watermark advance is only sound when records arrive in high-water order, and a re-synced + // old night carries a fresh updatedAt. date/startAt only break ties. (Corrected in Phase 3; + // Phase 2 ordered by date and could strand a night below an advanced watermark.) + @Query("SELECT * FROM sleep_sessions WHERE updatedAt > :watermark AND sourceRaw NOT IN ('demo','mock') ORDER BY updatedAt ASC, date ASC, startAt ASC") + suspend fun updatedSince(watermark: Long): List + /** Earliest tracked day key (local midnight millis) — bounds how far Day navigation can page back. */ @Query("SELECT MIN(date) FROM sleep_sessions WHERE totalMinutes > 0") suspend fun earliestDay(): Long? @@ -415,6 +491,13 @@ interface MealEntryDao { """) suspend fun dayTotals(day: Long): List + /** Meals newer than the nutrition watermark, on `updatedAt` (Phase 6): a logged meal is + * insert-once so `updatedAt == createdAt` today, but an in-place edit bumps `updatedAt` + * and the row re-selects — the same watermark semantics the other groups use. Same + * demo/mock exclusion as the other groups. */ + @Query("SELECT * FROM meal_entries WHERE updatedAt > :watermark AND sourceRaw NOT IN ('demo','mock') ORDER BY updatedAt ASC") + suspend fun updatedSince(watermark: Long): List + @Upsert suspend fun upsert(entry: MealEntryEntity) diff --git a/app/src/main/java/com/pulseloop/data/entity/NutritionEntities.kt b/app/src/main/java/com/pulseloop/data/entity/NutritionEntities.kt index f762b21..8a233e3 100644 --- a/app/src/main/java/com/pulseloop/data/entity/NutritionEntities.kt +++ b/app/src/main/java/com/pulseloop/data/entity/NutritionEntities.kt @@ -26,6 +26,13 @@ data class MealEntryEntity( val notes: String? = null, val loggedByCoach: Boolean = false, val createdAt: Long = System.currentTimeMillis(), + /** + * Last-modified stamp driving the Health Connect nutrition export watermark + record + * version (Phase 6). Equals [createdAt] for the insert-once rows logged today; a future + * in-place meal-edit path bumps it so the edited meal re-exports under the same + * `pl-meal-` clientRecordId. iOS's twin meal model carries this for the same reason. + */ + val updatedAt: Long = System.currentTimeMillis(), ) @Entity(tableName = "food_products", indices = [Index("lastUsedAt")]) diff --git a/app/src/main/java/com/pulseloop/health/HealthConnectAvailability.kt b/app/src/main/java/com/pulseloop/health/HealthConnectAvailability.kt new file mode 100644 index 0000000..dd0ba4d --- /dev/null +++ b/app/src/main/java/com/pulseloop/health/HealthConnectAvailability.kt @@ -0,0 +1,30 @@ +package com.pulseloop.health + +import android.content.Context +import androidx.health.connect.client.HealthConnectClient + +/** + * Availability of the Health Connect provider on this device, as the settings screen needs it. + * + * Health Connect ships in AOSP from Android 14; on older devices it is the separate + * `com.google.android.apps.healthdata` app from the Play Store, so "absent" and "needs an + * update" are distinct, actionable states (install deep link vs update prompt) rather than one + * broken toggle (docs/health-connect-integration.md §2, caveat 1). + * + * Wraps `HealthConnectClient.getSdkStatus` (official get-started guide; 1.1.0 constants: + * SDK_UNAVAILABLE = 1, SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED = 2, SDK_AVAILABLE = 3). + */ +enum class HealthConnectAvailability { + AVAILABLE, + PROVIDER_UPDATE_REQUIRED, + UNAVAILABLE, +} + +object HealthConnectSdk { + fun availability(context: Context): HealthConnectAvailability = + when (HealthConnectClient.getSdkStatus(context.applicationContext)) { + HealthConnectClient.SDK_AVAILABLE -> HealthConnectAvailability.AVAILABLE + HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> HealthConnectAvailability.PROVIDER_UPDATE_REQUIRED + else -> HealthConnectAvailability.UNAVAILABLE + } +} diff --git a/app/src/main/java/com/pulseloop/health/HealthConnectExportWorker.kt b/app/src/main/java/com/pulseloop/health/HealthConnectExportWorker.kt new file mode 100644 index 0000000..a242ac9 --- /dev/null +++ b/app/src/main/java/com/pulseloop/health/HealthConnectExportWorker.kt @@ -0,0 +1,114 @@ +package com.pulseloop.health + +import android.content.Context +import android.util.Log +import androidx.health.connect.client.HealthConnectClient +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.pulseloop.data.PulseLoopDatabase +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.concurrent.TimeUnit + +/** + * Runs the Health Connect export pass (docs/health-connect-integration.md Phase 1). + * + * A plain one-time worker — no foreground service: a pass is a bounded DB read + chunked writes, + * and watermarks advance per chunk, so a 10-minute WorkManager timeout mid-backfill simply + * resumes from the watermark on the next trigger. Triggers (ring sync done, background sync done) + * are debounced by the 15 s initial delay + [ExistingWorkPolicy.REPLACE]: a burst of sync events + * coalesces into one pass. + * + * Hard gate (plan §3): while [HealthConnectPrefs.BackfillChoice.NOT_ASKED] the export never runs — + * first-enable asks "Sync all history / Only new data from now on" before anything is written. + */ +class HealthConnectExportWorker( + context: Context, + params: WorkerParameters, +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + val store = HealthConnectPrefsStore.get(applicationContext) + val prefs = store.current + if (!prefs.enabled) return Result.success() + if (prefs.backfillChoice == HealthConnectPrefs.BackfillChoice.NOT_ASKED) { + Log.i(TAG, "backfill choice not made yet — export gated") + return Result.success() + } + if (HealthConnectSdk.availability(applicationContext) != HealthConnectAvailability.AVAILABLE) { + Log.i(TAG, "Health Connect provider unavailable — skipping pass") + return Result.success() + } + + val client = HealthConnectClient.getOrCreate(applicationContext) + return try { + val exporter = HealthConnectExporter( + client = client, + db = PulseLoopDatabase.getInstance(applicationContext), + store = store, + ) + // The pass and "Remove PulseLoop data" must never interleave (review pass 5): a pass + // still inserting while the removal deletes would re-write records the user asked to + // delete, and its non-suspending setWatermark could land AFTER clearWatermarks(), + // leaving watermarks that claim deleted records were exported. Cancelling the work is + // not enough on its own — cancellation is only observed at a suspension point — so + // both sides take this process-wide lock (the iOS `isSyncing` latch analogue). + passMutex.withLock { + val result = exporter.run() + store.update { it.copy(lastSyncAt = System.currentTimeMillis(), lastSyncSummary = result.summary()) } + Log.i(TAG, "pass done: ${result.summary()}") + } + Result.success() + } catch (e: SecurityException) { + // Permission revoked mid-pass: never retry in a loop. Re-check the live granted set + // now (plan: "a SecurityException from insertRecords should also trigger a re-check") + // and correct the stored lastGrantedPermissions; the settings screen / next app start + // then surface a full revocation, and the automatic grow-reset makes any later re-grant + // re-export regardless. + Log.w(TAG, "SecurityException — permission revoked mid-pass", e) + runCatching { + val live = HealthConnectPermissionReconcile.storedSetOf( + client.permissionController.getGrantedPermissions(), + ) + HealthConnectPermissionReconcile.reconcile( + store.current.lastGrantedPermissions.toSet(), live.toSet(), store, + ) + store.update { it.copy(lastGrantedPermissions = live) } + } + Result.success() + } + } + + companion object { + private const val TAG = "HealthConnectExport" + private const val WORK_NAME = "health_connect_export" + + /** + * Serializes an export pass against [HealthConnectRemoval.removeAll]. Process-wide: both + * run as WorkManager workers in the app process. See the use site in [doWork]. + */ + internal val passMutex = Mutex() + private const val DEBOUNCE_SECONDS = 15L + + /** Debounced enqueue — safe to call from every trigger; a burst coalesces into one pass. */ + fun enqueue(context: Context) { + val request = OneTimeWorkRequestBuilder() + .setInitialDelay(DEBOUNCE_SECONDS, TimeUnit.SECONDS) + .build() + WorkManager.getInstance(context.applicationContext) + .enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.REPLACE, request) + } + + /** + * Cancel a pending/in-flight export pass. Called before a removal so the two cannot race + * (iOS guards removeAllExportedData with an isSyncing latch; Android has no equivalent + * latch, so we cancel the unique work instead). + */ + fun cancel(context: Context) { + WorkManager.getInstance(context.applicationContext).cancelUniqueWork(WORK_NAME) + } + } +} diff --git a/app/src/main/java/com/pulseloop/health/HealthConnectExporter.kt b/app/src/main/java/com/pulseloop/health/HealthConnectExporter.kt new file mode 100644 index 0000000..8673181 --- /dev/null +++ b/app/src/main/java/com/pulseloop/health/HealthConnectExporter.kt @@ -0,0 +1,703 @@ +package com.pulseloop.health + +import androidx.health.connect.client.HealthConnectClient +import androidx.health.connect.client.PermissionController +import androidx.health.connect.client.records.ExerciseRoute +import androidx.health.connect.client.records.ExerciseRouteResult +import androidx.health.connect.client.records.ExerciseSessionRecord +import androidx.health.connect.client.records.Record +import androidx.health.connect.client.records.metadata.Device +import com.pulseloop.data.PulseLoopDatabase +import com.pulseloop.health.exporters.ActivityExporter +import com.pulseloop.health.exporters.NutritionExporter +import com.pulseloop.health.exporters.RestingHeartRateExporter +import com.pulseloop.health.exporters.SleepExporter +import com.pulseloop.health.exporters.VitalsExporter +import com.pulseloop.health.exporters.WorkoutExporter +import kotlinx.coroutines.delay + +/** + * Chunk + retry progress for one kind's insert pass (see [healthConnectInsertChunked]). + */ +data class ChunkProgress( + /** Max source-row createdAt fully inserted; 0 when nothing landed. */ + val lastCompletedHighWater: Long, + val allCompleted: Boolean, + val attempts: Int, + val inserted: Int, + val lastError: Exception?, +) + +/** + * Inserts [records] in [HealthConnectExporter.CHUNK_SIZE] chunks with retry/backoff + * (Gadgetbridge's production constants: 200 per call, 5 retries, 1 / 2 / 4 / 8 / 16 s). [highWaters] + * holds the source-row high water for each record (parallel lists); the watermark may only + * advance to a value whose rows all reached Health Connect, so each chunk's high water is the max + * of its records'. A [SecurityException] rethrows immediately (the caller aborts the pass) — + * never retry a permission failure; any other error is retried and then reported via + * [ChunkProgress] — never thrown, so one failing kind cannot sink the rest. + */ +internal suspend fun healthConnectInsertChunked( + records: List, + highWaters: List, + insert: suspend (List) -> Unit, +): ChunkProgress { + require(records.size == highWaters.size) { "records/highWaters must be parallel" } + if (records.isEmpty()) return ChunkProgress(0L, true, 0, 0, null) + var lastOk = 0L + var attempts = 0 + var inserted = 0 + var lastError: Exception? = null + var i = 0 + while (i < records.size) { + val end = minOf(i + HealthConnectExporter.CHUNK_SIZE, records.size) + val chunk = records.subList(i, end) + val chunkHigh = highWaters.subList(i, end).maxOrNull() ?: 0L + var success = false + for (attempt in 0..HealthConnectExporter.MAX_RETRIES) { + attempts++ + try { + insert(chunk) + success = true + lastError = null + break + } catch (e: SecurityException) { + throw e // permission failure — abort, never retry + } catch (e: Exception) { + lastError = e + if (attempt < HealthConnectExporter.MAX_RETRIES) delay(HealthConnectExporter.RETRY_BASE_MS shl attempt) + } + } + if (!success) { + // A watermark may only advance past values whose records ALL landed. One source row + // can produce several records sharing one high water (Phase 3 writes steps + energy + + // distance from one day), so if the chunk boundary splits such a row, the max of the + // completed chunks would strand the unlanded sibling below an advanced watermark. + // Clamp to the largest completed value that is strictly below everything still + // pending. + val minRemaining = highWaters.subList(i, highWaters.size).min() + val safe = highWaters.subList(0, i).filter { it < minRemaining }.maxOrNull() ?: 0L + return ChunkProgress(minOf(lastOk, safe), false, attempts, inserted, lastError) + } + lastOk = maxOf(lastOk, chunkHigh) + inserted += chunk.size + i = end + } + return ChunkProgress(lastOk, true, attempts, inserted, null) +} + +/** + * Inserts [chunk], with the 1 MB single-record fallback (plan §3 robustness constants; + * Gadgetbridge's `insertRecords` + `shrinkOversizedRoute`): the platform rejects an oversized + * insert with "...single record size limit: 1000000, was: N...". That failure is deterministic, + * so plain backoff would just burn retries — instead the routes of the [ExerciseSessionRecord]s + * in the chunk (the only variable-size records we build) are decimated to ~90 % of the limit and + * the retry is IMMEDIATE, no backoff. Any error the shrink cannot address rethrows for the + * caller's normal retry loop; a [SecurityException] always rethrows first (never retry a + * permission failure). + */ +internal suspend fun insertChunkWithRouteShrink( + chunk: List, + insert: suspend (List) -> Unit, +): Unit { + var current = chunk + // Bounded defensively: every successful shrink removes at least one route point, and once a + // route is at the 2-point floor [shrinkOversizedRoute] reports nothing shrank, so this can + // never spin — but a cap keeps a malformed platform message from making it so. + var shrinks = 0 + while (true) { + try { + insert(current) + return + } catch (e: SecurityException) { + throw e + } catch (e: Exception) { + val shrunk = shrinkOversizedRoute(current, e) ?: throw e + if (++shrinks > MAX_ROUTE_SHRINKS) throw e + current = shrunk + } + } +} + +/** Defense-in-depth cap on consecutive route shrinks (see [insertChunkWithRouteShrink]). */ +internal const val MAX_ROUTE_SHRINKS = 5 + +/** + * The watermark advance decision for a group whose selection contains rows that produce no record + * (observer review, Phase 4 stage B — BLOCKER fix; generalized to every group in review pass 5). + * + * [droppedHighWater] is the max source high water among selected rows that can NEVER become + * exportable — a zero-duration workout, a demo-sourced row, a measurement outside the platform's + * range, a sleep session with no stages. Such a row is invisible to + * [healthConnectInsertChunked]'s clamp (which only sees RECORD high waters), so without this the + * group watermark stops just below it and every later pass re-selects and re-upserts the whole + * tail behind it, forever — until some newer exportable row happens to leapfrog it. + * + * It may be applied **only when the pass fully completed**. On a partial chunk failure a dropped + * row's high water sitting above the failed point would leapfrog the unlanded valid rows below it + * (they would never be re-selected, and for workouts netting would still subtract their energy + * from re-exported day records — loss that compounds). On a completed pass everything exportable + * has landed, so advancing past the never-exportable rows is safe. + * + * Rows that are merely *not yet* exportable — a future-dated activity day, an unpaired + * blood-pressure reading whose other side may still arrive — are deliberately NOT counted as + * dropped by the exporters, because advancing past them would lose them for good. + */ +internal fun watermarkAdvance( + allCompleted: Boolean, + lastCompletedHighWater: Long, + droppedHighWater: Long?, +): Long = if (allCompleted) maxOf(lastCompletedHighWater, droppedHighWater ?: 0L) else lastCompletedHighWater + +/** + * Read-site consent clamp (review pass 3): the single place the EXPORT_NEW_ONLY boundary is + * enforced, instead of at every watermark-nulling write site. A null group watermark is + * overloaded — it means both "never exported" and "export from epoch" + * (`createdSince(kind, 0)`) — so any path that nulls one (resetWatermarks, clearWatermarks + * from removal or the revocation dialog) would otherwise silently re-export a NEW_ONLY user's + * pre-consent history. Clamping the SELECT watermark to the consent instant here makes every + * nulling path safe by construction: the exporter reads `max(stored ?: 0, newOnlyConsentAt ?: 0)` + * rather than `stored ?: 0`. `newOnlyConsentAt` is only non-null for an EXPORT_NEW_ONLY user + * (the sentinel records it), so this is a no-op for EXPORT_ALL / NOT_ASKED. The STORED watermark + * still drives the monotonic advance — the caller compares against `stored`, not this value — + * so only the SELECT is clamped. + */ +internal fun effectiveWatermark(storedWatermark: Long?, newOnlyConsentAt: Long?): Long = + maxOf(storedWatermark ?: 0L, newOnlyConsentAt ?: 0L) + +/** + * If [e] carries the platform's single-record size-limit message and [records] contains an + * [ExerciseSessionRecord] whose route is long enough to decimate, returns a copy of the list + * with those routes cut to ~[HealthConnectTypeMappings.ROUTE_SHRINK_MARGIN] of the limit + * (first and last points preserved, no duplicated timestamps — HC rejects both). Returns null + * when the error is unrelated or nothing can shrink, so the caller falls back to normal + * retry/abort. Terminates: once a route is down to 2 points [shrinkOversizedRoute] reports + * nothing shrank and the original error rethrows. + */ +internal fun shrinkOversizedRoute(records: List, e: Exception): List? { + val (limit, was) = HealthConnectTypeMappings.parseRecordSizeLimit(e.message) ?: return null + var shrankAny = false + val result = records.map { record -> + val route = ((record as? ExerciseSessionRecord)?.exerciseRouteResult as? ExerciseRouteResult.Data) + ?.exerciseRoute + ?: return@map record + val points = route.route + if (points.size < 2) return@map record + val target = (points.size * (limit.toDouble() / was.toDouble()) * HealthConnectTypeMappings.ROUTE_SHRINK_MARGIN).toInt() + if (target >= points.size) return@map record + val decimated = HealthConnectTypeMappings.decimateToSize(points, target) + // A route already at the 2-point floor "shrinks" to itself — count it only when points + // were actually removed, or the caller would retry the same oversized chunk forever. + if (decimated.size == points.size) return@map record + shrankAny = true + ExerciseSessionRecord( + startTime = record.startTime, + startZoneOffset = record.startZoneOffset, + endTime = record.endTime, + endZoneOffset = record.endZoneOffset, + metadata = record.metadata, + exerciseType = record.exerciseType, + title = record.title, + notes = record.notes, + exerciseRoute = ExerciseRoute(decimated), + ) + } + return if (shrankAny) result else null +} + +/** + * The export engine (docs/health-connect-integration.md §3 + Phase 1): a pure DB → Health Connect + * pass driven by watermarks, never by events. + * + * Robustness constants are measured from Gadgetbridge's production code: [CHUNK_SIZE] records per + * `insertRecords` call; [MAX_RETRIES] retries with exponential backoff from 1 s (1 / 2 / 4 / 8 / + * 16 s); a [SecurityException] aborts immediately — never retry a permission failure. The + * watermark advances only to a timestamp that actually reached Health Connect, and never rewinds + * ([HealthConnectPrefsStore.setWatermark] enforces the monotonic part). + * + * Phase 1 wires the vitals group; Phase 2 adds the sleep group (its own [SleepExporter] and its + * own SLEEP watermark key); Phases 3–4 append the same way (activity / workouts); Phase 5 + * (nutrition) does the rest. The workouts group is the only one that needs the + * 1 MB per-record fallback ([insertChunkWithRouteShrink]), because the embedded GPS route is + * the only variable-size record we build. + */ +class HealthConnectExporter( + private val client: HealthConnectClient, + private val db: PulseLoopDatabase, + private val store: HealthConnectPrefsStore, + private val now: () -> Long = { System.currentTimeMillis() }, +) { + + /** + * One full pass. Returns a human-readable summary via [PassResult]; never throws for a single + * failing kind (one failing type must not sink the others), but a [SecurityException] — a + * mid-pass permission revocation — aborts the whole pass. + */ + suspend fun run(): PassResult { + val prefs = store.current + val timestamp = now() + var wm0 = store.currentWatermarks + + // First-enable "Only new data from now on": stamp every group's watermark to now exactly + // once, then export nothing — the choice is made meaningful without a data pass. Gated + // on the dedicated [HealthConnectPrefs.newOnlyStamped] flag, NOT on a null watermark: a + // Phase 6 grow-reset also nulls the VITALS watermark when a permission is granted out of + // band, and inferring "first enable" from that would re-stamp every group to now and + // silently drop the rows pending between the reset and this pass. + // + // [HealthConnectPrefs.newOnlyConsentAt] records this stamp's instant as the consent + // boundary: a later grow-reset (permission re-grant or a re-enabled vitals toggle) resets + // the affected group's watermark, and [HealthConnectPrefsStore.resetWatermarks] clamps it + // back to this instant rather than null for a NEW_ONLY user — otherwise a null watermark + // means "export from epoch" and the pre-consent history the user declined would re-export. + if (prefs.backfillChoice == HealthConnectPrefs.BackfillChoice.EXPORT_NEW_ONLY && !prefs.newOnlyStamped) { + HealthConnectWatermarks.Key.values().forEach { store.setWatermark(it, timestamp) } + store.update { it.copy(newOnlyStamped = true, newOnlyConsentAt = timestamp) } + return PassResult( + inserted = emptyMap(), + skipped = listOf("all (backfill choice: export new data only — watermarks stamped)"), + errors = emptyList(), + ) + } + + // Re-check the granted set live on every pass (plan: partial grants are first-class; the + // per-kind check below then gates each record class against it). 1.1.0: PermissionController + // is an interface obtained from the client (no (client) constructor). + val granted = client.permissionController.getGrantedPermissions() + val device = deviceForMetadata(db) + + // One-time Phase 4 netting flip ([HealthConnectPrefs.nettingFlipDone]): on the first + // pass of a build where netting is live, reset the ACTIVITY and WORKOUTS watermarks so + // the daily records exported under the Phase 3 build — UN-netted, because + // WORKOUTS_EXPORTED was false then — are re-selected and re-upserted with their netted + // values, in the same pass that writes the workout siblings they compensate for. + // Without this, every pre-flip day containing a workout would over-count by the + // workout's own energy/distance for as long as the day stays un-updated (write-only: + // the stale un-netted record cannot be deleted). The re-export is idempotent — same + // clientRecordIds, higher-or-equal versions (an unchanged row re-upserts at the SAME + // version — the platform accepts equal-version upserts, verified live in the Phase 4 + // flip pass) — and bounded: each group's full history once. + // + // Gated on EXPORT_ALL (observer review, Phase 4 stage B): a user who chose "Only new + // data from now on" consented to no history — a full-watermark reset would re-export + // pre-consent days, violating the Phase 1 backfill boundary. Their narrower residual + // (pre-flip un-netted daily records for days that were already inside the consented + // window) is accepted: it is bounded by the days touched between Phase 3 enable and the + // Phase 4 update, and the accepted-stale-window rule on + // [HealthConnectTypeMappings.activityLeftover] covers it. + if (WORKOUTS_EXPORTED && prefs.workouts && !prefs.nettingFlipDone && + prefs.backfillChoice == HealthConnectPrefs.BackfillChoice.EXPORT_ALL && + HealthConnectPermissions.exercise.first() in granted) { + store.resetWatermarks( + setOf(HealthConnectWatermarks.Key.ACTIVITY, HealthConnectWatermarks.Key.WORKOUTS), + ) + store.update { it.copy(nettingFlipDone = true) } + // The reset just invalidated the snapshot above: re-read before the groups use it. + wm0 = store.currentWatermarks + } + + val inserted = LinkedHashMap() + val skipped = mutableListOf() + val errors = mutableListOf() + + // ── Vitals group (Phase 1; watermarked on Measurement.createdAt, one group watermark) ── + val kindToggles = mapOf( + "hr" to prefs.heartRate, + "spo2" to prefs.oxygenSaturation, + "hrv" to prefs.heartRateVariability, + "temp" to prefs.bodyTemperature, + // Phase 5 measurement-based kinds. + "glucose" to prefs.bloodGlucose, + "resp_rate" to prefs.respiratoryRate, + "vo2max" to prefs.vo2Max, + "bp" to prefs.bloodPressure, + ) + val kindLabels = mapOf( + "hr" to "heart rate", + "spo2" to "SpO2", + "hrv" to "HRV", + "temp" to "body temperature", + "glucose" to "blood glucose", + "resp_rate" to "respiratory rate", + "vo2max" to "VO2max", + "bp" to "blood pressure", + ) + val vitalsWm = wm0.vitals + val kindHighs = LinkedHashMap() + val exporter = VitalsExporter(db) + + for ((kindKey, toggleOn) in kindToggles) { + if (!toggleOn) { + skipped += kindLabels.getValue(kindKey) + " (toggle off)" + continue + } + val permission = HealthConnectPermissions.WRITE_PERMISSION_BY_KIND[kindKey] + if (permission == null || permission !in granted) { + skipped += kindLabels.getValue(kindKey) + " (permission not granted)" + continue + } + val pending = exporter.build(kindKey, effectiveWatermark(vitalsWm, prefs.newOnlyConsentAt), device) + val progress = insertChunked(pending.records, pending.highWaters) { chunk -> + client.insertRecords(chunk) + } + val label = kindLabels.getValue(kindKey) + if (pending.records.isEmpty()) { + // Nothing new for this kind: its "everything exported" point is now — it must not + // hold the group watermark hostage at the old value. + kindHighs[kindKey] = timestamp + } else { + kindHighs[kindKey] = watermarkAdvance( + progress.allCompleted, progress.lastCompletedHighWater, pending.droppedHighWater, + ) + inserted[kindKey] = progress.inserted + if (!progress.allCompleted) { + errors += "$label: stopped after ${progress.inserted} records " + + "(attempts=${progress.attempts}, last error: ${progress.lastError?.message ?: "unknown"})" + } + } + if (pending.skipped > 0) { + skipped += "$label: ${pending.skipped} reading(s) dropped (unpaired or out of range)" + } + } + + // Group watermark = the point below which EVERY exported kind is fully done (the min of + // per-kind highs). A kind that failed partway pins the group at its last success, so the + // next pass re-reads only what it missed — and re-upserts (same clientRecordIds) the + // kinds that were already further along. setWatermark() then enforces never-rewind. + if (kindHighs.isNotEmpty()) { + val groupHigh = kindHighs.values.minOrNull() ?: 0L + if (groupHigh > (vitalsWm ?: 0L)) store.setWatermark(HealthConnectWatermarks.Key.VITALS, groupHigh) + } + + // ── Sleep group (Phase 2; watermarked on SleepSessionEntity.updatedAt — a re-synced + // night re-upserts the same pl-sleep- record in place) ── + if (!prefs.sleep) { + skipped += "sleep (toggle off)" + } else { + val permission = HealthConnectPermissions.sleep.first() + if (permission !in granted) { + skipped += "sleep (permission not granted)" + } else { + val sleepPending = SleepExporter(db).build(effectiveWatermark(wm0.sleep, prefs.newOnlyConsentAt), device) + val sleepProgress = insertChunked(sleepPending.records, sleepPending.highWaters) { chunk -> + client.insertRecords(chunk) + } + if (sleepPending.records.isEmpty()) { + // Nothing new for sleep: the group's "everything exported" point is now — it + // must not hold its watermark hostage at the old value (same rule as a + // vitals kind with no new rows). + if (timestamp > (wm0.sleep ?: 0L)) { + store.setWatermark(HealthConnectWatermarks.Key.SLEEP, timestamp) + } + } else { + inserted["sleep"] = sleepProgress.inserted + // Advance only to what actually landed — or past the never-exportable rows + // when the pass completed (see [watermarkAdvance]); setWatermark() enforces + // never-rewind. + val sleepAdvance = watermarkAdvance( + sleepProgress.allCompleted, sleepProgress.lastCompletedHighWater, sleepPending.droppedHighWater, + ) + if (sleepAdvance > (wm0.sleep ?: 0L)) { + store.setWatermark(HealthConnectWatermarks.Key.SLEEP, sleepAdvance) + } + if (!sleepProgress.allCompleted) { + errors += "sleep: stopped after ${sleepProgress.inserted} record(s) " + + "(attempts=${sleepProgress.attempts}, last error: ${sleepProgress.lastError?.message ?: "unknown"})" + } + } + if (sleepPending.skippedSessions > 0) { + skipped += "sleep: ${sleepPending.skippedSessions} session(s) without valid stages" + } + } + } + + // ── Activity group (Phase 3; watermarked on ActivityDailyEntity.updatedAt — a day whose + // totals grow through the afternoon re-upserts the same three + // pl-act-- records in place) ── + if (!prefs.stepsAndActivity) { + skipped += "steps & activity (toggle off)" + } else { + val metricPermissions = linkedMapOf( + HealthConnectTypeMappings.ACT_STEPS to HealthConnectPermissions.steps.first(), + HealthConnectTypeMappings.ACT_ENERGY to HealthConnectPermissions.activeCalories.first(), + HealthConnectTypeMappings.ACT_DIST to HealthConnectPermissions.distance.first(), + ) + val metricLabels = mapOf( + HealthConnectTypeMappings.ACT_STEPS to "steps", + HealthConnectTypeMappings.ACT_ENERGY to "active calories", + HealthConnectTypeMappings.ACT_DIST to "distance", + ) + // Partial grants are first-class (plan §4): each of the three record types is gated on + // its own write permission, and the ones that are granted still export. + metricPermissions.forEach { (metric, permission) -> + if (permission !in granted) skipped += metricLabels.getValue(metric) + " (permission not granted)" + } + val metrics = metricPermissions.filterValues { it in granted }.keys + if (metrics.isNotEmpty()) { + val activityPending = ActivityExporter(db).build( + watermark = effectiveWatermark(wm0.activity, prefs.newOnlyConsentAt), + device = device, + metrics = metrics, + // Netting is only correct while the workout records it compensates for are + // actually being written — see [shouldNetWorkouts]. + netWorkouts = shouldNetWorkouts(prefs, granted), + nowMs = timestamp, + ) + val activityProgress = insertChunked(activityPending.records, activityPending.highWaters) { chunk -> + client.insertRecords(chunk) + } + if (activityPending.records.isEmpty()) { + // Nothing new (or nothing writable) for activity: its "everything exported" + // point is now — same rule as an empty vitals kind or sleep pass. Note this + // only fires when the WHOLE pass produced nothing, so a zero-metric day whose + // updatedAt sits above every record-producing day is re-read on each pass until + // some pass comes back empty. Bounded and idempotent, not a leak. + if (timestamp > (wm0.activity ?: 0L)) { + store.setWatermark(HealthConnectWatermarks.Key.ACTIVITY, timestamp) + } + } else { + inserted["activity"] = activityProgress.inserted + val activityAdvance = watermarkAdvance( + activityProgress.allCompleted, activityProgress.lastCompletedHighWater, activityPending.droppedHighWater, + ) + if (activityAdvance > (wm0.activity ?: 0L)) { + store.setWatermark(HealthConnectWatermarks.Key.ACTIVITY, activityAdvance) + } + if (!activityProgress.allCompleted) { + errors += "activity: stopped after ${activityProgress.inserted} record(s) " + + "(attempts=${activityProgress.attempts}, last error: ${activityProgress.lastError?.message ?: "unknown"})" + } + } + if (activityPending.skippedDays > 0) { + skipped += "activity: ${activityPending.skippedDays} day(s) with nothing to export" + } + } + } + + // ── Workouts group (Phase 4; watermarked on ActivitySessionEntity.updatedAt — a + // post-finish edit or vitals backfill re-upserts the same pl-wk- records + // in place) ── + if (!prefs.workouts) { + skipped += "workouts (toggle off)" + } else { + val exercisePermission = HealthConnectPermissions.exercise.first() + if (exercisePermission !in granted) { + skipped += "workouts (permission not granted)" + } else { + // The route is an embedded field with its own, independently grantable write + // permission: without it the session still writes, just without a route. The + // siblings are standalone records, each gated on its OWN permission — the same + // three that the activity group gates its per-metric records on (plan Phase 4 — + // partial grants are first-class). + val withRoute = HealthConnectPermissions.exerciseRoute.first() in granted + val withEnergy = HealthConnectPermissions.activeCalories.first() in granted + val withDistance = HealthConnectPermissions.distance.first() in granted + val workoutsPending = WorkoutExporter(db).build( + effectiveWatermark(wm0.workouts, prefs.newOnlyConsentAt), device, withRoute, withEnergy, withDistance, timestamp, + ) + val workoutsProgress = insertChunked(workoutsPending.records, workoutsPending.highWaters) { chunk -> + insertChunkWithRouteShrink(chunk) { client.insertRecords(it) } + } + if (workoutsPending.records.isEmpty()) { + // Nothing new (or nothing exportable) for workouts: its "everything exported" + // point is now — same rule as an empty vitals kind, sleep or activity pass. + // EXCEPT when the pass stopped at a future-dated session: that session IS new + // data, just not yet exportable, and stamping to now would leapfrog it (iOS + // `guard end <= now else break`). + if (!workoutsPending.blockedFuture && timestamp > (wm0.workouts ?: 0L)) { + store.setWatermark(HealthConnectWatermarks.Key.WORKOUTS, timestamp) + } + } else { + inserted["workouts"] = workoutsProgress.inserted + // Advance to what landed — or past the never-exportable rows (invalidHighWater) + // ONLY when the pass completed: a zero-duration session produces no record, so + // it would otherwise be re-selected forever (iOS advances its workout watermark + // past such sessions in the same step) — but on a partial failure that value + // could leapfrog unlanded valid sessions (see [watermarkAdvance]). + val advanceTo = watermarkAdvance( + workoutsProgress.allCompleted, + workoutsProgress.lastCompletedHighWater, + workoutsPending.invalidHighWater, + ) + if (advanceTo > (wm0.workouts ?: 0L)) { + store.setWatermark(HealthConnectWatermarks.Key.WORKOUTS, advanceTo) + } + if (!workoutsProgress.allCompleted) { + errors += "workouts: stopped after ${workoutsProgress.inserted} record(s) " + + "(attempts=${workoutsProgress.attempts}, last error: ${workoutsProgress.lastError?.message ?: "unknown"})" + } + } + if (workoutsPending.blockedFuture) { + skipped += "workouts: pass stopped at a future-dated session (retries next run)" + } + if (workoutsPending.skippedSessions > 0) { + skipped += "workouts: ${workoutsPending.skippedSessions} session(s) with zero or negative duration" + } + } + } + + // ── Resting HR group (Phase 5; a single mutable baseline, watermarked on + // UserProfileEntity.hrRestingBaselineUpdatedAt - a re-learn re-upserts the same + // pl-resting-hr record in place at a higher version) ── + if (!prefs.restingHeartRate) { + skipped += "resting heart rate (toggle off)" + } else { + val restingPermission = HealthConnectPermissions.restingHeartRate.first() + if (restingPermission !in granted) { + skipped += "resting heart rate (permission not granted)" + } else { + val restingPending = RestingHeartRateExporter(db).build(effectiveWatermark(wm0.restingHr, prefs.newOnlyConsentAt), device) + if (restingPending.records.isEmpty()) { + // Nothing to export (no baseline yet, already current, or implausible): the + // group's "everything exported" point is now - same rule as the other groups. + if (timestamp > (wm0.restingHr ?: 0L)) { + store.setWatermark(HealthConnectWatermarks.Key.RESTING_HR, timestamp) + } + } else { + val restingProgress = insertChunked(restingPending.records, restingPending.highWaters) { chunk -> + client.insertRecords(chunk) + } + inserted["resting_hr"] = restingProgress.inserted + if (restingProgress.lastCompletedHighWater > (wm0.restingHr ?: 0L)) { + store.setWatermark(HealthConnectWatermarks.Key.RESTING_HR, restingProgress.lastCompletedHighWater) + } + if (!restingProgress.allCompleted) { + errors += "resting heart rate: stopped after ${restingProgress.inserted} record(s) " + + "(attempts=${restingProgress.attempts}, last error: ${restingProgress.lastError?.message ?: "unknown"})" + } + } + } + } + + // ── Nutrition group (Phase 5; Phase 6 watermarks on MealEntryEntity.updatedAt - a logged + // meal is insert-once so updatedAt == createdAt today, but an in-place edit bumps it and + // the row re-selects; version = updatedAt) ── + // iOS gates nutrition export on the nutrition FEATURE's master toggle as well as the + // Health Connect per-type toggle (+Nutrition.swift:19) - off-feature meals must not leak to + // Health Connect, so both must be on. + val nutritionFeatureOn = db.userGoalDao().get()?.nutritionEnabled ?: false + if (!prefs.nutrition) { + skipped += "nutrition (toggle off)" + } else if (!nutritionFeatureOn) { + skipped += "nutrition (nutrition feature off)" + } else { + val nutritionPermission = HealthConnectPermissions.nutrition.first() + if (nutritionPermission !in granted) { + skipped += "nutrition (permission not granted)" + } else { + val nutritionPending = NutritionExporter(db).build(effectiveWatermark(wm0.nutrition, prefs.newOnlyConsentAt), device) + if (nutritionPending.records.isEmpty()) { + // Nothing new (or nothing exportable) for nutrition: its "everything exported" + // point is now - same rule as the other groups. + if (timestamp > (wm0.nutrition ?: 0L)) { + store.setWatermark(HealthConnectWatermarks.Key.NUTRITION, timestamp) + } + } else { + val nutritionProgress = insertChunked(nutritionPending.records, nutritionPending.highWaters) { chunk -> + client.insertRecords(chunk) + } + inserted["nutrition"] = nutritionProgress.inserted + val nutritionAdvance = watermarkAdvance( + nutritionProgress.allCompleted, nutritionProgress.lastCompletedHighWater, nutritionPending.droppedHighWater, + ) + if (nutritionAdvance > (wm0.nutrition ?: 0L)) { + store.setWatermark(HealthConnectWatermarks.Key.NUTRITION, nutritionAdvance) + } + if (!nutritionProgress.allCompleted) { + errors += "nutrition: stopped after ${nutritionProgress.inserted} record(s) " + + "(attempts=${nutritionProgress.attempts}, last error: ${nutritionProgress.lastError?.message ?: "unknown"})" + } + } + if (nutritionPending.skippedMeals > 0) { + skipped += "nutrition: ${nutritionPending.skippedMeals} meal(s) outside the platform's range" + } + } + } + + return PassResult(inserted, skipped, errors) + } + + /** + * Ring attribution for record metadata. 1.1.0's [Metadata] requires a non-null device, so + * when no real ring is paired the export is attributed to the app itself (iOS equivalent: + * "samples are then attributed to the app only"). + */ + private suspend fun deviceForMetadata(db: PulseLoopDatabase): Device { + val d = db.deviceDao().currentReal() ?: return Device( + type = Device.TYPE_PHONE, manufacturer = "PulseLoop", model = "app", + ) + val modelId = d.wearableModelID // e.g. "colmi-r10" + val manufacturer: String + val model: String + if (modelId != null && modelId.contains('-')) { + manufacturer = modelId.substringBefore('-') + model = modelId.substringAfter('-') + } else { + manufacturer = "PulseLoop" + model = d.name.ifBlank { modelId ?: "ring" } + } + return Device(type = Device.TYPE_RING, manufacturer = manufacturer, model = model) + } + + /** Chunk + retry delegate — the implementation is top-level ([healthConnectInsertChunked]) so + * it is testable without a client. */ + suspend fun insertChunked( + records: List, + highWaters: List, + insert: suspend (List) -> Unit, + ): ChunkProgress = healthConnectInsertChunked(records, highWaters, insert) + + companion object { + /** + * Whether workout energy/distance may be netted out of the daily aggregates. + * + * iOS gates this on its `exportWorkouts` preference alone, because on iOS the workout + * exporter already exists — netting and the compensating `HKWorkout` ship together. Here + * the two shipped separately (Phase 3, then Phase 4), so netting additionally requires + * that workouts are genuinely exportable in this very build: the workout exporter + * existing at all ([WORKOUTS_EXPORTED]) and `WRITE_EXERCISE` actually granted — the + * second half still matters after Phase 4 lands, because the toggle can be on while the + * permission is denied, which the plan treats as a first-class state (and then no + * workout record is written for the daily netting to compensate against). + * + * **Phase 4 flips [WORKOUTS_EXPORTED] to true in the same commit that adds + * [WorkoutExporter]** (plan Phase 4 "Inherited from Phase 3") — netting is live from + * this commit on. A day whose netted leftover falls to ≤ 0 has its record DROPPED, not + * floored: the stale-record decision lives on + * [HealthConnectTypeMappings.activityLeftover]. + */ + internal fun shouldNetWorkouts(prefs: HealthConnectPrefs, granted: Set): Boolean = + WORKOUTS_EXPORTED && prefs.workouts && HealthConnectPermissions.exercise.first() in granted + + /** + * True since Phase 4, which shipped the [WorkoutExporter] this flag refers to — in the + * same commit, as the plan requires — so netting and the compensating workout records + * turn on together and no day is ever netted against records nothing writes. + */ + internal const val WORKOUTS_EXPORTED = true + + /** Gadgetbridge: records per `insertRecords` call. */ + const val CHUNK_SIZE = 200 + /** Gadgetbridge: retries after the initial attempt (backoff 1 / 2 / 4 / 8 / 16 s). */ + const val MAX_RETRIES = 5 + const val RETRY_BASE_MS = 1_000L + } + + // ── result ── + + data class PassResult( + val inserted: Map, + val skipped: List, + val errors: List, + ) { + fun summary(): String { + val bits = mutableListOf() + if (inserted.isNotEmpty()) { + bits += "exported " + inserted.entries.joinToString(", ") { "${it.key} ${it.value}" } + } + if (skipped.isNotEmpty()) bits += "skipped: " + skipped.joinToString("; ") + if (errors.isNotEmpty()) bits += errors.joinToString("; ") + return if (bits.isEmpty()) "nothing new to export" else bits.joinToString(" · ") + } + } +} diff --git a/app/src/main/java/com/pulseloop/health/HealthConnectPermissionReconcile.kt b/app/src/main/java/com/pulseloop/health/HealthConnectPermissionReconcile.kt new file mode 100644 index 0000000..af6b252 --- /dev/null +++ b/app/src/main/java/com/pulseloop/health/HealthConnectPermissionReconcile.kt @@ -0,0 +1,161 @@ +package com.pulseloop.health + +import android.content.Context +import androidx.health.connect.client.HealthConnectClient +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * Permission → export-watermark reconciliation (docs/health-connect-integration.md §4 Phase 6). + * + * Each export group has ONE watermark but SEVERAL independently grantable record types (VITALS + * covers hr/spo2/hrv/temp + the Phase 5 glucose/resp/vo2/bp; ACTIVITY covers steps/energy/distance; + * WORKOUTS covers exercise + route). Granting a subset advances the shared watermark past every + * historical row, so a type granted *later* never backfills its history — the DAO selects on + * `updatedAt > watermark`, and the watermark is already ahead. The fix is to reset the affected + * group's watermark when the granted set *grows*, so the newly grantable types re-select from + * null and backfill (idempotent upsert under the same clientRecordIds). + * + * The Phase 5 kinds (glucose/resp/vo2/bp) share the VITALS watermark, so they map to VITALS here: + * on an upgrading install the one VITALS reset also backfills the `.name`-fixed legacy hr/spo2/hrv/ + * temp rows. SLEEP / NUTRITION / RESTING_HR are single-permission groups whose watermark starts + * null and backfills on first grant, so a grow-reset on them is a no-op — mapping them anyway keeps + * the table uniform and makes a revoke→re-grant re-export them too (safe: idempotent). + * + * Revocation is the other direction: we store [HealthConnectPrefs.lastGrantedPermissions] and, on + * app start and settings-screen open, diff against the live granted set. A mid-pass + * [SecurityException] in the worker means the same — the next reconcile corrects the stored set. + * A full revocation is surfaced to the user as an offer to clear the watermarks (Gadgetbridge's + * `HealthConnectResetDialogFragment` pattern) so a later re-grant re-exports; the grow-reset above + * is the correctness backstop that makes any re-grant backfill regardless. + */ +object HealthConnectPermissionReconcile { + + private val V = HealthConnectWatermarks.Key.VITALS + private val S = HealthConnectWatermarks.Key.SLEEP + private val A = HealthConnectWatermarks.Key.ACTIVITY + private val W = HealthConnectWatermarks.Key.WORKOUTS + private val N = HealthConnectWatermarks.Key.NUTRITION + private val R = HealthConnectWatermarks.Key.RESTING_HR + + /** + * Every write permission → the export group whose watermark it advances. Keyed by the concrete + * permission string (`HealthPermission.getWritePermission(RecordClass)`) so it can never drift + * from [HealthConnectPermissions]. All 16 requested permissions are covered. + */ + val PERMISSION_GROUP: Map = mapOf( + // VITALS — one shared watermark across all eight measurement kinds. + HealthConnectPermissions.heartRate.first() to V, + HealthConnectPermissions.oxygenSaturation.first() to V, + HealthConnectPermissions.heartRateVariability.first() to V, + HealthConnectPermissions.bodyTemperature.first() to V, + HealthConnectPermissions.bloodGlucose.first() to V, + HealthConnectPermissions.respiratoryRate.first() to V, + HealthConnectPermissions.vo2Max.first() to V, + HealthConnectPermissions.bloodPressure.first() to V, + // SLEEP. + HealthConnectPermissions.sleep.first() to S, + // ACTIVITY — three independently grantable metrics, one watermark. + HealthConnectPermissions.steps.first() to A, + HealthConnectPermissions.activeCalories.first() to A, + HealthConnectPermissions.distance.first() to A, + // WORKOUTS — session + embedded route, one watermark. + HealthConnectPermissions.exercise.first() to W, + HealthConnectPermissions.exerciseRoute.first() to W, + // NUTRITION + RESTING_HR — single-permission groups (null-and-backfill-on-grant). + HealthConnectPermissions.nutrition.first() to N, + HealthConnectPermissions.restingHeartRate.first() to R, + ) + + /** + * The live granted set, reduced to the permissions this app actually requests and stored in + * [HealthConnectPrefs.lastGrantedPermissions]. Every reconcile path funnels through this so + * the stored set has one definition (review pass 5): the permission-sheet callback filtered + * its result to [HealthConnectPermissions.all] while `onAppStart` and the settings screen + * stored `getGrantedPermissions()` verbatim, so a health permission granted outside `all` + * would have made the two disagree and looked like a grow/shrink on every reconcile. + */ + fun storedSetOf(granted: Collection): List = + granted.filter { it in HealthConnectPermissions.all }.sorted() + + /** The distinct watermark groups a set of permissions belongs to. */ + fun groupsFor(permissions: Collection): Set = + permissions.mapNotNull { PERMISSION_GROUP[it] }.toSet() + + /** + * The single export group a settings-screen row belongs to (each row's permissions map to one + * group by construction; null on the impossible 0-or-many case). Used to reset the VITALS + * watermark when a vitals kind is re-enabled — the toggle-side equivalent of the permission + * grow-reset — so the readings recorded while it was off backfill (its shared group watermark + * had advanced past them). + */ + fun groupFor(row: HealthConnectPermissions.DataTypeRow): HealthConnectWatermarks.Key? = + HealthConnectPermissions.permissionsForRow(row).mapNotNull { PERMISSION_GROUP[it] }.toSet().singleOrNull() + + /** What changed between two granted sets, after the automatic grow-reset has been applied. */ + data class Outcome( + /** Newly granted permissions. */ + val grew: Set, + /** The groups whose watermarks were reset because [grew] is non-empty. */ + val grewGroups: Set, + /** Permissions that were revoked. */ + val revoked: Set, + /** True when nothing is granted now but something was before — the settings screen offers + * a watermark reset so a later re-grant re-exports. */ + val allRevoked: Boolean, + ) + + /** + * Diff [previous] against [current] and, when the set grew, reset the watermarks of the groups + * the new permissions belong to. Does NOT store [current] — the caller persists + * `lastGrantedPermissions` (keeps this testable and single-responsibility). Idempotent: + * a no-op when nothing grew. + */ + fun reconcile( + previous: Set, + current: Set, + store: HealthConnectPrefsStore, + ): Outcome { + val grew = current - previous + val revoked = previous - current + val grewGroups = if (grew.isEmpty()) emptySet() else groupsFor(grew) + if (grewGroups.isNotEmpty()) store.resetWatermarks(grewGroups) + return Outcome(grew, grewGroups, revoked, current.isEmpty() && previous.isNotEmpty()) + } + + /** + * App-start hook (plan: "on app start … diff against getGrantedPermissions()"). Guarded so the + * common not-applicable path never touches the client: only runs when the export is enabled and + * the provider is available. On a grow it resets the affected watermarks and enqueues a pass — + * including a re-grant made after a full revocation: the stored set is then empty, so this must + * NOT early-return on an empty stored set, or that grow (and its backfilling watermark reset) + * is never seen unless the user happens to open Settings. A full revocation itself is left for + * the settings screen to surface (no UI here). [scope] is the caller's lifecycle scope so the + * work cancels with it. + */ + fun onAppStart(context: Context, scope: CoroutineScope) { + val appContext = context.applicationContext + val store = HealthConnectPrefsStore.get(appContext) + val prefs = store.current + if (!prefs.enabled) return + if (HealthConnectSdk.availability(appContext) != HealthConnectAvailability.AVAILABLE) return + scope.launch { + val client = runCatching { HealthConnectClient.getOrCreate(appContext) }.getOrNull() ?: return@launch + val granted = runCatching { client.permissionController.getGrantedPermissions() } + .getOrNull() ?: return@launch + val live = storedSetOf(granted) + val outcome = reconcile(prefs.lastGrantedPermissions.toSet(), live.toSet(), store) + store.update { + it.copy( + lastGrantedPermissions = live, + // A grow re-opens the one-shot revocation offer (matching the settings/launcher + // paths), so a later full revocation can still surface it even when this grow + // was detected out-of-band here. + revocationOfferDismissed = + if (outcome.grewGroups.isEmpty()) it.revocationOfferDismissed else false, + ) + } + if (outcome.grewGroups.isNotEmpty()) HealthConnectExportWorker.enqueue(appContext) + } + } +} diff --git a/app/src/main/java/com/pulseloop/health/HealthConnectPermissions.kt b/app/src/main/java/com/pulseloop/health/HealthConnectPermissions.kt new file mode 100644 index 0000000..925486e --- /dev/null +++ b/app/src/main/java/com/pulseloop/health/HealthConnectPermissions.kt @@ -0,0 +1,130 @@ +package com.pulseloop.health + +import androidx.health.connect.client.permission.HealthPermission +import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord +import androidx.health.connect.client.records.BodyTemperatureRecord +import androidx.health.connect.client.records.BloodGlucoseRecord +import androidx.health.connect.client.records.BloodPressureRecord +import androidx.health.connect.client.records.DistanceRecord +import androidx.health.connect.client.records.ExerciseSessionRecord +import androidx.health.connect.client.records.HeartRateRecord +import androidx.health.connect.client.records.HeartRateVariabilityRmssdRecord +import androidx.health.connect.client.records.NutritionRecord +import androidx.health.connect.client.records.OxygenSaturationRecord +import androidx.health.connect.client.records.RespiratoryRateRecord +import androidx.health.connect.client.records.RestingHeartRateRecord +import androidx.health.connect.client.records.SleepSessionRecord +import androidx.health.connect.client.records.StepsRecord +import androidx.health.connect.client.records.Vo2MaxRecord + +/** + * Health Connect write permissions grouped by logical data type, derived from the record + * classes via `HealthPermission.getWritePermission` — never hardcoded strings + * (docs/health-connect-integration.md §3). + * + * Only the Phases 1–4 data types live here (and in the manifest). Phase 5 types — blood + * pressure, glucose, respiratory rate, VO2max, resting heart rate, nutrition — join this + * object, the manifest, and the permission sheet only when Phase 5 lands, on purpose: a + * narrower first permission sheet is better UX. No READ_* permissions anywhere — the export + * is write-only. + * + * The exercise route is an embedded field of [ExerciseSessionRecord], not a standalone + * record: the library exposes its permission as the `PERMISSION_WRITE_EXERCISE_ROUTE` + * constant. Singular on purpose — the official docs note `READ_EXERCISE_ROUTES` is the + * plural one. + */ +object HealthConnectPermissions { + + /** Logical data-type rows on the settings screen, in display order. */ + enum class DataTypeRow { + HEART_RATE, + OXYGEN_SATURATION, + HEART_RATE_VARIABILITY, + BODY_TEMPERATURE, + SLEEP, + STEPS_AND_ACTIVITY, + WORKOUTS, + NUTRITION, + BLOOD_PRESSURE, + BLOOD_GLUCOSE, + RESPIRATORY_RATE, + VO2_MAX, + RESTING_HEART_RATE, + } + + val heartRate: Set = setOf(HealthPermission.getWritePermission(HeartRateRecord::class)) + val oxygenSaturation: Set = setOf(HealthPermission.getWritePermission(OxygenSaturationRecord::class)) + val heartRateVariability: Set = setOf(HealthPermission.getWritePermission(HeartRateVariabilityRmssdRecord::class)) + val bodyTemperature: Set = setOf(HealthPermission.getWritePermission(BodyTemperatureRecord::class)) + val sleep: Set = setOf(HealthPermission.getWritePermission(SleepSessionRecord::class)) + val steps: Set = setOf(HealthPermission.getWritePermission(StepsRecord::class)) + val activeCalories: Set = setOf(HealthPermission.getWritePermission(ActiveCaloriesBurnedRecord::class)) + val distance: Set = setOf(HealthPermission.getWritePermission(DistanceRecord::class)) + val exercise: Set = setOf(HealthPermission.getWritePermission(ExerciseSessionRecord::class)) + val exerciseRoute: Set = setOf(HealthPermission.PERMISSION_WRITE_EXERCISE_ROUTE) + + // Phase 5 (beyond iOS) — declared and requested from this phase on. + val nutrition: Set = setOf(HealthPermission.getWritePermission(NutritionRecord::class)) + val bloodPressure: Set = setOf(HealthPermission.getWritePermission(BloodPressureRecord::class)) + val bloodGlucose: Set = setOf(HealthPermission.getWritePermission(BloodGlucoseRecord::class)) + val respiratoryRate: Set = setOf(HealthPermission.getWritePermission(RespiratoryRateRecord::class)) + val vo2Max: Set = setOf(HealthPermission.getWritePermission(Vo2MaxRecord::class)) + val restingHeartRate: Set = setOf(HealthPermission.getWritePermission(RestingHeartRateRecord::class)) + + /** Every write permission the app requests via the master toggle. */ + val all: Set = buildSet { + addAll(heartRate) + addAll(oxygenSaturation) + addAll(heartRateVariability) + addAll(bodyTemperature) + addAll(sleep) + addAll(steps) + addAll(activeCalories) + addAll(distance) + addAll(exercise) + addAll(exerciseRoute) + addAll(nutrition) + addAll(bloodPressure) + addAll(bloodGlucose) + addAll(respiratoryRate) + addAll(vo2Max) + addAll(restingHeartRate) + } + + /** The permissions backing one settings-screen row. */ + fun permissionsForRow(row: DataTypeRow): Set = when (row) { + DataTypeRow.HEART_RATE -> heartRate + DataTypeRow.OXYGEN_SATURATION -> oxygenSaturation + DataTypeRow.HEART_RATE_VARIABILITY -> heartRateVariability + DataTypeRow.BODY_TEMPERATURE -> bodyTemperature + DataTypeRow.SLEEP -> sleep + DataTypeRow.STEPS_AND_ACTIVITY -> steps + activeCalories + distance + DataTypeRow.WORKOUTS -> exercise + exerciseRoute + DataTypeRow.NUTRITION -> nutrition + DataTypeRow.BLOOD_PRESSURE -> bloodPressure + DataTypeRow.BLOOD_GLUCOSE -> bloodGlucose + DataTypeRow.RESPIRATORY_RATE -> respiratoryRate + DataTypeRow.VO2_MAX -> vo2Max + DataTypeRow.RESTING_HEART_RATE -> restingHeartRate + } + + /** + * The single write permission per measurement kind, for the exporter's per-kind check + * (plan: "each pass re-checks its own record class against the granted set"). Keys are the + * exporter's `kindKey` tokens (the short id tokens, e.g. "hr", "bp") - NOT the + * `MeasurementEntity.kindRaw` column values (those are the `.name` strings like "HEART_RATE"; + * VitalsExporter's kindKey → kindRaw map bridges the two). The orchestrator looks this up by + * kindKey, so one entry covers a paired record's both source rows ("bp" → both sys + dia). + */ + val WRITE_PERMISSION_BY_KIND: Map = mapOf( + "hr" to heartRate.first(), + "spo2" to oxygenSaturation.first(), + "hrv" to heartRateVariability.first(), + "temp" to bodyTemperature.first(), + // Phase 5 measurement-based kinds (see VitalsExporter's kindKey → kindRaw mapping). + "glucose" to bloodGlucose.first(), + "resp_rate" to respiratoryRate.first(), + "vo2max" to vo2Max.first(), + "bp" to bloodPressure.first(), + ) +} diff --git a/app/src/main/java/com/pulseloop/health/HealthConnectPrefsStore.kt b/app/src/main/java/com/pulseloop/health/HealthConnectPrefsStore.kt new file mode 100644 index 0000000..3473d15 --- /dev/null +++ b/app/src/main/java/com/pulseloop/health/HealthConnectPrefsStore.kt @@ -0,0 +1,356 @@ +package com.pulseloop.health + +import android.content.Context +import android.content.SharedPreferences +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** + * What PulseLoop exports to Health Connect and how far the last export got — the Android port + * of iOS `AppleHealthPrefsStore` (docs/health-connect-integration.md §3). + * + * Mirrors [com.pulseloop.ui.dashboard.MetricPrefsStore]: a JSON blob behind a StateFlow. A + * second key holds the watermarks, so their (frequent, mid-backfill) writes don't rewrite the + * settings blob. Non-sensitive sync state, so the plain shared prefs file — not the encrypted + * store. + */ +@Serializable +data class HealthConnectPrefs( + /** Master toggle; defaults OFF (iOS parity). */ + val enabled: Boolean = false, + // Per-data-type toggles; all default ON under the master toggle (iOS parity). + val heartRate: Boolean = true, + val oxygenSaturation: Boolean = true, + val heartRateVariability: Boolean = true, + val bodyTemperature: Boolean = true, + val sleep: Boolean = true, + val stepsAndActivity: Boolean = true, + val workouts: Boolean = true, + val nutrition: Boolean = true, + // Phase 5 data types (beyond iOS); all default ON under the master toggle (iOS parity). + val bloodPressure: Boolean = true, + val bloodGlucose: Boolean = true, + val respiratoryRate: Boolean = true, + val vo2Max: Boolean = true, + val restingHeartRate: Boolean = true, + /** + * First-enable backfill choice. Exports are hard-gated on this in Phase 1: nothing runs + * while it is [BackfillChoice.NOT_ASKED]. + */ + val backfillChoice: BackfillChoice = BackfillChoice.NOT_ASKED, + val lastSyncAt: Long? = null, + val lastSyncSummary: String? = null, + /** Write permissions granted at the last permission-sheet result; Phase 6 diffs this to detect revocation. */ + val lastGrantedPermissions: List = emptyList(), + /** + * One-time Phase 4 marker: true after the first pass on a build where netting is live reset + * the ACTIVITY + WORKOUTS watermarks (see [HealthConnectExporter.run]) so daily records + * exported under the Phase 3 build — un-netted, because [com.pulseloop.health.HealthConnectExporter.WORKOUTS_EXPORTED] + * was false — get re-upserted with their netted values now that the workout siblings they + * compensate for are being written. Old blobs without this key decode to false (tolerant + * decode), which is exactly the "still needs the flip" state for upgrading users. + */ + val nettingFlipDone: Boolean = false, + /** + * Phase 6: one-shot flag for the full-revocation reset offer (Gadgetbridge pattern). Set when + * the user declines ("Not now") or confirms the reset; cleared on a later re-grant (a grow) so + * a future full revocation offers again. Prevents re-offering on every settings open while the + * revoked state persists. + */ + val revocationOfferDismissed: Boolean = false, + /** + * One-time marker for the first-enable "Only new data from now on" watermark stamp (see + * [HealthConnectExporter.run]). true after the first pass stamps every group's watermark to + * now for an EXPORT_NEW_ONLY choice. Deliberately NOT inferred from a null watermark: a + * Phase 6 grow-reset also nulls the VITALS watermark when a permission is granted out of + * band, and inferring "first enable" from that would re-stamp every group to now and + * silently drop the rows pending between the reset and the next pass. "Remove PulseLoop + * data" resets this to false so a fresh re-enable re-stamps. + */ + val newOnlyStamped: Boolean = false, + /** + * The instant an [BackfillChoice.EXPORT_NEW_ONLY] user's "only new data from now on" consent + * took effect — the pass that first stamped every group's watermark to now for that choice + * records its timestamp here (see [HealthConnectExporter.run]). [resetWatermarks] clamps a + * NEW_ONLY group reset to this instant instead of null: a null group watermark means "export + * from epoch", which would re-export the pre-consent history the user explicitly declined. + * The exporter also clamps every SELECT watermark to this instant at the read site + * ([HealthConnectExporter.effectiveWatermark]), which makes every watermark-nulling path safe + * by construction. "Remove PulseLoop data" clears it so a fresh re-enable re-stamps. + */ + val newOnlyConsentAt: Long? = null, + /** + * Progress/outcome of the last "Remove PulseLoop data" run, surfaced on the settings screen. + * Persisted rather than held in composable state because the removal now runs in + * [HealthConnectRemovalWorker] — it outlives the screen, so its result has to survive + * navigation, rotation and process death to be reportable at all. + * [REMOVAL_IN_PROGRESS] is the sentinel for "running"; anything else is a finished message. + */ + val removalStatus: String? = null, +) { + @Serializable + enum class BackfillChoice { NOT_ASKED, EXPORT_ALL, EXPORT_NEW_ONLY } + + /** Any granted permission counts as connected — partial grants are first-class. */ + val isConnected: Boolean get() = lastGrantedPermissions.isNotEmpty() + + fun toggleFor(row: HealthConnectPermissions.DataTypeRow): Boolean = when (row) { + HealthConnectPermissions.DataTypeRow.HEART_RATE -> heartRate + HealthConnectPermissions.DataTypeRow.OXYGEN_SATURATION -> oxygenSaturation + HealthConnectPermissions.DataTypeRow.HEART_RATE_VARIABILITY -> heartRateVariability + HealthConnectPermissions.DataTypeRow.BODY_TEMPERATURE -> bodyTemperature + HealthConnectPermissions.DataTypeRow.SLEEP -> sleep + HealthConnectPermissions.DataTypeRow.STEPS_AND_ACTIVITY -> stepsAndActivity + HealthConnectPermissions.DataTypeRow.WORKOUTS -> workouts + HealthConnectPermissions.DataTypeRow.NUTRITION -> nutrition + HealthConnectPermissions.DataTypeRow.BLOOD_PRESSURE -> bloodPressure + HealthConnectPermissions.DataTypeRow.BLOOD_GLUCOSE -> bloodGlucose + HealthConnectPermissions.DataTypeRow.RESPIRATORY_RATE -> respiratoryRate + HealthConnectPermissions.DataTypeRow.VO2_MAX -> vo2Max + HealthConnectPermissions.DataTypeRow.RESTING_HEART_RATE -> restingHeartRate + } + + fun withToggleFor(row: HealthConnectPermissions.DataTypeRow, value: Boolean): HealthConnectPrefs = + when (row) { + HealthConnectPermissions.DataTypeRow.HEART_RATE -> copy(heartRate = value) + HealthConnectPermissions.DataTypeRow.OXYGEN_SATURATION -> copy(oxygenSaturation = value) + HealthConnectPermissions.DataTypeRow.HEART_RATE_VARIABILITY -> copy(heartRateVariability = value) + HealthConnectPermissions.DataTypeRow.BODY_TEMPERATURE -> copy(bodyTemperature = value) + HealthConnectPermissions.DataTypeRow.SLEEP -> copy(sleep = value) + HealthConnectPermissions.DataTypeRow.STEPS_AND_ACTIVITY -> copy(stepsAndActivity = value) + HealthConnectPermissions.DataTypeRow.WORKOUTS -> copy(workouts = value) + HealthConnectPermissions.DataTypeRow.NUTRITION -> copy(nutrition = value) + HealthConnectPermissions.DataTypeRow.BLOOD_PRESSURE -> copy(bloodPressure = value) + HealthConnectPermissions.DataTypeRow.BLOOD_GLUCOSE -> copy(bloodGlucose = value) + HealthConnectPermissions.DataTypeRow.RESPIRATORY_RATE -> copy(respiratoryRate = value) + HealthConnectPermissions.DataTypeRow.VO2_MAX -> copy(vo2Max = value) + HealthConnectPermissions.DataTypeRow.RESTING_HEART_RATE -> copy(restingHeartRate = value) + } + + companion object { + val DEFAULT = HealthConnectPrefs() + + /** [removalStatus] sentinel: a removal is enqueued or running. */ + const val REMOVAL_IN_PROGRESS = "in_progress" + } +} + +/** + * Export watermarks, one per export group. Vitals watermark on `Measurement.createdAt` — + * deliberately NOT the sample timestamp, so late-arriving ring history is still picked up; + * the other groups watermark on the row's `updatedAt` (iOS `AppleHealthSyncState` + * semantics). null = never exported. + */ +@Serializable +data class HealthConnectWatermarks( + val vitals: Long? = null, + val sleep: Long? = null, + val activity: Long? = null, + val workouts: Long? = null, + val nutrition: Long? = null, + val restingHr: Long? = null, +) { + enum class Key { VITALS, SLEEP, ACTIVITY, WORKOUTS, NUTRITION, RESTING_HR } + + fun get(key: Key): Long? = when (key) { + Key.VITALS -> vitals + Key.SLEEP -> sleep + Key.ACTIVITY -> activity + Key.WORKOUTS -> workouts + Key.NUTRITION -> nutrition + Key.RESTING_HR -> restingHr + } + + fun copyWith(key: Key, value: Long): HealthConnectWatermarks = when (key) { + Key.VITALS -> copy(vitals = value) + Key.SLEEP -> copy(sleep = value) + Key.ACTIVITY -> copy(activity = value) + Key.WORKOUTS -> copy(workouts = value) + Key.NUTRITION -> copy(nutrition = value) + Key.RESTING_HR -> copy(restingHr = value) + } + + companion object { + val DEFAULT = HealthConnectWatermarks() + } +} + +class HealthConnectPrefsStore internal constructor(private val prefsStore: SharedPreferences) { + + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + + private val _prefs = MutableStateFlow(load()) + val prefs: StateFlow = _prefs.asStateFlow() + val current: HealthConnectPrefs get() = _prefs.value + + private val _watermarks = MutableStateFlow(loadWatermarks()) + val watermarks: StateFlow = _watermarks.asStateFlow() + val currentWatermarks: HealthConnectWatermarks get() = _watermarks.value + + /** + * Serializes every read-modify-write below (review pass 4 MINOR). All the mutators are + * read-modify-write over a single JSON blob, and there are genuinely concurrent writers: + * [HealthConnectExportWorker] stamps `lastSyncAt`/`lastSyncSummary` and advances watermarks + * on the worker dispatcher, [HealthConnectRemoval] writes from its own scope, and the + * settings screen writes toggles / `enabled` / `backfillChoice` from the main thread. A plain + * `_prefs.value = transform(current)` has no compare-and-set, so two writers that read the + * same snapshot silently drop each other's fields — e.g. a data-type Switch flipped off just + * as a long `EXPORT_ALL` backfill pass finishes snaps back on and re-exports that type. + * + * The lock spans the persist as well as the in-memory assignment, so the on-disk blob can + * never end up ordered differently from the StateFlow (a lost disk write would resurrect the + * dropped field on the next process start). + */ + private val writeLock = Any() + + init { + repairMissingConsentInstant() + } + + fun update(transform: (HealthConnectPrefs) -> HealthConnectPrefs) { + synchronized(writeLock) { + val prev = _prefs.value + val next = transform(prev) + if (next == prev) return + _prefs.value = next + prefsStore.edit().putString(KEY_PREFS, json.encodeToString(HealthConnectPrefs.serializer(), next)).apply() + } + } + + /** + * Back-fills [HealthConnectPrefs.newOnlyConsentAt] for an install that stamped its NEW_ONLY + * watermarks under a build that had [HealthConnectPrefs.newOnlyStamped] but not yet + * `newOnlyConsentAt` (review pass 4 NIT; branch/dogfood installs only — `origin/main` has no + * Health Connect code). Such a blob tolerantly decodes to `stamped = true, consentAt = null` + * with every watermark already stamped, so the sentinel never re-fires and nothing would ever + * populate the consent instant: [HealthConnectExporter.effectiveWatermark] would degrade to + * the unclamped `stored ?: 0` permanently, and the first watermark-nulling path (the + * revocation dialog's "Reset export", or a grow-reset) would export the full pre-consent + * history the user declined. + * + * The recovery is the inverse of the stamp: the sentinel wrote the same instant to all six + * groups, so the oldest surviving watermark is that instant (or later, if a group has since + * advanced) — taking the minimum reconstructs the consent boundary without ever placing it + * earlier than the real one, which is the direction that would leak history. Runs only when + * there is something to reconstruct from; a fully-cleared blob leaves the flag alone, and the + * next sentinel pass re-stamps normally. + */ + private fun repairMissingConsentInstant() { + val p = _prefs.value + if (p.backfillChoice != HealthConnectPrefs.BackfillChoice.EXPORT_NEW_ONLY) return + if (!p.newOnlyStamped || p.newOnlyConsentAt != null) return + val marks = _watermarks.value + val oldest = HealthConnectWatermarks.Key.values().mapNotNull { marks.get(it) }.minOrNull() ?: return + update { it.copy(newOnlyConsentAt = oldest) } + } + + /** + * Advance a watermark. Only ever moves forward: an interrupted backfill resumes, never + * re-exports, and a crash mid-chunk cannot push the watermark backwards (plan §3: + * "Advance the watermark only to a timestamp that actually reached Health Connect, and + * never rewind"). + */ + fun setWatermark(key: HealthConnectWatermarks.Key, value: Long) { + synchronized(writeLock) { + val cur = _watermarks.value + val existing = cur.get(key) + if (existing != null && value <= existing) return + val next = cur.copyWith(key, value) + _watermarks.value = next + prefsStore.edit().putString(KEY_WATERMARKS, json.encodeToString(HealthConnectWatermarks.serializer(), next)).apply() + } + } + + /** + * Reset specific groups' watermarks to null ("never exported"). The monotonic + * [setWatermark] can never rewind, so a deliberate re-export — the Phase 4 netting-flip + * reset and Phase 6's permission/revocation resets — goes through here. Records re-exported + * after a reset upsert under the same clientRecordIds, so the pass stays idempotent. + * + * **EXPORT_NEW_ONLY consent clamp (review MAJOR):** a null group watermark means "export from + * epoch" (the exporters select on `createdSince(kind, watermark ?: 0)`). For a user who + * chose "only new data from now on", a grow-reset that nulled their watermark would re-export + * the pre-consent history they explicitly declined — the Phase 4 netting flip is deliberately + * gated on `EXPORT_ALL` for exactly this reason. So when the backfill choice is + * [HealthConnectPrefs.BackfillChoice.EXPORT_NEW_ONLY], the named groups are reset to the + * consent instant ([HealthConnectPrefs.newOnlyConsentAt], the pass that first stamped them) + * instead of null: the re-granted / re-enabled types backfill their post-consent rows and + * nothing older. `consent` is null for an `EXPORT_ALL`/`NOT_ASKED` choice — and for a + * NEW_ONLY user whose sentinel has not stamped yet (all watermarks null, nothing to clamp) — + * preserving the original null-and-backfill-from-epoch behaviour there. + */ + fun resetWatermarks(keys: Set) { + if (keys.isEmpty()) return + synchronized(writeLock) { + val prefs = _prefs.value + val consent: Long? = + if (prefs.backfillChoice == HealthConnectPrefs.BackfillChoice.EXPORT_NEW_ONLY) prefs.newOnlyConsentAt else null + val cur = _watermarks.value + // copyWith can't null a key; rebuild the blob with the reset keys nulled (or clamped to the + // consent instant for EXPORT_NEW_ONLY). + val next = HealthConnectWatermarks( + vitals = if (HealthConnectWatermarks.Key.VITALS in keys) consent else cur.vitals, + sleep = if (HealthConnectWatermarks.Key.SLEEP in keys) consent else cur.sleep, + activity = if (HealthConnectWatermarks.Key.ACTIVITY in keys) consent else cur.activity, + workouts = if (HealthConnectWatermarks.Key.WORKOUTS in keys) consent else cur.workouts, + nutrition = if (HealthConnectWatermarks.Key.NUTRITION in keys) consent else cur.nutrition, + restingHr = if (HealthConnectWatermarks.Key.RESTING_HR in keys) consent else cur.restingHr, + ) + _watermarks.value = next + prefsStore.edit().putString(KEY_WATERMARKS, json.encodeToString(HealthConnectWatermarks.serializer(), next)).apply() + } + } + + + /** Clear every watermark (iOS `removeAllExportedData`; Phase 6 revocation reset). */ + fun clearWatermarks() { + synchronized(writeLock) { + val next = HealthConnectWatermarks.DEFAULT + _watermarks.value = next + prefsStore.edit().putString(KEY_WATERMARKS, json.encodeToString(HealthConnectWatermarks.serializer(), next)).apply() + } + } + + private fun load(): HealthConnectPrefs { + val raw = prefsStore.getString(KEY_PREFS, null) ?: return HealthConnectPrefs.DEFAULT + // Tolerant decode: a blob written before a field existed falls back to per-field + // defaults, and a blob containing a key we don't know yet decodes without wiping + // the known fields (ignoreUnknownKeys) — a new field must never reset the user's + // existing choices. + return try { + json.decodeFromString(HealthConnectPrefs.serializer(), raw) + } catch (_: Exception) { + HealthConnectPrefs.DEFAULT + } + } + + private fun loadWatermarks(): HealthConnectWatermarks { + val raw = prefsStore.getString(KEY_WATERMARKS, null) ?: return HealthConnectWatermarks.DEFAULT + return try { + json.decodeFromString(HealthConnectWatermarks.serializer(), raw) + } catch (_: Exception) { + HealthConnectWatermarks.DEFAULT + } + } + + companion object { + private const val KEY_PREFS = "pulseloop.healthconnect.v1" + private const val KEY_WATERMARKS = "pulseloop.healthconnect.watermarks.v1" + // Same shared file as MetricPrefsStore — the app's plain (non-encrypted) UI prefs. + private const val FILE = "pulseloop_prefs" + + @Volatile + private var instance: HealthConnectPrefsStore? = null + + /** Process-wide shared instance so Settings and the (Phase 1) exporter mutate the same flows. */ + fun get(context: Context): HealthConnectPrefsStore = + instance ?: synchronized(this) { + instance ?: HealthConnectPrefsStore( + context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE) + ).also { instance = it } + } + } +} diff --git a/app/src/main/java/com/pulseloop/health/HealthConnectRationaleActivity.kt b/app/src/main/java/com/pulseloop/health/HealthConnectRationaleActivity.kt new file mode 100644 index 0000000..5083810 --- /dev/null +++ b/app/src/main/java/com/pulseloop/health/HealthConnectRationaleActivity.kt @@ -0,0 +1,49 @@ +package com.pulseloop.health + +import android.os.Bundle +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView +import androidx.activity.ComponentActivity + +/** + * Target of the "privacy policy" link in the Health Connect permission sheet — mandatory for + * any app requesting health data types (official get-started guide). Pre-34 devices fire + * `androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE` at this activity directly; API 34+ + * reaches the `ViewPermissionUsageActivity` alias (manifest, guarded by + * START_VIEW_PERMISSION_USAGE), which targets this same screen. + * + * In-app rationale, no hosted URL — the Gadgetbridge precedent for sideload-only + * distribution (docs/health-connect-integration.md §2, caveat 2). + */ +class HealthConnectRationaleActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val title = TextView(this).apply { + text = "How PulseLoop uses Health Connect" + textSize = 18f + setPadding(0, 0, 0, dp(12)) + } + val body = TextView(this).apply { + text = """ + PulseLoop can export your health data to Health Connect, so other apps and dashboards you choose can show it. The data types it writes are: heart rate, resting heart rate, oxygen saturation, heart rate variability, body temperature, respiratory rate, blood pressure, blood glucose, VO₂ max, sleep, steps, activity calories and distance, finished workouts with GPS routes, and logged meals (nutrition). + + Most of these come from your ring; blood pressure, blood glucose and meals are values you log yourself in PulseLoop. + + · The export is write-only: PulseLoop never reads data back from Health Connect. + · Records carry a stable PulseLoop identifier, so re-exporting the same reading replaces it instead of duplicating it. + · You control the export: the master toggle and per-data-type switches in Settings → Health Connect, and you can revoke any permission at any time in the Health Connect app. + """.trimIndent() + } + val column = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(24), dp(24), dp(24), dp(24)) + addView(title) + addView(body) + } + setContentView(ScrollView(this).apply { addView(column) }) + } + + private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() +} diff --git a/app/src/main/java/com/pulseloop/health/HealthConnectRemoval.kt b/app/src/main/java/com/pulseloop/health/HealthConnectRemoval.kt new file mode 100644 index 0000000..a826ba3 --- /dev/null +++ b/app/src/main/java/com/pulseloop/health/HealthConnectRemoval.kt @@ -0,0 +1,140 @@ +package com.pulseloop.health + +import android.content.Context +import android.util.Log +import androidx.health.connect.client.HealthConnectClient +import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord +import androidx.health.connect.client.records.BodyTemperatureRecord +import androidx.health.connect.client.records.BloodGlucoseRecord +import androidx.health.connect.client.records.BloodPressureRecord +import androidx.health.connect.client.records.DistanceRecord +import androidx.health.connect.client.records.ExerciseSessionRecord +import androidx.health.connect.client.records.HeartRateRecord +import androidx.health.connect.client.records.HeartRateVariabilityRmssdRecord +import androidx.health.connect.client.records.NutritionRecord +import androidx.health.connect.client.records.OxygenSaturationRecord +import androidx.health.connect.client.records.Record +import androidx.health.connect.client.records.RespiratoryRateRecord +import androidx.health.connect.client.records.RestingHeartRateRecord +import androidx.health.connect.client.records.SleepSessionRecord +import androidx.health.connect.client.records.StepsRecord +import androidx.health.connect.client.records.Vo2MaxRecord +import androidx.health.connect.client.time.TimeRangeFilter +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.time.Instant +import kotlin.reflect.KClass + +/** + * "Remove PulseLoop data from Health Connect" (iOS `removeAllExportedData` parity; + * docs/health-connect-integration.md §4 Phase 6). + * + * Deletes every record PulseLoop wrote — [HealthConnectClient.deleteRecords] by record type over + * the full time range — then clears all watermarks + the last-sync stamp so the export state + * matches an empty Health store (a later re-grant / re-enable starts a fresh backfill). + * + * Scoping: Health Connect attributes each record to the app that wrote it (the data source) and + * the platform filters `deleteRecords(type, range)` to records "belonging to the calling + * application" (1.1.0 client KDoc), so this removes only the records PulseLoop wrote — never + * another app's records of the same type. Only the WRITE permission for a type is needed; + * READ is not (verified against the 1.1.0 enforcement path). Each type is deleted only if its + * WRITE permission is still granted — both to avoid a SecurityException mid-removal and because + * we could only ever have written types we were granted. + * + * Concurrency: a pending export pass is cancelled, and then the process-wide + * [HealthConnectExportWorker.passMutex] is taken so an already-RUNNING pass is waited out rather + * than raced (review pass 5 — `cancelUniqueWork` only records the cancellation; the worker keeps + * going until its next suspension point, so on its own it let a pass re-write records that had + * just been deleted and let a `setWatermark` land after `clearWatermarks()`). Together they are + * the iOS isSyncing-latch analogue. + * + * Atomicity: the delete loop and the state reset run under [NonCancellable]. A half-finished + * removal — some record types deleted, watermarks still claiming they were exported — is the one + * outcome that cannot be repaired from a write-only client, so once deletion starts it finishes. + * Callers should run this from [HealthConnectRemovalWorker], not a UI scope. + * + * Per-type failures are logged and skipped so one bad type cannot sink the rest (the same + * "one bad record must not sink the chunk" rule the exporters apply). + */ +object HealthConnectRemoval { + + private const val TAG = "HealthConnectRemoval" + + /** + * The 15 record types PulseLoop writes (the exercise route is an embedded field of + * [ExerciseSessionRecord], not a standalone record), each paired with the single WRITE + * permission that guards it. Deletion is attempted only for granted types. + */ + private val RECORD_TYPES: List, String>> = listOf( + HeartRateRecord::class to HealthConnectPermissions.heartRate.first(), + OxygenSaturationRecord::class to HealthConnectPermissions.oxygenSaturation.first(), + HeartRateVariabilityRmssdRecord::class to HealthConnectPermissions.heartRateVariability.first(), + BodyTemperatureRecord::class to HealthConnectPermissions.bodyTemperature.first(), + SleepSessionRecord::class to HealthConnectPermissions.sleep.first(), + StepsRecord::class to HealthConnectPermissions.steps.first(), + ActiveCaloriesBurnedRecord::class to HealthConnectPermissions.activeCalories.first(), + DistanceRecord::class to HealthConnectPermissions.distance.first(), + ExerciseSessionRecord::class to HealthConnectPermissions.exercise.first(), + NutritionRecord::class to HealthConnectPermissions.nutrition.first(), + BloodPressureRecord::class to HealthConnectPermissions.bloodPressure.first(), + BloodGlucoseRecord::class to HealthConnectPermissions.bloodGlucose.first(), + RespiratoryRateRecord::class to HealthConnectPermissions.respiratoryRate.first(), + Vo2MaxRecord::class to HealthConnectPermissions.vo2Max.first(), + RestingHeartRateRecord::class to HealthConnectPermissions.restingHeartRate.first(), + ) + + /** Outcome of a removal, for the settings screen to surface. */ + data class RemovalResult(val deletedTypes: Int, val skippedUngranted: Int, val failedTypes: Int) + + suspend fun removeAll(context: Context, client: HealthConnectClient, store: HealthConnectPrefsStore): RemovalResult { + // Drop anything queued, then wait out a pass that is already running (see the KDoc). + HealthConnectExportWorker.cancel(context) + return HealthConnectExportWorker.passMutex.withLock { + withContext(NonCancellable) { removeAllLocked(client, store) } + } + } + + private suspend fun removeAllLocked(client: HealthConnectClient, store: HealthConnectPrefsStore): RemovalResult { + val granted = client.permissionController.getGrantedPermissions() + // Everything we ever wrote: from the epoch to now (Health Connect requires a range). + val range = TimeRangeFilter.after(Instant.EPOCH) + var deleted = 0 + var skipped = 0 + var failed = 0 + for ((type, permission) in RECORD_TYPES) { + if (permission !in granted) { + skipped++ + continue // never wrote it (no grant) -> nothing to delete; avoid a SecurityException + } + try { + client.deleteRecords(type, range) + deleted++ + } catch (e: Exception) { + // One failing type must not sink the rest: log and continue (the watermarks are + // still cleared below, so the next pass re-attempts anything that survived). + failed++ + Log.w(TAG, "deleteRecords(${type.simpleName}) failed", e) + } + } + // The export state must match the now-empty store: no watermarks, no last-sync stamp. The + // export is also turned OFF and the backfill choice + first-enable stamp marker reset + // ("reset the export to start fresh"): leaving it enabled with backfillChoice=EXPORT_ALL + // would let the very next background trigger (ring sync, app-start grow, settings open) + // re-export the whole history within ~15 s, silently undoing the destructive removal. + // Re-enabling re-offers the backfill dialog (the choice is back to NOT_ASKED) so the user + // picks fresh. + store.clearWatermarks() + store.update { + it.copy( + enabled = false, + backfillChoice = HealthConnectPrefs.BackfillChoice.NOT_ASKED, + newOnlyStamped = false, + newOnlyConsentAt = null, + lastSyncAt = null, + lastSyncSummary = null, + ) + } + return RemovalResult(deleted, skipped, failed) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/pulseloop/health/HealthConnectRemovalWorker.kt b/app/src/main/java/com/pulseloop/health/HealthConnectRemovalWorker.kt new file mode 100644 index 0000000..105da87 --- /dev/null +++ b/app/src/main/java/com/pulseloop/health/HealthConnectRemovalWorker.kt @@ -0,0 +1,82 @@ +package com.pulseloop.health + +import android.content.Context +import android.util.Log +import androidx.health.connect.client.HealthConnectClient +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters + +/** + * Runs "Remove PulseLoop data from Health Connect" ([HealthConnectRemoval.removeAll]). + * + * A worker rather than a coroutine in the settings screen's scope (review pass 5). The removal + * deletes 15 record classes and then resets the export state; run from a + * `rememberCoroutineScope()`, a back-press mid-delete cancelled it at the next `deleteRecords` + * suspension, leaving some types deleted, the rest alive, and `clearWatermarks()` never called — + * watermarks then claim records were exported that no longer exist, and write-only means nothing + * re-exports them. There is also nowhere to report the outcome once the screen is gone. A worker + * survives navigation, rotation and process death, and reports through + * [HealthConnectPrefs.removalStatus]. + * + * Not retried: the delete is idempotent, but a permanent failure would otherwise loop; the user + * gets an actionable message and the button back instead. + */ +class HealthConnectRemovalWorker( + context: Context, + params: WorkerParameters, +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + val store = HealthConnectPrefsStore.get(applicationContext) + if (HealthConnectSdk.availability(applicationContext) != HealthConnectAvailability.AVAILABLE) { + store.update { it.copy(removalStatus = "Health Connect isn't available right now. Try again.") } + return Result.success() + } + val client = runCatching { HealthConnectClient.getOrCreate(applicationContext) }.getOrNull() + if (client == null) { + store.update { it.copy(removalStatus = "Could not reach Health Connect. Try again.") } + return Result.success() + } + return try { + val result = HealthConnectRemoval.removeAll(applicationContext, client, store) + val message = when { + result.failedTypes > 0 -> + "Removed PulseLoop data, but ${result.failedTypes} type(s) could not be deleted — try again." + result.deletedTypes == 0 -> + "Nothing to remove: PulseLoop has no write permissions left, so it can't delete " + + "what it wrote. Delete it in the Health Connect app, or re-grant and try again." + else -> "Removed PulseLoop data from Health Connect." + } + store.update { it.copy(removalStatus = message) } + Log.i(TAG, "removal done: $message") + Result.success() + } catch (e: Exception) { + Log.w(TAG, "removal failed", e) + store.update { it.copy(removalStatus = "Could not remove PulseLoop data. Try again.") } + Result.success() + } + } + + companion object { + private const val TAG = "HealthConnectExport" + private const val WORK_NAME = "health_connect_removal" + + /** + * Enqueue a removal and mark it in progress. [ExistingWorkPolicy.KEEP] so a double-tap + * cannot start two concurrent removals; the running one already deletes everything. + */ + fun enqueue(context: Context) { + val appContext = context.applicationContext + HealthConnectPrefsStore.get(appContext) + .update { it.copy(removalStatus = HealthConnectPrefs.REMOVAL_IN_PROGRESS) } + WorkManager.getInstance(appContext).enqueueUniqueWork( + WORK_NAME, + ExistingWorkPolicy.KEEP, + OneTimeWorkRequestBuilder().build(), + ) + } + } +} diff --git a/app/src/main/java/com/pulseloop/health/HealthConnectTypeMappings.kt b/app/src/main/java/com/pulseloop/health/HealthConnectTypeMappings.kt new file mode 100644 index 0000000..b6f9c0d --- /dev/null +++ b/app/src/main/java/com/pulseloop/health/HealthConnectTypeMappings.kt @@ -0,0 +1,734 @@ +package com.pulseloop.health + +import androidx.health.connect.client.records.ExerciseSessionRecord +import androidx.health.connect.client.records.MealType +import androidx.health.connect.client.records.SleepSessionRecord +import com.pulseloop.ring.SleepStage +import java.time.Instant +import java.time.ZoneId +import java.time.ZoneOffset + +/** + * Pure identity + mapping helpers for the Health Connect export — the Android port of the iOS + * [HealthKitTypeMappings] sync-identifier scheme (docs/health-connect-integration.md §3 + * "`clientRecordId` scheme"). Deliberately free of [androidx.health.connect.client.HealthConnectClient] + * so the identity rules — the part that must stay stable or the upsert silently duplicates — are + * trivially unit-testable. + * + * Identity traps this module encodes (plan §3): + * - key instantaneous vitals on kind + sample instant, never on [com.pulseloop.data.entity.MeasurementEntity.id] + * (random UUID for live rows), so a reading that arrives once live and once via history + * collapses onto one record; + * - key heart-rate buckets on the local hour start, so a late sample re-upserts the whole hour + * instead of appending a second record. + */ +object HealthConnectTypeMappings { + + /** [com.pulseloop.data.entity.MeasurementEntity.sourceRaw] values that must never reach + * Health Connect (mirrors iOS, which never exports demo/mock data). */ + val EXCLUDED_SOURCES = setOf("demo", "mock") + + const val HOUR_MS = 3_600_000L + + /** Gadgetbridge: cap a heart-rate series record at 1 000 samples — Google's guidance is to + * "avoid creating single, long-duration records; structure data into smaller records". */ + const val MAX_SAMPLES_PER_HR_RECORD = 1000 + + /** Gadgetbridge: a gap longer than 15 minutes starts a new series record. */ + const val MAX_HR_GAP_MS = 15 * 60_000L + + // ── clientRecordId builders (plan §3, ported from HealthKitTypeMappings.swift:100-139) ── + + /** `pl-m--` — instantaneous vitals. Millisecond precision: live bursts can + * emit two readings inside the same second, and a whole-second id would collapse them. + * Immutable sample → version is always 1. */ + fun vitalsRecordId(kindKey: String, epochMs: Long): String = "pl-m-$kindKey-$epochMs" + + /** + * `pl-hr-` for a heart-rate hour bucket — or + * `pl-hr--` when the hour's samples split into several series + * records (see [splitHrSegments]). Segments are rebuilt from scratch on every pass, so the + * index is deterministic for a given dataset and a re-run upserts the same ids. + * + * Known edge (accepted): an hour that starts life as a single segment (plain id) and later + * gains a >15-min gap re-keys to suffixed ids, leaving the old single record in Health + * Connect. Write-only means we cannot delete it, and its content is fully re-exported under + * the new ids, so nothing is lost or double-counted — one superseded record per hour that + * ever splits (rare: needs a >15-min ring gap inside a local hour). + */ + fun hrRecordId(hourStartEpochMs: Long, segmentIndex: Int? = null): String = + if (segmentIndex == null) "pl-hr-$hourStartEpochMs" else "pl-hr-$hourStartEpochMs-$segmentIndex" + + // ── time helpers ── + + /** Local-midnight start of the hour containing [epochMs] (epoch millis). */ + fun hourStartOf(epochMs: Long, zone: ZoneId = ZoneId.systemDefault()): Long = + Instant.ofEpochMilli(epochMs).atZone(zone) + .truncatedTo(java.time.temporal.ChronoUnit.HOURS) + .toInstant().toEpochMilli() + + /** Zone offset at an instant — every record carries the offset it was written in. */ + fun zoneOffsetAt(instant: Instant, zone: ZoneId = ZoneId.systemDefault()): ZoneOffset = + zone.rules.getOffset(instant) + + // ── plausibility guards (platform validation rejects the insert otherwise) ── + + /** Health Connect enforces 1..300 bpm; 0 means "not measured" on the rings. */ + fun isPlausibleHr(value: Double): Boolean = value in 1.0..300.0 + + /** 0 = not measured; below 20 is a contact artifact, not a reading. */ + fun isPlausibleSpO2(value: Double): Boolean = value in 20.0..100.0 + + /** Health Connect's own bounds for RMSSD (ms). */ + fun isPlausibleHrvRmssd(value: Double): Boolean = value in 1.0..200.0 + + /** Core body temperature, °C — ring sensor range with artifact margin. */ + fun isPlausibleBodyTemperature(value: Double): Boolean = value in 30.0..42.0 + + // ── daily activity (Phase 3) ── + + /** + * `pl-act--` — one day-spanning aggregate per metric, where metric is + * [ACT_STEPS] / [ACT_ENERGY] / [ACT_DIST] (the same three tokens iOS uses in + * `HealthKitTypeMappings.activitySyncID`). `clientRecordVersion` = the row's `updatedAt`, so a + * day that gains steps later re-upserts the same record instead of adding a second one. + */ + fun activityRecordId(metric: String, dayEpochMs: Long): String = "pl-act-$metric-$dayEpochMs" + + const val ACT_STEPS = "steps" + const val ACT_ENERGY = "energy" + const val ACT_DIST = "dist" + + /** + * End instant for a day-spanning record: the last millisecond of the local day, clamped to + * [nowMs] so "today" never ends in the future (iOS `HealthSyncService.swift:271-274`). Returns + * `null` when the clamped end is not strictly after [dayStartMs] — every Health Connect + * `IntervalRecord` constructor rejects `startTime >= endTime`. + * + * The next day is computed with [java.time.ZonedDateTime.plusDays], not `+ 86_400_000`, so a + * DST transition day is 23 or 25 hours as the calendar sees it. + */ + fun activityDayEndMs(dayStartMs: Long, nowMs: Long, zone: ZoneId = ZoneId.systemDefault()): Long? { + val nextDay = Instant.ofEpochMilli(dayStartMs).atZone(zone).plusDays(1).toInstant().toEpochMilli() + val end = minOf(nextDay - 1L, nowMs) + return if (end > dayStartMs) end else null + } + + // The three guards below take the UNION of the two validators a record passes through: the + // Jetpack constructor's own `require`s below Android 14, and the platform's `requireInRange` + // from 14 (U) up (androidx `StepsRecord.kt:44-52` branches on SDK level). Where they disagree + // — steps floor 1 in Jetpack, 0 on the platform — the stricter bound wins. Following + // Gadgetbridge, an out-of-range value is DROPPED, never clamped, so one bad row cannot sink + // its whole 200-record chunk. NaN and infinity fall out of every comparison and are dropped + // for free. This also honours the write-data guide's "handle zero values" rule: omit the + // record rather than assert a zero we cannot vouch for. + + /** + * Steps per record. The platform's range is `1..1_000_000`, but the ceiling here is this + * app's own corruption threshold: `EventPersistenceSubscriber.kt:374` treats a stored day + * above 200 000 steps as garbage from the old little-endian live-activity decode and + * self-heals it on the next sync. Between the bad write and that heal, an export would push + * a six-figure step day into a store we can never retract it from, so the guard stops where + * the app's own trust in the number stops. + */ + fun isPlausibleSteps(count: Long): Boolean = count in 1L..MAX_TRUSTED_DAILY_STEPS + + /** `EventPersistenceSubscriber`'s stale-value threshold, reused as the export ceiling. */ + const val MAX_TRUSTED_DAILY_STEPS = 200_000L + + /** + * A daily total minus what the workout records carry for the same day. Pure so the subtraction + * — the part that can silently under-report — is testable without Room. + * + * Returns the leftover even when it goes negative; the caller's plausibility guard drops it. + * + * **Stale-record decision (Phase 4): a ≤ 0 leftover is dropped, and the resulting stale + * window is ACCEPTED — no floor-value record is written.** The alternative — overwriting the + * stale un-netted record with a floor value — cannot be a reliable repair: the overwrite only + * happens when the day is re-selected (`updatedAt` above the activity watermark), and the + * stale scenario is precisely a day that stops updating around the netting flip, so the floor + * would mostly just fabricate a 1 kcal / 1 m value in a store we can never retract, against + * the write-data guide's own rule to omit zero values. What is left is bounded and one-time: + * at most the workout's own energy or distance, on a day whose stored daily total a single + * finished session's figure exceeds, dropped while its pre-netting record is still live. A + * consumer summing the daily record plus the workout siblings over-counts by exactly that + * amount for that day, and it cannot grow — the workout record that causes it is versioned + * (`session.updatedAt`) and upserts in place. + * + * Pre-flip staleness (daily records written by a Phase 3 build, before any workout sibling + * existed) is NOT this window and is repaired properly: the one-time netting-flip reset in + * [HealthConnectExporter.run] re-exports every day on the first netting-live pass, so each + * stale un-netted record is overwritten with its netted value under the same clientRecordId. + * (What that reset cannot repair is exactly the ≤ 0 case: a day that nets to nothing has no + * record to overwrite the stale one with — write-only, so it stays. Accepted.) + */ + fun activityLeftover(total: Double, netted: Double): Double = total - netted + + /** Active energy, kcal: platform range `0..1_000_000`; a zero day is not worth a record. */ + fun isPlausibleActiveCalories(kcal: Double): Boolean = kcal > 0.0 && kcal <= 1_000_000.0 + + /** Distance, metres: platform range `0..1_000_000` (1 000 km per record). */ + fun isPlausibleDistanceMeters(meters: Double): Boolean = meters > 0.0 && meters <= 1_000_000.0 + + /** + * One finished workout reduced to what daily-aggregate netting needs. + * [dayStartMs] is the local start-of-day of the session's **start** (iOS keys netting on + * `startedAt`, so a workout crossing midnight nets entirely against the day it began). + * [startedAtMs] / [endedAtMs] / [totalPauseSeconds] feed [creditedActiveMinutes] — the + * credit-eligibility check must see exactly the numbers [com.pulseloop.service.ActivityRollup.credit] + * sees, or netting subtracts what was never credited. + */ + data class NettableSession( + val dayStartMs: Long, + val calories: Double?, + val distanceMeters: Double?, + val useGps: Boolean, + val startedAtMs: Long, + val endedAtMs: Long?, + val totalPauseSeconds: Double, + ) + + /** Per-day workout kcal / metres to subtract from the daily aggregates. */ + data class WorkoutNetting( + val kcalByDay: Map, + val metersByDay: Map, + ) { + fun kcal(dayStartMs: Long): Double = kcalByDay[dayStartMs] ?: 0.0 + fun meters(dayStartMs: Long): Double = metersByDay[dayStartMs] ?: 0.0 + + companion object { val EMPTY = WorkoutNetting(emptyMap(), emptyMap()) } + } + + /** + * Port of iOS `HealthSyncService.workoutNetting` (`HealthSyncService.swift:315-331`): the + * per-day finished-workout totals that Phase 4 will write as their own records, so a Health + * Connect consumer summing the day aggregate + the workout does not count them twice. + * + * Two deliberate Android differences, both forced by this app's data model rather than taste: + * + * - **Energy is netted even though `ActivityRollup.credit` never adds workout kcal to the + * daily row.** What makes netting correct here is the *ring*: when `activity_daily.calories` + * holds a device-reported figure it is the ring's own all-day active energy, which already + * covers the minutes the workout was running. This is exactly iOS's reason, and iOS's rule + * (all finished sessions) ports unchanged. Narrower than it looks: the app itself only + * treats that column as device-reported when `source != "ring_history" && calories > 0` + * (`DailyCalorieEstimator.deviceReportedCalories`), and the estimated figure it falls back + * to for other days already has workout energy folded in — which is why the exporter writes + * the raw column (iOS parity) rather than `effectiveActiveCalories`. + * - **Distance netting drops iOS's walk/run type filter.** iOS needs it because HealthKit + * splits distance across `.distanceWalkingRunning` / `.distanceCycling`, so netting a ride + * out of the walking total would under-count. Health Connect has a single `DistanceRecord` + * type that every workout's distance lands in, so restricting to walk/run here would leave + * a GPS ride's metres counted twice. The netting set is therefore + * [NettableSession.useGps] sessions of any type — the set `ActivityRollup.credit` folds + * into the daily row (`ActivityRollup.kt:20-32`). + * + * **Keeping "netted set == credited set" exact (the Phase 3 imperfections, resolved):** + * 1. `ActivityRollup.credit` early-returns for a session with no full active minute — its + * exact condition is [creditedActiveMinutes] — and a session `credit` never folded into + * the daily row must not be subtracted either. [workoutNetting] therefore skips the same + * sessions. + * 2. `EventPersistenceSubscriber.applyActivityBucketAtomic` *overwrites* a past day's + * `distanceMeters` with the ring's bucket sum (the ratchet only applies to today), + * discarding the credited GPS metres. On the export side that is self-healing: the + * overwrite stamps `updatedAt`, so the day is re-selected and re-exported with the + * ring-only leftover, and the workout's distance sibling restores the credited metres — + * a consumer summing daily + workout lands on the ring's own day total, the correct + * reading for a past day. The only residual is a leftover that nets to ≤ 0, which is the + * accepted stale window documented on [activityLeftover]. + * + * Callers pass only sessions that are `finished` with a non-null `endedAt`, mirroring iOS. + */ + fun workoutNetting(sessions: List): WorkoutNetting { + val kcal = HashMap() + val meters = HashMap() + for (s in sessions) { + // ActivityRollup.credit skips this session (no full active minute) → its energy and + // metres were never credited into the daily row → netting must not subtract them. + if (creditedActiveMinutes(s.startedAtMs, s.endedAtMs, s.totalPauseSeconds) <= 0) continue + val k = s.calories + if (k != null && k > 0.0) kcal[s.dayStartMs] = (kcal[s.dayStartMs] ?: 0.0) + k + val m = s.distanceMeters + if (s.useGps && m != null && m > 0.0) meters[s.dayStartMs] = (meters[s.dayStartMs] ?: 0.0) + m + } + return WorkoutNetting(kcal, meters) + } + + /** + * Port of `ActivityRollup.minutesFor` — the same arithmetic and the same `minutes <= 0` + * early-return that decide whether [com.pulseloop.service.ActivityRollup.credit] folds a + * session into the daily row. Netting subtracts exactly what `credit` added, so the two must + * agree on credit eligibility (Phase 3 imperfection #1, resolved). + */ + fun creditedActiveMinutes(startedAtMs: Long, endedAtMs: Long?, totalPauseSeconds: Double): Int { + val ended = endedAtMs ?: return 0 + return maxOf(0, (((ended - startedAtMs) / 1000.0) - totalPauseSeconds).toInt()) / 60 + } + + // ── workouts (Phase 4; plan §3 identity table + Phase 4 spec) ── + + /** Sibling-record tokens for [workoutChildRecordId] (plan §3 identity table). */ + const val WK_ENERGY = "energy" + const val WK_DIST = "dist" + + /** + * `pl-wk-` — the session's [ExerciseSessionRecord]. The session id is the stable + * Room primary key: unlike sleep blocks, a workout row is never replaced wholesale (an edit + * or a post-finish vitals backfill bumps `updatedAt` in place), so it is a safe upsert key + * with no suffix scheme. Version = `session.updatedAt`, set by the exporter. + */ + fun workoutRecordId(sessionId: String): String = "pl-wk-$sessionId" + + /** + * `pl-wk--` — a sibling energy/distance record over the session window, for + * [WK_ENERGY] / [WK_DIST] (plan §3 identity table). Same version as the session record. + */ + fun workoutChildRecordId(sessionId: String, kind: String): String = "pl-wk-$sessionId-$kind" + + /** + * PulseLoop activity type → [ExerciseSessionRecord] constant (plan Phase 4 exercise-type map, + * the shape of `strava/StravaSportMapping`). Unknown types degrade to OTHER_WORKOUT — the + * session is real effort, just unclassified (same fallback rule as [sleepStageType]). + */ + fun exerciseType(type: String): Int = when (type) { + "walk" -> ExerciseSessionRecord.EXERCISE_TYPE_WALKING + "run" -> ExerciseSessionRecord.EXERCISE_TYPE_RUNNING + "cycle" -> ExerciseSessionRecord.EXERCISE_TYPE_BIKING + "gym" -> ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING + "squash" -> ExerciseSessionRecord.EXERCISE_TYPE_SQUASH + "yoga" -> ExerciseSessionRecord.EXERCISE_TYPE_YOGA + "dance" -> ExerciseSessionRecord.EXERCISE_TYPE_DANCING + "hike" -> ExerciseSessionRecord.EXERCISE_TYPE_HIKING + "sport" -> ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT + else -> ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT + } + + /** What one finished session may export to (Phase 4 guards; the DAO already filters to + * `statusRaw = 'finished' AND endedAt IS NOT NULL`). */ + enum class WorkoutSelection { EXPORT, /** zero/negative duration — can never become exportable. */ INVALID, + /** `endedAt` in the future (clock skew) — retry next run, never leapfrog. */ FUTURE } + + /** + * Per-session export decision (plan Phase 4 guards: `endedAt > startedAt`, not future), + * mirroring iOS `exportWorkouts`: a zero/negative duration can never become exportable, while + * a future-dated end is transient clock skew — the pass must stop at it and retry later + * instead of skipping it (the watermark may not leapfrog a session whose end has not happened). + */ + fun selectWorkoutSession(startedAtMs: Long, endedAtMs: Long?, nowMs: Long): WorkoutSelection = when { + endedAtMs == null || endedAtMs <= startedAtMs -> WorkoutSelection.INVALID + endedAtMs > nowMs -> WorkoutSelection.FUTURE + else -> WorkoutSelection.EXPORT + } + + /** + * One GPS fix reduced to what an `ExerciseRoute.Location` needs. Primitives on purpose: this + * file stays Room-free and unit-testable — the caller maps + * [com.pulseloop.data.entity.ActivityGpsPointEntity] → this, already filtered to `accepted`. + */ + data class GpsRoutePoint( + val timeMs: Long, + val latitude: Double, + val longitude: Double, + val horizontalAccuracyMeters: Double? = null, + val altitudeMeters: Double? = null, + ) + + /** + * Route sanitisation (plan Phase 4; Gadgetbridge `buildSanitisedRoute`): drop points outside + * the session window [sessionStartMs, sessionEndMs] (inclusive), points with non-finite or + * out-of-range coordinates, and duplicate timestamps — Health Connect rejects a route whose + * points repeat a timestamp, and the first point of a duplicate keeps its place. The result + * is sorted by time. A route needs ≥ 2 points to exist; the caller treats a shorter result + * as "no route" (the session still writes). + */ + fun sanitizeRoutePoints( + sessionStartMs: Long, + sessionEndMs: Long, + raw: List, + ): List { + if (sessionEndMs < sessionStartMs) return emptyList() + val seen = HashSet() + val kept = mutableListOf() + for (p in raw.sortedBy { it.timeMs }) { + if (p.timeMs < sessionStartMs || p.timeMs > sessionEndMs) continue + if (!p.latitude.isFinite() || !p.longitude.isFinite()) continue + if (p.latitude !in -90.0..90.0 || p.longitude !in -180.0..180.0) continue + if (!seen.add(p.timeMs)) continue // duplicate timestamp — HC rejects it + kept += p + } + return kept + } + + /** Matches "...single record size limit: 1000000, was: 1700644" from the HC platform + * (Gadgetbridge's production format for the 1 MB single-record limit). */ + private val RECORD_SIZE_REGEX = + Regex("single record size limit:\\s*(\\d+),\\s*was:\\s*(\\d+)") + + /** + * The 1 MB per-record platform limit, parsed out of the insert exception message (plan §3 + * robustness constants; no API exposes it). Returns the `(limit, was)` pair, or null when the + * message doesn't carry it — the caller then falls back to its normal retry. + */ + fun parseRecordSizeLimit(message: String?): Pair? { + val m = RECORD_SIZE_REGEX.find(message ?: "") ?: return null + val limit = m.groupValues[1].toLongOrNull() ?: return null + val was = m.groupValues[2].toLongOrNull() ?: return null + if (limit <= 0L || was <= limit) return null + return limit to was + } + + /** Gadgetbridge: aim for 90 % of the limit to leave room for per-point overhead the size + * model doesn't capture. */ + const val ROUTE_SHRINK_MARGIN = 0.9 + + /** + * Uniformly decimates [points] down to [target] points (clamped to ≥ 2), preserving first and + * last — Gadgetbridge's `decimateRoute`. The uniform stride keeps the shape of the route; + * `step` is always ≥ 1 here (target < size), so the integer indices are strictly increasing + * and no timestamp is ever duplicated (which HC would reject). + */ + fun decimateToSize(points: List, target: Int): List { + val t = target.coerceAtLeast(2) + if (points.size <= t) return points + val lastIndex = points.size - 1 + val step = lastIndex.toDouble() / (t - 1).toDouble() + val kept = ArrayList(t) + var idx = 0.0 + repeat(t - 1) { + kept += points[idx.toInt().coerceAtMost(lastIndex - 1)] + idx += step + } + kept += points.last() + return kept + } + + // ── heart-rate series segmentation ── + + /** One sorted sample: sample instant + whole bpm (HeartRateRecord.Sample is a Long). */ + data class HrSample(val timeMs: Long, val bpm: Long) + + /** + * Splits a sorted, plausibility-filtered hour's samples into series records, copying + * Gadgetbridge's rules: a new segment on a local-date change, on a gap longer than + * [MAX_HR_GAP_MS], and when a segment holds [MAX_SAMPLES_PER_HR_RECORD] samples. + * Pure — unit-testable without a database or a client. + */ + fun splitHrSegments(samples: List, zone: ZoneId = ZoneId.systemDefault()): List> { + if (samples.isEmpty()) return emptyList() + val sorted = samples.sortedBy { it.timeMs } + val segments = mutableListOf>() + var current = mutableListOf(sorted.first()) + var prevDay = Instant.ofEpochMilli(sorted.first().timeMs).atZone(zone).toLocalDate() + for (i in 1 until sorted.size) { + val sample = sorted[i] + val prev = sorted[i - 1] + val day = Instant.ofEpochMilli(sample.timeMs).atZone(zone).toLocalDate() + val newDay = day != prevDay + val gapTooLong = sample.timeMs - prev.timeMs > MAX_HR_GAP_MS + val full = current.size >= MAX_SAMPLES_PER_HR_RECORD + if (newDay || gapTooLong || full) { + segments.add(current) + current = mutableListOf(sample) + } else { + current.add(sample) + } + prevDay = day + } + segments.add(current) + return segments + } + + /** + * [HeartRateRecord] requires a positive duration: a single-sample segment gets its end bumped + * by 1 s (Gadgetbridge does the same). + */ + fun seriesEndMs(startMs: Long, endMs: Long): Long = if (endMs <= startMs) endMs + 1L else endMs + + // ── sleep (Phase 2; plan §3 identity table + Phase 2 spec) ── + + /** One session's shape for a day's clientRecordId selection — the two fields of a + * [com.pulseloop.data.entity.SleepSessionEntity] that determine which session is the day's + * main sleep. Primitives on purpose: this file stays Room-free and unit-testable. */ + data class SleepDaySession(val startAtMs: Long, val totalMinutes: Long) + + /** + * `pl-sleep-` — the clientRecordId for a waking day's [SleepSessionRecord], keyed + * on the session's `date` (the waking day's local midnight, epoch ms). NEVER keyed on + * [com.pulseloop.data.entity.SleepStageBlockEntity.id] — a fresh random UUID on every re-sync, + * because upsertSleepSessionAtomic replaces the blocks — or on the session UUID (plan §3, + * identity trap #1). A re-synced night must upsert the SAME Health Connect record in place, + * and `date` is stable across re-syncs. Millisecond-epoch form of the iOS + * `pl-sleep-` identifier; the version (session.updatedAt) is set by the exporter. + * + * A waking day can hold more than one session (a main night plus a daytime nap, split by + * SleepSegmentation), and Health Connect resolves one clientRecordId to one record per app — + * two sessions sharing the plain id would silently replace each other. Disambiguation: + * the day's main session ([mainSleepIndex]) keeps the plain id; the others take a + * deterministic suffix, `pl-sleep--` ([sleepSessionSuffix]). + * + * Known edge (accepted, mirrors the HR hour-split edge): if a nap later joins or leaves the + * day the suffixed ids shift, leaving one superseded record in Health Connect. Write-only + * means we cannot delete it; its content is re-exported under the new ids on the same pass, + * so nothing is lost or double-counted. + */ + fun sleepSessionRecordId(dayEpochMs: Long, suffix: Int? = null): String = + if (suffix == null) "pl-sleep-$dayEpochMs" else "pl-sleep-$dayEpochMs-$suffix" + + /** + * The index of a waking day's main session: the longest ([SleepDaySession.totalMinutes]), + * ties to the earliest start — the same main sleep [com.pulseloop.data.dao.SleepSessionDao.byDay] + * surfaces to the single-session callers. null for an empty day. + */ + fun mainSleepIndex(sessions: List): Int? { + if (sessions.isEmpty()) return null + var best = 0 + for (i in 1 until sessions.size) { + val cur = sessions[i] + val prev = sessions[best] + if (cur.totalMinutes > prev.totalMinutes || + (cur.totalMinutes == prev.totalMinutes && cur.startAtMs < prev.startAtMs) + ) best = i + } + return best + } + + /** + * The deterministic suffix for session [index] of a multi-session waking day: its 1-based + * position in startAt order among the day's non-main sessions; null when [index] IS the day's + * main session (it keeps the plain id). Purely a function of the day's session set, so a + * re-run computes the same ids. + */ + fun sleepSessionSuffix(sessions: List, index: Int): Int? { + val main = mainSleepIndex(sessions) ?: return null + if (index == main) return null + val nonMain = sessions.indices.filter { it != main }.sortedBy { sessions[it].startAtMs } + return nonMain.indexOf(index) + 1 + } + + /** A proposed stage span (epoch millis) for [normalizeSleepStages]. [stageType] is already + * mapped through [sleepStageType] — the client's constants, never a raw int. */ + data class SleepStageSpan(val startMs: Long, val endMs: Long, val stageType: Int) + + /** + * [com.pulseloop.data.entity.SleepStageBlockEntity.stageRaw] (a [SleepStage] name) → the + * client's [SleepSessionRecord] stage-type constant (plan Phase 2). Anything unrecognized + * degrades to [SleepSessionRecord.STAGE_TYPE_UNKNOWN] rather than being dropped — the block + * is real sleep time, just unclassified. + */ + fun sleepStageType(stageRaw: String): Int = when (stageRaw) { + SleepStage.DEEP.name -> SleepSessionRecord.STAGE_TYPE_DEEP + SleepStage.LIGHT.name -> SleepSessionRecord.STAGE_TYPE_LIGHT + SleepStage.REM.name -> SleepSessionRecord.STAGE_TYPE_REM + SleepStage.AWAKE.name -> SleepSessionRecord.STAGE_TYPE_AWAKE + SleepStage.UNKNOWN.name -> SleepSessionRecord.STAGE_TYPE_UNKNOWN + else -> SleepSessionRecord.STAGE_TYPE_UNKNOWN + } + + /** + * Normalizes raw stage blocks into a stage list the [SleepSessionRecord] constructor accepts + * (plan Phase 2): sort by start, clamp each span to [sessionStartMs, sessionEndMs], drop + * overlaps (keep the earlier, truncate the later to start where the earlier ends), and drop + * zero/negative-length stages. The result is sorted, non-overlapping (touching is allowed — + * the record's validation rejects a stage ending after the NEXT stage's start), and inside + * the session bounds. + */ + fun normalizeSleepStages( + sessionStartMs: Long, + sessionEndMs: Long, + raw: List, + ): List { + if (sessionEndMs <= sessionStartMs) return emptyList() + val result = mutableListOf() + for (span in raw.sortedBy { it.startMs }) { + var start = span.startMs.coerceAtLeast(sessionStartMs) + val end = span.endMs.coerceAtMost(sessionEndMs) + if (end <= start) continue // zero/negative length after the session-bound clamp + if (result.isNotEmpty() && start < result.last().endMs) { + start = result.last().endMs // keep the earlier stage, truncate this one + if (end <= start) continue + } + result += SleepStageSpan(start, end, span.stageType) + } + return result + } + + // ── Phase 5: beyond-iOS vitals + nutrition (plan §3 Phase-5 table, §4 Phase 5) ── + + /** + * `pl-m-bp-` — one `BloodPressureRecord` paired from a systolic + a diastolic + * [com.pulseloop.data.entity.MeasurementEntity] row that share the same sample instant. The + * pair is immutable (a taken reading is never re-written), so `clientRecordVersion` = 1. + * Millisecond precision, same as [vitalsRecordId]. Both source rows collapse onto ONE record, + * so the id is keyed on the shared timestamp — never on either row's random live UUID, so a + * reading that arrives once live and once via history still lands on the same record. + */ + fun bloodPressureRecordId(timestampMs: Long): String = "pl-m-bp-$timestampMs" + + /** + * `pl-resting-hr` — the single `RestingHeartRateRecord` for the user's learned resting-HR + * baseline ([com.pulseloop.data.entity.UserProfileEntity.hrRestingBaseline]). A constant id: + * there is exactly one baseline, so a re-learned value re-upserts the SAME record in place + * rather than accumulating one per re-learn. `clientRecordVersion` = the baseline's + * `hrRestingBaselineUpdatedAt` — a re-learn always advances it, so the newer value wins the + * upsert (and it is never the metric value itself). + */ + const val RESTING_HR_RECORD_ID = "pl-resting-hr" + + /** + * `pl-meal-` — one `NutritionRecord` per [com.pulseloop.data.entity.MealEntryEntity]. + * The meal's Room primary key is a stable UUID: a logged meal is insert-once (never churned on + * re-sync the way sleep blocks are), so it is a safe upsert key. `clientRecordVersion` = the + * meal's `createdAt`. + */ + fun nutritionRecordId(mealId: String): String = "pl-meal-$mealId" + + // ── Phase 5 plausibility guards ── + // + // Each guard is the intersection of (a) the platform bound Health Connect enforces on insert + // (a violation throws and would sink the whole 200-record chunk) and (b) the app's own + // data-quality range (RingEventBridge's plausibility windows), so a 0 sentinel or a + // misdecoded live value never reaches a write-only store. Following Gadgetbridge and the Phase + // 1 style, an out-of-range value is DROPPED, never clamped; NaN / ±∞ fall out of every + // comparison and are dropped for free. + + /** + * Systolic mmHg — the app's own floor (RingEventBridge.systolicRange 60) intersected with the + * platform ceiling (⚠️ 200, from the BloodPressureRecord clinit; 201+ would throw and sink the + * whole chunk). The app's decode range is 60..250 but the platform only accepts 20..200. + */ + fun isPlausibleSystolic(mmHg: Double): Boolean = mmHg.isFinite() && mmHg in 60.0..MAX_SYSTOLIC_MMHG + + /** + * Diastolic mmHg — the app's own range (RingEventBridge.diastolicRange 30..150). Unlike the + * systolic ceiling, the app's diastolic ceiling (150) is TIGHTER than the platform's (180), so + * the app range is the binding constraint; a 151..180 reading is within the platform but + * implausible by the app's own bar and is dropped for consistency. + */ + fun isPlausibleDiastolic(mmHg: Double): Boolean = mmHg.isFinite() && mmHg in 30.0..150.0 + + /** Health Connect's systolic ceiling, mmHg (BloodPressureRecord clinit; binds over the app's 250). */ + const val MAX_SYSTOLIC_MMHG = 200.0 + + /** One side of a blood-pressure reading, reduced to what [pairBloodPressure] needs. */ + data class BpSide(val timestampMs: Long, val value: Double, val createdAt: Long) + + /** A matched, plausible blood-pressure pair ready to become one [BloodPressureRecord]. */ + data class BpPair(val timestampMs: Long, val systolic: Double, val diastolic: Double, val highWater: Long) + + /** + * The outcome of [pairBloodPressure]: the exportable [pairs] plus the drop counts, so the + * exporter can report dropped readings as skipped (consistent with the other groups' skipped + * counters). [unpaired] = a timestamp present on only one side (a decode/storage anomaly); + * [outOfRange] = a matched pair with an implausible systolic or diastolic. + */ + data class BpPairingResult( + val pairs: List, + val unpaired: Int, + val outOfRange: Int, + /** + * Max `createdAt` among the OUT-OF-RANGE pairs only. A stored measurement's value never + * changes, so such a pair can never become exportable and the exporter may advance past + * it (otherwise it pins the VITALS watermark and every later pass re-reads the tail + * behind it). UNPAIRED rows are deliberately excluded: the missing side may still arrive, + * and it can only pair while the present side stays above the watermark. + */ + val outOfRangeHighWater: Long? = null, + ) { + val dropped: Int get() = unpaired + outOfRange + } + + /** + * Pairs systolic + diastolic rows by EXACT timestamp equality into [BpPair]s (plan Phase 5). + * The app always writes the pair with one `event.timestamp`, so exact equality is correct - a + * tolerance would risk cross-pairing two nearby readings. A timestamp present on only one side + * (an unpaired reading - a decode/storage anomaly) and a pair with an out-of-range value are + * dropped, never clamped. [highWater] is the max of the pair's two `createdAt`s, so the + * exporter can advance the group watermark only past a value whose rows BOTH reached Health + * Connect. Pure (no DB) so the pairing rules - the part that decides what a BP reading exports + * as - are unit-testable. + */ + fun pairBloodPressure(sys: List, dia: List): BpPairingResult { + val sysByTs = HashMap() + for (s in sys) sysByTs[s.timestampMs] = s + val diaByTs = HashMap() + for (d in dia) diaByTs[d.timestampMs] = d + val out = mutableListOf() + var unpaired = 0 + var outOfRange = 0 + var outOfRangeHigh: Long? = null + for (ts in (sysByTs.keys + diaByTs.keys).toSet().sorted()) { + val s = sysByTs[ts] + val d = diaByTs[ts] + if (s == null || d == null) { unpaired++; continue } // a reading on only one side + if (!isPlausibleSystolic(s.value) || !isPlausibleDiastolic(d.value)) { + outOfRange++ + outOfRangeHigh = maxOf(outOfRangeHigh ?: Long.MIN_VALUE, maxOf(s.createdAt, d.createdAt)) + continue + } + out += BpPair(ts, s.value, d.value, maxOf(s.createdAt, d.createdAt)) + } + return BpPairingResult(out, unpaired, outOfRange, outOfRangeHigh) + } + + /** + * Blood glucose, mg/dL. The floor is the app's own range (20, RingEventBridge.bloodSugarRange) + * so a 0 sentinel / artifact is dropped; the ceiling is the platform's hard cap — ⚠️ 900.0 + * mg/dL, NOT the plan's 900.91. Verified against the 1.1.0 bytecode: MAX_BLOOD_GLUCOSE_LEVEL + * is 50 mmol/L and the client's mg/dL→mmol/L factor is exactly 1/18, so 50 mmol/L = 900.0 + * mg/dL (900.0 maps to 50.0, accepted; 900.01 maps to 50.0006 and THROWS). Gadgetbridge's + * 900.91 guard is looser than the platform and would let (900.0, 900.91] through to a throwing + * constructor - the whole chunk would sink. The app's own bridge range is 20..600 + * (RingEventBridge), so (600, 900] is unreachable from a ring; the guard still uses the + * platform ceiling (900.0) rather than 600 so a legitimately high value (if ever stored) would + * still export instead of being silently dropped. + */ + fun isPlausibleBloodGlucose(mgDl: Double): Boolean = mgDl.isFinite() && mgDl in 20.0..MAX_BLOOD_GLUCOSE_MGDL + + /** Health Connect's hard glucose ceiling, mg/dL (= 50 mmol/L at the client's 1/18 factor). */ + const val MAX_BLOOD_GLUCOSE_MGDL = 900.0 + + /** Respiratory rate, breaths/min — the app's own range (RingEventBridge 5..60), inside the platform 0..1000. */ + fun isPlausibleRespRate(breathsPerMin: Double): Boolean = breathsPerMin.isFinite() && breathsPerMin in 5.0..60.0 + + /** VO2max, mL/kg/min — the app's own range (RingEventBridge 1..100); the platform bound is 0..100. */ + fun isPlausibleVo2Max(mlPerKgMin: Double): Boolean = mlPerKgMin.isFinite() && mlPerKgMin in 1.0..100.0 + + /** Resting HR, bpm — the platform bound (⚠️ 1..300; the platform rejects 0, unlike the client). */ + fun isPlausibleRestingHr(bpm: Double): Boolean = bpm.isFinite() && bpm in 1.0..300.0 + + // ── Phase 5 nutrition (NutritionRecord) ── + // + // NutritionRecord's clinit bounds (verified from the 1.1.0 AAR): energy 0..1e8 cal; the macro + // masses (protein/carbs/fat/fiber/sugar) 0..100,000 g; sodium is a micronutrient capped at + // 100 g = 100,000 mg (and is built with Mass.milligrams, not grams). A meal comes only from the + // manual "Log Meal" dialog and is normally tiny, but a typo must not sink the whole 200-record + // chunk, so an out-of-range meal is DROPPED (never clamped), like every other guard here. + + /** + * Meal energy, kcal. The platform cap is `Energy.calories(100_000_000)` (verified from the + * 1.1.0 clinit) and [androidx.health.connect.client.units.Energy.calories] is SMALL calories, + * so that is 100,000,000 cal = **100,000 kcal** - NOT 1e8 kcal. A 0-calorie meal carries no + * energy field; a value above 100,000 kcal would throw from the ctor (sink the chunk), so the + * guard drops it. (An earlier 1e8 *kcal* cap was 1000x too loose - caught in review.) + */ + fun isPlausibleNutritionEnergyKcal(kcal: Double): Boolean = kcal.isFinite() && kcal > 0.0 && kcal <= MAX_NUTRITION_ENERGY_KCAL + + /** A macro mass in grams (or sodium in mg — same numeric cap), 0..100,000. */ + fun isPlausibleNutritionMass(value: Double): Boolean = value.isFinite() && value > 0.0 && value <= MAX_NUTRITION_MASS_G + + const val MAX_NUTRITION_ENERGY_KCAL = 100_000.0 + const val MAX_NUTRITION_MASS_G = 100_000.0 + + /** + * [com.pulseloop.data.entity.MealEntryEntity.mealTypeRaw] (one of breakfast/lunch/dinner/snack, + * the app's own four) → the client's [MealType] int. Anything unrecognized degrades to + * MEAL_TYPE_UNKNOWN (the meal is real, just unclassified). + */ + fun nutritionMealType(mealTypeRaw: String): Int = when (mealTypeRaw) { + "breakfast" -> MealType.MEAL_TYPE_BREAKFAST + "lunch" -> MealType.MEAL_TYPE_LUNCH + "dinner" -> MealType.MEAL_TYPE_DINNER + "snack" -> MealType.MEAL_TYPE_SNACK + else -> MealType.MEAL_TYPE_UNKNOWN + } +} diff --git a/app/src/main/java/com/pulseloop/health/HealthConnectWorkoutDeletion.kt b/app/src/main/java/com/pulseloop/health/HealthConnectWorkoutDeletion.kt new file mode 100644 index 0000000..15c11ee --- /dev/null +++ b/app/src/main/java/com/pulseloop/health/HealthConnectWorkoutDeletion.kt @@ -0,0 +1,108 @@ +package com.pulseloop.health + +import android.content.Context +import android.util.Log +import androidx.health.connect.client.HealthConnectClient +import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord +import androidx.health.connect.client.records.DistanceRecord +import androidx.health.connect.client.records.ExerciseSessionRecord +import com.pulseloop.health.HealthConnectTypeMappings.WK_DIST +import com.pulseloop.health.HealthConnectTypeMappings.WK_ENERGY + +/** + * Removes one session's exported records from Health Connect when the local row is deleted + * (plan Phase 4 "Deletion hooks"; iOS parity: `HealthSyncService.deleteExportedWorkout`). + * Fired from the workout UI delete (WorkoutSummaryScreen). The coach's `delete_activity_session` + * is a pending-action flow with no confirm handler wired yet, so it does NOT call this today — + * when it is, it should call this the same way. + * + * The session's route travels INSIDE its [ExerciseSessionRecord] (an embedded field, not a + * standalone provider record), so deleting the session record removes the route with it; the + * energy/distance SIBLINGS are standalone records and are deleted by their own clientRecordIds. + * + * Best-effort on purpose: a local delete must never fail because of Health Connect. Every gate + * (provider available, permission granted) and every error degrades to a log line — + * [HealthConnectClient.deleteRecords] has no client-side permission check of its own, so the + * granted-set diff here is the only guard against a denied-permission call. The three record + * classes are each deleted in their own runCatching so one unknown id (a conditional sibling + * that was never written) cannot abort the rest and orphan the survivors. + * + * Deliberately NOT gated on the master export toggle (observer review, Phase 4 stage B): a user + * who exported workouts and later switched the export off still owns those Health Connect + * records — deleting the local session must remove them, or they become unmanageable ghosts + * (iOS guards on availability only, not on its export preference). + */ +object HealthConnectWorkoutDeletion { + private const val TAG = "HealthConnectExport" + + /** + * Deletes [sessionId]'s `pl-wk-` session record plus its `-energy` / `-dist` siblings + * (each only when its write permission is granted — a record we never had permission to + * write is a no-op: its clientRecordId simply isn't in the store). Returns the number of + * record classes actually deleted; 0 when the provider is unavailable, the client cannot be + * created, or every permission is denied. + */ + suspend fun removeSessionRecords(context: Context, sessionId: String): Int { + if (HealthConnectSdk.availability(context.applicationContext) != HealthConnectAvailability.AVAILABLE) { + return 0 + } + val client = try { + HealthConnectClient.getOrCreate(context.applicationContext) + } catch (e: Exception) { + Log.w(TAG, "workout delete: no Health Connect client", e) + return 0 + } + return try { + val granted = client.permissionController.getGrantedPermissions() + var deleted = 0 + // Only our own clientRecordIds scope the delete — recordIdsList (provider-side + // record ids) stays empty. + // + // Each class is isolated in its own runCatching (review, Phase 4): the + // clientRecordIds overload aborts the whole transaction on any unknown id, and the + // siblings are conditional — the energy record only when calories are plausible, the + // distance record only for useGps sessions. Without isolation a GPS run with no + // calorie figure throws on the (never-written) energy id, a shared catch swallows + // it, and the distance delete never runs — orphaning a pl-wk--dist record the + // app can no longer manage (write-only, local row gone). + if (HealthConnectPermissions.exercise.first() in granted) { + runCatching { + client.deleteRecords( + ExerciseSessionRecord::class, + recordIdsList = emptyList(), + clientRecordIdsList = listOf(HealthConnectTypeMappings.workoutRecordId(sessionId)), + ) + } + .onSuccess { deleted++ } + .onFailure { Log.w(TAG, "workout delete: session record for $sessionId", it) } + } + if (HealthConnectPermissions.activeCalories.first() in granted) { + runCatching { + client.deleteRecords( + ActiveCaloriesBurnedRecord::class, + recordIdsList = emptyList(), + clientRecordIdsList = listOf(HealthConnectTypeMappings.workoutChildRecordId(sessionId, WK_ENERGY)), + ) + } + .onSuccess { deleted++ } + .onFailure { Log.w(TAG, "workout delete: energy record for $sessionId", it) } + } + if (HealthConnectPermissions.distance.first() in granted) { + runCatching { + client.deleteRecords( + DistanceRecord::class, + recordIdsList = emptyList(), + clientRecordIdsList = listOf(HealthConnectTypeMappings.workoutChildRecordId(sessionId, WK_DIST)), + ) + } + .onSuccess { deleted++ } + .onFailure { Log.w(TAG, "workout delete: distance record for $sessionId", it) } + } + if (deleted > 0) Log.i(TAG, "workout delete: removed $deleted record class(es) for session $sessionId") + deleted + } catch (e: Exception) { + Log.w(TAG, "workout delete: Health Connect error (local delete unaffected)", e) + 0 + } + } +} diff --git a/app/src/main/java/com/pulseloop/health/exporters/ActivityExporter.kt b/app/src/main/java/com/pulseloop/health/exporters/ActivityExporter.kt new file mode 100644 index 0000000..1cbf3b2 --- /dev/null +++ b/app/src/main/java/com/pulseloop/health/exporters/ActivityExporter.kt @@ -0,0 +1,222 @@ +package com.pulseloop.health.exporters + +import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord +import androidx.health.connect.client.records.DistanceRecord +import androidx.health.connect.client.records.Record +import androidx.health.connect.client.records.StepsRecord +import androidx.health.connect.client.records.metadata.Device +import androidx.health.connect.client.records.metadata.Metadata +import androidx.health.connect.client.units.Energy +import androidx.health.connect.client.units.Length +import com.pulseloop.data.PulseLoopDatabase +import com.pulseloop.data.entity.ActivityDailyEntity +import com.pulseloop.health.HealthConnectTypeMappings +import com.pulseloop.health.HealthConnectTypeMappings.ACT_DIST +import com.pulseloop.health.HealthConnectTypeMappings.ACT_ENERGY +import com.pulseloop.health.HealthConnectTypeMappings.ACT_STEPS +import com.pulseloop.health.HealthConnectTypeMappings.EXCLUDED_SOURCES +import com.pulseloop.health.HealthConnectTypeMappings.NettableSession +import com.pulseloop.health.HealthConnectTypeMappings.WorkoutNetting +import com.pulseloop.util.TimeUtil +import java.time.Instant +import java.time.ZoneId + +/** + * Builds the Phase 3 daily-activity records (docs/health-connect-integration.md Phase 3): one + * [StepsRecord], one [ActiveCaloriesBurnedRecord] and one [DistanceRecord] per + * [ActivityDailyEntity], each spanning the local day — `startOfDay … min(endOfDay, now)`, so + * today's record never ends in the future. + * + * Identity: `clientRecordId = pl-act--` with `clientRecordVersion = + * row.updatedAt`, so a day that gains steps through the afternoon re-upserts the *same* three + * records rather than accumulating one per sync (plan §3). `ActivityDailyEntity.date` is already + * the local start-of-day in epoch millis and is uniquely indexed, so — unlike sleep — there is no + * multi-row-per-day suffix problem here. + * + * Workout netting ([HealthConnectTypeMappings.workoutNetting], ported from iOS + * `HealthSyncService.swift:315-331`): finished-workout energy and GPS distance are subtracted from + * the day's totals so that a Health Connect consumer adding the daily aggregate to Phase 4's + * per-workout records does not count the same effort twice. The netting is gated on the workouts + * toggle by the caller, exactly as iOS gates it on `exportWorkouts` — if workouts are not being + * exported there is nothing to double-count and the full day total is written. + * + * Pure DB → records: no client, no inserts — [com.pulseloop.health.HealthConnectExporter] owns the + * write path and decides which [metrics] are both toggled on and permission-granted. + */ +class ActivityExporter(private val db: PulseLoopDatabase) { + + /** + * Pending activity records with the parallel [highWaters] list: entry i is the source row's + * `updatedAt` that record i represents, so the exporter can advance the activity watermark + * only to a value whose days all reached Health Connect. [skippedDays] counts days selected by + * the watermark that produced no record at all (every metric zero, netted to zero, implausible, + * or the day lies entirely in the future). + */ + data class PendingActivity( + val records: List, + val highWaters: List, + val skippedDays: Int, + /** + * Max `updatedAt` among days that were selected but produced no record *and can never + * produce one from their current contents* — every metric zero, netted away, or + * implausible. Without it such a day sitting above every exportable one pins the ACTIVITY + * watermark and each later pass re-selects and re-upserts the whole tail behind it. Safe + * to advance past: any change to the day bumps `updatedAt`, which re-selects it wherever + * the watermark stands. A FUTURE-dated day is deliberately excluded — it is not yet + * exportable rather than never exportable, and advancing past it would lose it once the + * clock catches up. Applied only on a fully completed pass — see + * [com.pulseloop.health.watermarkAdvance]. + */ + val droppedHighWater: Long? = null, + ) + + /** + * Builds the pending records. Rows with `updatedAt <= [watermark]` were already exported; + * `null` means export everything (first-enable backfill). [metrics] is the subset of + * [ACT_STEPS] / [ACT_ENERGY] / [ACT_DIST] the caller has cleared for writing; [netWorkouts] + * mirrors iOS's `exportWorkouts` gate on netting; [nowMs] clamps today's end. + */ + suspend fun build( + watermark: Long?, + device: Device, + metrics: Set, + netWorkouts: Boolean, + nowMs: Long, + zone: ZoneId = ZoneId.systemDefault(), + ): PendingActivity { + if (metrics.isEmpty()) return PendingActivity(emptyList(), emptyList(), 0) + val wm = watermark ?: 0L + val selected = db.activityDailyDao().updatedSince(wm) + val rows = selected.filter { it.source !in EXCLUDED_SOURCES } + // Demo/mock rows are permanently unexportable (a row's source never changes), so they + // count as drops — otherwise a seeded demo day newer than every real one pins the + // watermark forever. + val excludedHigh: Long? = selected.filter { it.source in EXCLUDED_SOURCES } + .maxOfOrNull { it.updatedAt } + if (rows.isEmpty()) return PendingActivity(emptyList(), emptyList(), 0, excludedHigh) + + val netting = if (netWorkouts) loadNetting(rows, zone) else WorkoutNetting.EMPTY + + val records = mutableListOf() + val highWaters = mutableListOf() + var skipped = 0 + var droppedHigh: Long? = excludedHigh + + for (row in rows) { + // Re-normalize rather than trusting the stored value (iOS does the same: + // `cal.startOfDay(for: row.date)`, HealthSyncService.swift:270). `date` is local + // midnight *in the zone it was written in*, so after a timezone change a day can be + // stored at an offset midnight; keying the record on that would emit a second, + // overlapping record for the same calendar day — and Health Connect sums an app's own + // overlapping records rather than de-duplicating them. Normalizing also makes these + // keys align by construction with loadNetting's, which are built from `startedAt`. + val dayStart = TimeUtil.startOfDayLocal(row.date, zone) + // Clamp so "today" never ends in the future; a row dated ahead of now yields nothing. + val endMs = HealthConnectTypeMappings.activityDayEndMs(dayStart, nowMs, zone) + if (endMs == null) { + skipped++ + // NOT a drop: a future-dated day becomes exportable once now catches up. + continue + } + val start = Instant.ofEpochMilli(dayStart) + val end = Instant.ofEpochMilli(endMs) + val startOffset = HealthConnectTypeMappings.zoneOffsetAt(start, zone) + val endOffset = HealthConnectTypeMappings.zoneOffsetAt(end, zone) + val before = records.size + + if (ACT_STEPS in metrics) { + // Steps are not netted: a workout's steps are the ring's steps — there is no + // separate step record per workout for them to double against (Phase 4 writes + // energy and distance siblings only). + val steps = row.steps.toLong() + if (HealthConnectTypeMappings.isPlausibleSteps(steps)) { + records += StepsRecord( + start, + startOffset, + end, + endOffset, + steps, + metadata(device, ACT_STEPS, dayStart, row.updatedAt), + ) + highWaters += row.updatedAt + } + } + + if (ACT_ENERGY in metrics) { + val leftover = HealthConnectTypeMappings.activityLeftover(row.calories, netting.kcal(dayStart)) + if (HealthConnectTypeMappings.isPlausibleActiveCalories(leftover)) { + records += ActiveCaloriesBurnedRecord( + start, + startOffset, + end, + endOffset, + Energy.kilocalories(leftover), + metadata(device, ACT_ENERGY, dayStart, row.updatedAt), + ) + highWaters += row.updatedAt + } + } + + if (ACT_DIST in metrics) { + val leftover = HealthConnectTypeMappings.activityLeftover(row.distanceMeters, netting.meters(dayStart)) + if (HealthConnectTypeMappings.isPlausibleDistanceMeters(leftover)) { + records += DistanceRecord( + start, + startOffset, + end, + endOffset, + Length.meters(leftover), + metadata(device, ACT_DIST, dayStart, row.updatedAt), + ) + highWaters += row.updatedAt + } + } + + if (records.size == before) { + skipped++ + droppedHigh = maxOf(droppedHigh ?: Long.MIN_VALUE, row.updatedAt) + } + } + return PendingActivity(records, highWaters, skipped, droppedHigh) + } + + private fun metadata(device: Device, metric: String, dayStart: Long, updatedAt: Long): Metadata = + Metadata.autoRecorded( + device, + HealthConnectTypeMappings.activityRecordId(metric, dayStart), + updatedAt, + ) + + /** + * The finished workouts overlapping the pending days, reduced to netting inputs. One query + * spans the whole pending range rather than one per day — a first-enable backfill can select + * years of rows. + */ + private suspend fun loadNetting(rows: List, zone: ZoneId): WorkoutNetting { + // Bound the query on the NORMALIZED day starts (startOfDayLocal), not the raw stored date: + // after a timezone change startOfDayLocal(date) can be up to a day earlier than date, and a + // session starting in that [normalizedDayStart, date) window maps to the same netting day + // key as the daily row — so a raw `from` would exclude it and its energy/distance would not + // net out, letting a consumer double-count the workout against the daily aggregate. + val from = rows.minOf { TimeUtil.startOfDayLocal(it.date, zone) } + val to = Instant.ofEpochMilli(rows.maxOf { TimeUtil.startOfDayLocal(it.date, zone) }) + .atZone(zone).plusDays(1).toInstant().toEpochMilli() + val sessions = db.activitySessionDao().finishedStartedBetween(from, to) + if (sessions.isEmpty()) return WorkoutNetting.EMPTY + return HealthConnectTypeMappings.workoutNetting( + sessions.map { + NettableSession( + dayStartMs = TimeUtil.startOfDayLocal(it.startedAt, zone), + calories = it.calories, + distanceMeters = it.distanceMeters, + useGps = it.useGps, + // Duration inputs for the credit-eligibility check (workoutNetting skips the + // same sub-minute sessions ActivityRollup.credit never credited). + startedAtMs = it.startedAt, + endedAtMs = it.endedAt, + totalPauseSeconds = it.totalPauseSeconds, + ) + }, + ) + } +} diff --git a/app/src/main/java/com/pulseloop/health/exporters/NutritionExporter.kt b/app/src/main/java/com/pulseloop/health/exporters/NutritionExporter.kt new file mode 100644 index 0000000..62a4859 --- /dev/null +++ b/app/src/main/java/com/pulseloop/health/exporters/NutritionExporter.kt @@ -0,0 +1,128 @@ +package com.pulseloop.health.exporters + +import androidx.health.connect.client.records.NutritionRecord +import androidx.health.connect.client.records.Record +import androidx.health.connect.client.records.metadata.Device +import androidx.health.connect.client.records.metadata.Metadata +import androidx.health.connect.client.units.Energy +import androidx.health.connect.client.units.Mass +import com.pulseloop.data.PulseLoopDatabase +import com.pulseloop.data.entity.MealEntryEntity +import com.pulseloop.health.HealthConnectTypeMappings +import com.pulseloop.health.HealthConnectTypeMappings.EXCLUDED_SOURCES +import java.time.Instant +import java.time.ZoneId + +/** + * Builds the Phase 5 nutrition records (docs/health-connect-integration.md Phase 5): one + * [NutritionRecord] per [MealEntryEntity]. A [NutritionRecord] is an IntervalRecord carrying + * energy + all the macros the app logs (protein/carbs/fat/fiber/sugar/sodium) in a single + * record - simpler than HealthKit's seven separate dietary types. + * + * Identity: `clientRecordId = pl-meal-` - the meal's Room primary key is a + * stable UUID (a logged meal is insert-once, never churned on re-sync like a sleep block), so it + * is a safe upsert key; `clientRecordVersion = meal.updatedAt`. The interval is the meal's log + * instant with a +60 s end (a meal spans some eating time; Health Connect requires end > start). + * + * Watermarked on `updatedAt` by [HealthConnectExporter] (Phase 6) - iOS parity: iOS's meal model + * carries an `updatedAt` driving its export watermark because iOS edits meals in place. Android + * is insert-once today, so `updatedAt == createdAt` for every row and the switch is + * behavior-preserving; when an in-place meal-edit path lands it only needs to bump `updatedAt` + * and the edited meal re-exports under the same clientRecordId at a higher version (write-only + * means no repair, so the watermark must see the edit). + */ +class NutritionExporter(private val db: PulseLoopDatabase) { + + data class PendingNutrition( + val records: List, + val highWaters: List, + val skippedMeals: Int, + /** + * Max `updatedAt` among meals that were selected but produced no record (out of the + * platform's range, or empty). Without it a dropped meal above every exportable one pins + * the NUTRITION watermark and every later pass re-selects the tail behind it. Safe to + * advance past: correcting a meal bumps `updatedAt`, which re-selects it wherever the + * watermark stands. Applied only on a fully completed pass — see + * [com.pulseloop.health.watermarkAdvance]. + */ + val droppedHighWater: Long? = null, + ) + + /** + * Meals newer than the nutrition watermark, reduced to [NutritionRecord]s. A meal whose value + * is outside the platform's range (a typo) is dropped, never clamped, so one bad meal cannot + * sink the whole 200-record chunk. + */ + suspend fun build(watermark: Long?, device: Device): PendingNutrition { + val wm = watermark ?: 0L + val zone = ZoneId.systemDefault() + val selected = db.mealEntryDao().updatedSince(wm) + val meals = selected.filter { it.sourceRaw !in EXCLUDED_SOURCES } + val records = mutableListOf() + val highWaters = mutableListOf() + var skipped = 0 + // Demo/mock rows are permanently unexportable (a row's source never changes), so they + // count as drops too — otherwise a seeded demo meal newer than every real one pins the + // watermark forever. + var droppedHigh: Long? = selected.filter { it.sourceRaw in EXCLUDED_SOURCES } + .maxOfOrNull { it.updatedAt } + for (meal in meals) { + val record = buildRecord(meal, device, zone) + if (record == null) { + skipped++ + droppedHigh = maxOf(droppedHigh ?: Long.MIN_VALUE, meal.updatedAt) + continue + } + records += record + highWaters += meal.updatedAt + } + return PendingNutrition(records, highWaters, skipped, droppedHigh) + } + + private fun buildRecord(meal: MealEntryEntity, device: Device, zone: ZoneId): NutritionRecord? { + // Validate every value we are about to set before building: an out-of-range meal is + // dropped (never clamped) so a typo cannot throw from the ctor and sink the chunk. + if (meal.calories > 0.0 && !HealthConnectTypeMappings.isPlausibleNutritionEnergyKcal(meal.calories)) return null + for (v in listOf(meal.proteinG, meal.carbsG, meal.fatG, meal.fiberG, meal.sugarG, meal.sodiumMg)) { + if (v != null && v > 0.0 && !HealthConnectTypeMappings.isPlausibleNutritionMass(v)) return null + } + + val energy = if (meal.calories > 0.0) Energy.kilocalories(meal.calories) else null + val protein = if (meal.proteinG > 0.0) Mass.grams(meal.proteinG) else null + val carbs = if (meal.carbsG > 0.0) Mass.grams(meal.carbsG) else null + val fat = if (meal.fatG > 0.0) Mass.grams(meal.fatG) else null + val fiber = meal.fiberG?.takeIf { it > 0.0 }?.let { Mass.grams(it) } + val sugar = meal.sugarG?.takeIf { it > 0.0 }?.let { Mass.grams(it) } + // sodium is logged in milligrams and the platform field caps at 100 g = 100,000 mg; + // Mass.milligrams (NOT grams - grams would be a 1000x error and throw for > 100 g). + val sodium = meal.sodiumMg?.takeIf { it > 0.0 }?.let { Mass.milligrams(it) } + + // A meal with no name and no logged nutrient is an empty entry - nothing to export. + if (meal.name.isBlank() && energy == null && protein == null && carbs == null && fat == null && + fiber == null && sugar == null && sodium == null) return null + + // Clamp a future-dated meal to now (iOS parity, HealthSyncService+Nutrition.swift:79) so a + // clock-skewed timestamp never produces a future-dated record. + val start = Instant.ofEpochMilli(minOf(meal.timestamp, System.currentTimeMillis())) + val end = start.plusSeconds(60) + val offset = HealthConnectTypeMappings.zoneOffsetAt(start, zone) + return NutritionRecord( + startTime = start, + startZoneOffset = offset, + endTime = end, + endZoneOffset = offset, + // Meals are user-initiated (logged in the nutrition screen) - activelyRecorded, the + // same choice Phase 4 made for user-initiated workout records. + metadata = Metadata.activelyRecorded(device, HealthConnectTypeMappings.nutritionRecordId(meal.id), meal.updatedAt), + energy = energy, + protein = protein, + totalCarbohydrate = carbs, + totalFat = fat, + dietaryFiber = fiber, + sugar = sugar, + sodium = sodium, + name = meal.name, + mealType = HealthConnectTypeMappings.nutritionMealType(meal.mealTypeRaw), + ) + } +} diff --git a/app/src/main/java/com/pulseloop/health/exporters/RestingHeartRateExporter.kt b/app/src/main/java/com/pulseloop/health/exporters/RestingHeartRateExporter.kt new file mode 100644 index 0000000..ec8e567 --- /dev/null +++ b/app/src/main/java/com/pulseloop/health/exporters/RestingHeartRateExporter.kt @@ -0,0 +1,56 @@ +package com.pulseloop.health.exporters + +import androidx.health.connect.client.records.Record +import androidx.health.connect.client.records.RestingHeartRateRecord +import androidx.health.connect.client.records.metadata.Device +import androidx.health.connect.client.records.metadata.Metadata +import com.pulseloop.data.PulseLoopDatabase +import com.pulseloop.health.HealthConnectTypeMappings +import java.time.Instant + +/** + * Builds the Phase 5 resting-heart-rate record (docs/health-connect-integration.md Phase 5): + * one [RestingHeartRateRecord] for the user's learned resting-HR baseline + * ([com.pulseloop.data.entity.UserProfileEntity.hrRestingBaseline], computed by + * [com.pulseloop.service.RestingHRBaselineService] from the p10 of the trailing 30 days of HR). + * + * Unlike every other group, this is a **single mutable value, not a time series**: there is + * exactly one baseline, so the record is keyed on a constant [HealthConnectTypeMappings.RESTING_HR_RECORD_ID] + * and its instant is the baseline's `hrRestingBaselineUpdatedAt` - the + * moment the value was learned. [HealthConnectExporter] owns the RESTING_HR watermark (that + * same `hrRestingBaselineUpdatedAt`), so a re-learn advances the version and upserts the SAME + * record in place, while an unchanged baseline re-reads nothing. + * + * `beatsPerMinute` is a whole number; the baseline is stored at 0.5 resolution, so it is rounded + * to the nearest bpm. Pure DB -> record: no client, no inserts. + */ +class RestingHeartRateExporter(private val db: PulseLoopDatabase) { + + data class PendingRestingHr( + val records: List, + val highWaters: List, + ) + + /** + * The single resting-HR record to export, or none. [watermark] is the last exported + * `hrRestingBaselineUpdatedAt` (`null` = never exported); a baseline whose `updatedAt` has not + * advanced past it is already current and produces nothing. + */ + suspend fun build(watermark: Long?, device: Device): PendingRestingHr { + val profile = db.userProfileDao().get() ?: return PendingRestingHr(emptyList(), emptyList()) + val baseline = profile.hrRestingBaseline ?: return PendingRestingHr(emptyList(), emptyList()) + val updatedAt = profile.hrRestingBaselineUpdatedAt ?: return PendingRestingHr(emptyList(), emptyList()) + if (watermark != null && updatedAt <= watermark) return PendingRestingHr(emptyList(), emptyList()) + if (!HealthConnectTypeMappings.isPlausibleRestingHr(baseline)) return PendingRestingHr(emptyList(), emptyList()) + + val instant = Instant.ofEpochMilli(updatedAt) + val record = RestingHeartRateRecord( + time = instant, + zoneOffset = HealthConnectTypeMappings.zoneOffsetAt(instant), + beatsPerMinute = Math.round(baseline).toLong(), + metadata = Metadata.autoRecorded(device, HealthConnectTypeMappings.RESTING_HR_RECORD_ID, updatedAt), + ) + // high water = the baseline's updatedAt (the RESTING_HR watermark the exporter advances). + return PendingRestingHr(listOf(record), listOf(updatedAt)) + } +} diff --git a/app/src/main/java/com/pulseloop/health/exporters/SleepExporter.kt b/app/src/main/java/com/pulseloop/health/exporters/SleepExporter.kt new file mode 100644 index 0000000..277345f --- /dev/null +++ b/app/src/main/java/com/pulseloop/health/exporters/SleepExporter.kt @@ -0,0 +1,146 @@ +package com.pulseloop.health.exporters + +import androidx.health.connect.client.records.Record +import androidx.health.connect.client.records.SleepSessionRecord +import androidx.health.connect.client.records.metadata.Device +import androidx.health.connect.client.records.metadata.Metadata +import com.pulseloop.data.PulseLoopDatabase +import com.pulseloop.data.entity.SleepSessionEntity +import com.pulseloop.health.HealthConnectTypeMappings +import com.pulseloop.health.HealthConnectTypeMappings.EXCLUDED_SOURCES +import com.pulseloop.health.HealthConnectTypeMappings.SleepDaySession +import com.pulseloop.health.HealthConnectTypeMappings.SleepStageSpan +import java.time.Instant +import java.time.ZoneId + +/** + * Builds the Phase 2 sleep records (docs/health-connect-integration.md Phase 2): one + * `SleepSessionRecord` per [SleepSessionEntity], its stages from the session's + * [com.pulseloop.data.entity.SleepStageBlockEntity] rows — sorted, clamped to the session + * bounds, overlaps dropped (keep the earlier, truncate the later), zero-length stages dropped + * ([HealthConnectTypeMappings.normalizeSleepStages]) — with the client's stage-type constants + * (never raw ints). + * + * Identity (plan §3, identity trap #1): `clientRecordId = pl-sleep-` from the + * session's `date` — NEVER the block id (a fresh random UUID on every re-sync) nor the session + * UUID — so a re-synced night upserts the SAME record in place. `clientRecordVersion` = the + * session's `updatedAt`, so the later, fuller re-sync always wins the upsert. + * + * The selection is watermark-driven on `updatedAt` (sleep is a mutable group: re-synced nights + * must re-export), and demo rows are excluded (mirrors iOS). Pure DB → records: no client, no + * inserts — [com.pulseloop.health.HealthConnectExporter] owns the write path. + */ +class SleepExporter(private val db: PulseLoopDatabase) { + + /** + * Pending sleep records with the parallel [highWaters] list: entry i is the source session's + * `updatedAt` that record i represents, so the exporter can advance the sleep watermark only + * to a value whose sessions all reached Health Connect. [skippedSessions] counts sessions + * selected by the watermark but not written (invalid span, or no stages after normalization). + */ + data class PendingSleep( + val records: List, + val highWaters: List, + val skippedSessions: Int, + /** + * Max `updatedAt` among sessions that were selected but produced no record. Without it a + * dropped session sitting above every exportable one pins the SLEEP watermark, and every + * later pass re-selects and re-upserts the whole tail behind it. Safe to advance past: + * every repair (a re-sync that adds stages, or fixes the span) bumps `updatedAt`, which + * re-selects the row regardless of where the watermark stands. Applied only on a fully + * completed pass — see [com.pulseloop.health.watermarkAdvance]. + */ + val droppedHighWater: Long? = null, + ) + + /** + * Builds the pending records. Sessions with `updatedAt <= [watermark]` were already + * exported; `null` means export everything (first-enable backfill). + */ + suspend fun build(watermark: Long?, device: Device): PendingSleep { + val wm = watermark ?: 0L + val zone = ZoneId.systemDefault() + val dao = db.sleepSessionDao() + val selected = dao.updatedSince(wm) + val sessions = selected.filter { it.sourceRaw !in EXCLUDED_SOURCES } + // Demo/mock rows are permanently unexportable (a row's source never changes), so they + // count as drops — otherwise a seeded demo night newer than every real one pins the + // watermark forever. + var droppedHigh: Long? = selected.filter { it.sourceRaw in EXCLUDED_SOURCES } + .maxOfOrNull { it.updatedAt } + if (sessions.isEmpty()) return PendingSleep(emptyList(), emptyList(), 0, droppedHigh) + fun drop(updatedAt: Long) { droppedHigh = maxOf(droppedHigh ?: Long.MIN_VALUE, updatedAt) } + + // All blocks for the pending sessions in one query, grouped by session. + val blocksBySession = db.sleepStageBlockDao() + .forSessions(sessions.map { it.id }) + .groupBy { it.sessionId } + + // Main-session selection needs each day's FULL non-demo session set — the pending list + // alone cannot tell a night from the nap that shares its waking day (plan §3: the plain + // id belongs to the day's main sleep). + val dayCache = HashMap>() + + val records = mutableListOf() + val highWaters = mutableListOf() + var skipped = 0 + + for (session in sessions) { + if (session.endAt <= session.startAt) { + skipped++ + drop(session.updatedAt) + continue // the record constructor would reject it + } + val day = dayCache.getOrPut(session.date) { dao.ringAllByDay(session.date) } + val index = day.indexOfFirst { it.id == session.id } + if (index < 0) { + skipped++ + drop(session.updatedAt) + continue // cannot happen: the query filters to the same sourceRaw set + } + val recordId = HealthConnectTypeMappings.sleepSessionRecordId( + session.date, + HealthConnectTypeMappings.sleepSessionSuffix( + day.map { SleepDaySession(it.startAt, it.totalMinutes.toLong()) }, + index, + ), + ) + + val spans = blocksBySession[session.id].orEmpty().map { + SleepStageSpan( + it.startAt, + it.startAt + it.durationMinutes * 60_000L, + HealthConnectTypeMappings.sleepStageType(it.stageRaw), + ) + } + val stages = HealthConnectTypeMappings.normalizeSleepStages(session.startAt, session.endAt, spans) + if (stages.isEmpty()) { + skipped++ + drop(session.updatedAt) + continue // Gadgetbridge parity: a session with no valid stages is not written; + // a later re-sync that adds stages bumps updatedAt and re-selects it + } + + val start = Instant.ofEpochMilli(session.startAt) + val end = Instant.ofEpochMilli(session.endAt) + records += SleepSessionRecord( + startTime = start, + startZoneOffset = HealthConnectTypeMappings.zoneOffsetAt(start, zone), + endTime = end, + endZoneOffset = HealthConnectTypeMappings.zoneOffsetAt(end, zone), + metadata = Metadata.autoRecorded(device, recordId, session.updatedAt), + title = null, + notes = null, + stages = stages.map { + SleepSessionRecord.Stage( + Instant.ofEpochMilli(it.startMs), + Instant.ofEpochMilli(it.endMs), + it.stageType, + ) + }, + ) + highWaters += session.updatedAt + } + return PendingSleep(records, highWaters, skipped, droppedHigh) + } +} diff --git a/app/src/main/java/com/pulseloop/health/exporters/VitalsExporter.kt b/app/src/main/java/com/pulseloop/health/exporters/VitalsExporter.kt new file mode 100644 index 0000000..0c5a887 --- /dev/null +++ b/app/src/main/java/com/pulseloop/health/exporters/VitalsExporter.kt @@ -0,0 +1,291 @@ +package com.pulseloop.health.exporters + +import androidx.health.connect.client.records.BodyTemperatureRecord +import androidx.health.connect.client.records.BodyTemperatureMeasurementLocation +import androidx.health.connect.client.records.BloodGlucoseRecord +import androidx.health.connect.client.records.BloodPressureRecord +import androidx.health.connect.client.records.HeartRateRecord +import androidx.health.connect.client.records.HeartRateVariabilityRmssdRecord +import androidx.health.connect.client.records.OxygenSaturationRecord +import androidx.health.connect.client.records.Record +import androidx.health.connect.client.records.RespiratoryRateRecord +import androidx.health.connect.client.records.Vo2MaxRecord +import androidx.health.connect.client.records.metadata.Device +import androidx.health.connect.client.records.metadata.Metadata +import androidx.health.connect.client.units.BloodGlucose +import androidx.health.connect.client.units.Percentage +import androidx.health.connect.client.units.Pressure +import androidx.health.connect.client.units.Temperature +import com.pulseloop.data.PulseLoopDatabase +import com.pulseloop.data.entity.MeasurementEntity +import com.pulseloop.health.HealthConnectTypeMappings +import com.pulseloop.health.HealthConnectTypeMappings.HrSample +import com.pulseloop.health.HealthConnectTypeMappings.EXCLUDED_SOURCES +import java.time.Instant +import java.time.ZoneId + +/** + * Builds the VITALS-group records (docs/health-connect-integration.md Phase 1 + Phase 5): + * - heart rate as **series** records, one per local hour bucket, split per Gadgetbridge's rules + * (see [HealthConnectTypeMappings.splitHrSegments]) and re-upserted whole whenever the hour + * gains a sample — `clientRecordVersion` = max `createdAt` in the bucket, so the fuller, later + * version always wins; + * - SpO₂ / HRV (RMSSD) / body temperature (Phase 1) and blood glucose / respiratory rate / + * VO₂max (Phase 5) as instantaneous records, one per row, keyed `pl-m--` so a + * reading that arrives once live and once via history collapses onto one Health Connect record; + * - blood pressure (Phase 5) as paired instantaneous records: systolic + diastolic rows matched + * by exact timestamp into one [BloodPressureRecord], `pl-m-bp-` (see + * [HealthConnectTypeMappings.pairBloodPressure]). + * + * The selection is watermark-driven on `Measurement.createdAt` (never the sample timestamp — + * late-arriving ring history must still be picked up), and demo/mock rows are excluded (mirrors + * iOS). Pure DB → records: no client, no inserts — [com.pulseloop.health.HealthConnectExporter] + * owns the write path. + */ +class VitalsExporter(private val db: PulseLoopDatabase) { + + /** + * kindKey (the id token, e.g. "hr") -> the `measurements.kindRaw` column value to query. + * Every write path persists kindRaw = MeasurementKind..name (EventPersistenceSubscriber, + * DemoDataSeeder, MetricsService, MeasurementModal) - so the DAO query MUST use the .name, + * not the id token. (The Phase 1 bug: this queried by the .key, which matched no real or demo + * data and silently exported nothing for live ring history.) + */ + private val kindRaw: Map = mapOf( + "hr" to "HEART_RATE", + "spo2" to "SPO2", + "hrv" to "HRV", + "temp" to "TEMPERATURE", + // Phase 5 measurement-based kinds (instantaneous; "bp" is paired and handled separately). + "glucose" to "BLOOD_SUGAR", + "resp_rate" to "RESPIRATORY_RATE", + "vo2max" to "VO2MAX", + // the two source rows a blood-pressure reading is stored as (pair-builder queries both). + "bp_sys" to "BLOOD_PRESSURE_SYSTOLIC", + "bp_dia" to "BLOOD_PRESSURE_DIASTOLIC", + ) + + /** + * Records to insert for one vitals kind, with the parallel [highWaters] list: entry i is the + * max source-row `createdAt` that record i represents, so the exporter can advance the + * watermark only to a value whose rows all reached Health Connect. + */ + data class PendingKind( + val kindKey: String, + val records: List, + val highWaters: List, + /** Readings dropped by this kind's guard (unpaired / out-of-range BP), reported as skipped. */ + val skipped: Int = 0, + /** + * Max `createdAt` among selected rows that can NEVER become exportable — a demo/mock row + * (a row's source never changes) or a value outside the platform's range (a stored + * measurement's value never changes). Without it such a row above every exportable one + * pins the shared VITALS watermark, and every later pass re-reads and re-upserts all eight + * kinds' tails behind it. UNPAIRED blood-pressure rows are excluded on purpose: the + * missing side may still arrive, and it can only pair while the present side stays above + * the watermark. Applied only on a fully completed pass — see + * [com.pulseloop.health.watermarkAdvance]. + */ + val droppedHighWater: Long? = null, + ) + + /** + * Builds the pending records for [kindKey] ("hr" / "spo2" / "hrv" / "temp" / "glucose" / + * "resp_rate" / "vo2max" / "bp"). Rows with `createdAt <= [watermark]` were already exported; + * `null` means export everything. + */ + suspend fun build(kindKey: String, watermark: Long?, device: Device): PendingKind { + val wm = watermark ?: 0L + val dao = db.measurementDao() + return when (kindKey) { + "hr" -> buildHr(wm, device) + "bp" -> buildBloodPressure(wm, device) + else -> buildInstantaneous(kindKey, wm, device) + } + } + + // ── heart rate: hourly buckets, whole-hour re-read, series splitting ── + + private suspend fun buildHr(wm: Long, device: Device): PendingKind { + val dao = db.measurementDao() + // Watermark selection on createdAt — tells us WHICH local hours are touched… + val selected = dao.createdSince(kindRaw.getValue("hr"), wm) + val newRows = selected.filter { it.sourceRaw !in EXCLUDED_SOURCES } + var droppedHigh: Long? = selected.filter { it.sourceRaw in EXCLUDED_SOURCES } + .maxOfOrNull { it.createdAt } + if (newRows.isEmpty()) return PendingKind("hr", emptyList(), emptyList(), droppedHighWater = droppedHigh) + val zone = ZoneId.systemDefault() + + val touchedHours = newRows + .map { HealthConnectTypeMappings.hourStartOf(it.timestamp, zone) } + .distinct() + .sorted() + + val records = mutableListOf() + val highWaters = mutableListOf() + + for (hourStart in touchedHours) { + // …so we re-read every touched hour IN FULL by sample timestamp and rebuild its + // records from scratch: an hour that gains a sample must re-upsert the whole hour + // (plan §3). The end bound is exclusive-of-next-hour by one millisecond (BETWEEN is + // inclusive). + val hourRows = dao.rangeReal(kindRaw.getValue("hr"), hourStart, hourStart + HealthConnectTypeMappings.HOUR_MS - 1) + val samples = hourRows + .filter { it.sourceRaw !in EXCLUDED_SOURCES } + .filter { HealthConnectTypeMappings.isPlausibleHr(it.value) } + .map { HrSample(it.timestamp, it.value.toLong()) } + if (samples.isEmpty()) { + // Every reading in this hour is implausible — permanently unexportable, so the + // hour's rows must not hold the group watermark down. + newRows.filter { HealthConnectTypeMappings.hourStartOf(it.timestamp, zone) == hourStart } + .maxOfOrNull { it.createdAt } + ?.let { droppedHigh = maxOf(droppedHigh ?: Long.MIN_VALUE, it) } + continue + } + + val segments = HealthConnectTypeMappings.splitHrSegments(samples, zone) + // clientRecordVersion = max createdAt in the bucket — a later, fuller version always + // wins the upsert. + val version = hourRows.filter { it.sourceRaw !in EXCLUDED_SOURCES }.maxOf { it.createdAt } + + segments.forEachIndexed { index, segment -> + val startMs = segment.first().timeMs + val endMs = HealthConnectTypeMappings.seriesEndMs(startMs, segment.last().timeMs) + val start = Instant.ofEpochMilli(startMs) + val end = Instant.ofEpochMilli(endMs) + val recordId = HealthConnectTypeMappings.hrRecordId(hourStart, if (segments.size > 1) index else null) + records += HeartRateRecord( + start, + HealthConnectTypeMappings.zoneOffsetAt(start, zone), + end, + HealthConnectTypeMappings.zoneOffsetAt(end, zone), + segment.map { HeartRateRecord.Sample(Instant.ofEpochMilli(it.timeMs), it.bpm) }, + Metadata.autoRecorded(device, recordId, version), + ) + highWaters += version + } + } + return PendingKind("hr", records, highWaters, droppedHighWater = droppedHigh) + } + + // ── blood pressure: pair systolic + diastolic by timestamp into one record ── + + /** + * Pairs the systolic and diastolic [MeasurementEntity] rows that share a sample instant into + * one [BloodPressureRecord] (plan Phase 5). The app always writes the pair with the same + * `timestamp` (one [com.pulseloop.service.EventPersistenceSubscriber] transaction for live, + * one `event.timestamp` for history), so exact-timestamp equality is the correct pairing key - + * a tolerance would risk cross-pairing two nearby readings. An unpaired row (systolic with no + * diastolic, or vice versa) is a decode/storage anomaly and is dropped; the empty-kind + * watermark rule self-resolves it. `clientRecordId` is keyed on the shared timestamp, never on + * either row's random live UUID (plan identity trap #2), so a reading that arrives once live + * and once via history still lands on the same record. + */ + private suspend fun buildBloodPressure(wm: Long, device: Device): PendingKind { + val dao = db.measurementDao() + val sysSelected = dao.createdSince(kindRaw.getValue("bp_sys"), wm) + val diaSelected = dao.createdSince(kindRaw.getValue("bp_dia"), wm) + val sysRows = sysSelected.filter { it.sourceRaw !in EXCLUDED_SOURCES } + val diaRows = diaSelected.filter { it.sourceRaw !in EXCLUDED_SOURCES } + val excludedHigh = (sysSelected + diaSelected).filter { it.sourceRaw in EXCLUDED_SOURCES } + .maxOfOrNull { it.createdAt } + + val zone = ZoneId.systemDefault() + // Pure pairing + plausibility (see HealthConnectTypeMappings.pairBloodPressure): unpaired + // readings and out-of-range pairs are dropped here, so every result becomes one record. + val pairing = HealthConnectTypeMappings.pairBloodPressure( + sysRows.map { HealthConnectTypeMappings.BpSide(it.timestamp, it.value, it.createdAt) }, + diaRows.map { HealthConnectTypeMappings.BpSide(it.timestamp, it.value, it.createdAt) }, + ) + val records = mutableListOf() + val highWaters = mutableListOf() + for (pair in pairing.pairs) { + val instant = Instant.ofEpochMilli(pair.timestampMs) + val record = BloodPressureRecord( + time = instant, + zoneOffset = HealthConnectTypeMappings.zoneOffsetAt(instant, zone), + metadata = Metadata.autoRecorded(device, HealthConnectTypeMappings.bloodPressureRecordId(pair.timestampMs), 1L), + systolic = Pressure.millimetersOfMercury(pair.systolic), + diastolic = Pressure.millimetersOfMercury(pair.diastolic), + ) + records += record + highWaters += pair.highWater + } + // Out-of-range pairs and demo rows can never export; unpaired rows still can (see + // BpPairingResult.outOfRangeHighWater), so they keep holding the watermark. + val droppedHigh = listOfNotNull(excludedHigh, pairing.outOfRangeHighWater).maxOrNull() + return PendingKind("bp", records, highWaters, skipped = pairing.dropped, droppedHighWater = droppedHigh) + } + + // ── instantaneous kinds: one record per row ── + + private suspend fun buildInstantaneous(kindKey: String, wm: Long, device: Device): PendingKind { + val selected = db.measurementDao().createdSince(kindRaw.getValue(kindKey), wm) + val rows = selected.filter { it.sourceRaw !in EXCLUDED_SOURCES } + val zone = ZoneId.systemDefault() + val records = mutableListOf() + val highWaters = mutableListOf() + var droppedHigh: Long? = selected.filter { it.sourceRaw in EXCLUDED_SOURCES } + .maxOfOrNull { it.createdAt } + for (row in rows) { + val instant = Instant.ofEpochMilli(row.timestamp) + val offset = HealthConnectTypeMappings.zoneOffsetAt(instant, zone) + val record: Record? = when (kindKey) { + "spo2" -> if (HealthConnectTypeMappings.isPlausibleSpO2(row.value)) { + OxygenSaturationRecord(instant, offset, Percentage(row.value), metadata(row, "spo2", device)) + } else null + "hrv" -> if (HealthConnectTypeMappings.isPlausibleHrvRmssd(row.value)) { + HeartRateVariabilityRmssdRecord(instant, offset, row.value, metadata(row, "hrv", device)) + } else null + "temp" -> if (HealthConnectTypeMappings.isPlausibleBodyTemperature(row.value)) { + BodyTemperatureRecord( + instant, + offset, + metadata(row, "temp", device), + Temperature.celsius(row.value), + BodyTemperatureMeasurementLocation.MEASUREMENT_LOCATION_FINGER, + ) + } else null + "glucose" -> if (HealthConnectTypeMappings.isPlausibleBloodGlucose(row.value)) { + BloodGlucoseRecord( + time = instant, + zoneOffset = offset, + level = BloodGlucose.milligramsPerDeciliter(row.value), + metadata = metadata(row, "glucose", device), + ) + } else null + "resp_rate" -> if (HealthConnectTypeMappings.isPlausibleRespRate(row.value)) { + RespiratoryRateRecord( + time = instant, + zoneOffset = offset, + rate = row.value, + metadata = metadata(row, "resp_rate", device), + ) + } else null + "vo2max" -> if (HealthConnectTypeMappings.isPlausibleVo2Max(row.value)) { + Vo2MaxRecord( + time = instant, + zoneOffset = offset, + vo2MillilitersPerMinuteKilogram = row.value, + measurementMethod = Vo2MaxRecord.MEASUREMENT_METHOD_OTHER, + metadata = metadata(row, "vo2max", device), + ) + } else null + else -> null + } + if (record != null) { + records += record + highWaters += row.createdAt + } else { + // Guard failure on an immutable stored value — permanently unexportable. + droppedHigh = maxOf(droppedHigh ?: Long.MIN_VALUE, row.createdAt) + } + } + return PendingKind(kindKey, records, highWaters, droppedHighWater = droppedHigh) + } + + private fun metadata(row: MeasurementEntity, kindKey: String, device: Device): Metadata = + // Immutable sample → version 1; the id derives from kind + sample instant (never the row's + // random live UUID) so live/history duplicates collapse onto one Health Connect record. + Metadata.autoRecorded(device, HealthConnectTypeMappings.vitalsRecordId(kindKey, row.timestamp), 1L) +} diff --git a/app/src/main/java/com/pulseloop/health/exporters/WorkoutExporter.kt b/app/src/main/java/com/pulseloop/health/exporters/WorkoutExporter.kt new file mode 100644 index 0000000..d0503b0 --- /dev/null +++ b/app/src/main/java/com/pulseloop/health/exporters/WorkoutExporter.kt @@ -0,0 +1,254 @@ +package com.pulseloop.health.exporters + +import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord +import androidx.health.connect.client.records.DistanceRecord +import androidx.health.connect.client.records.ExerciseRoute +import androidx.health.connect.client.records.ExerciseSessionRecord +import androidx.health.connect.client.records.Record +import androidx.health.connect.client.records.metadata.Device +import androidx.health.connect.client.records.metadata.Metadata +import androidx.health.connect.client.units.Energy +import androidx.health.connect.client.units.Length +import com.pulseloop.data.PulseLoopDatabase +import com.pulseloop.data.entity.ActivitySessionEntity +import com.pulseloop.health.HealthConnectTypeMappings +import com.pulseloop.health.HealthConnectTypeMappings.WK_DIST +import com.pulseloop.health.HealthConnectTypeMappings.WK_ENERGY +import com.pulseloop.health.HealthConnectTypeMappings.WorkoutSelection +import com.pulseloop.ui.components.ActivityMeta +import java.time.Instant +import java.time.ZoneId + +/** + * Builds the Phase 4 workout records (docs/health-connect-integration.md Phase 4): one + * `ExerciseSessionRecord` per finished [ActivitySessionEntity] — the exercise-type map, + * [ActivityMeta.label] as the title, the session's notes, and an embedded [ExerciseRoute] from + * the session's accepted GPS fixes — plus sibling `ActiveCaloriesBurnedRecord` / + * `DistanceRecord` over the session window. + * + * Identity (plan §3): `clientRecordId = pl-wk-` (and `-energy` / `-dist` on the + * siblings), `clientRecordVersion = session.updatedAt` — a post-finish edit or vitals backfill + * re-upserts the SAME records in place. The session id is the stable Room primary key: unlike + * sleep blocks, workout rows are never replaced wholesale, so it is a safe upsert key with no + * suffix scheme. + * + * **Siblings == netting set** (Phase 3 amendment): energy is written for every finished session + * with plausible `calories > 0` — exactly what `workoutNetting` subtracts from the daily total; + * distance is written **only** for `useGps` sessions — the set `ActivityRollup.credit` folds + * into the daily row. That equality is what makes a consumer summing the daily aggregate plus + * the workout siblings land on the app's stored day total; an un-netted sibling would break it, + * and a netted-but-unwritten one would under-report. + * + * Route rules (plan Phase 4): `accepted` fixes only; sanitised by + * [HealthConnectTypeMappings.sanitizeRoutePoints] (session window, finite/in-range coordinates, + * duplicate timestamps); ≥ 2 clean points, else no route — the session still writes. [withRoute] + * is false when `WRITE_EXERCISE_ROUTE` was not granted: the session then writes without a route + * (partial grants are first-class). Platform update semantics (exercise-routes guide): re-upserting + * a session record while the route permission is granted but the new build carries no route + * DELETES the previously exported route — acceptable here for both no-route cases: the session was + * just edited such that its fixes no longer support a route (removing the stale route is correct), + * or the route permission was revoked (a revocation implies the user no longer wants routes + * exported). The 1 MB per-record limit cannot be known up front — + * [com.pulseloop.health.HealthConnectExporter] wraps the insert with the shrink-retry fallback + * ([HealthConnectTypeMappings.parseRecordSizeLimit] + [HealthConnectTypeMappings.decimateToSize]). + * + * Selection is watermark-driven on `updatedAt`, `ORDER BY updatedAt ASC` for the + * chunked-watermark invariant (Phase 2/3 fix). Two guard classes, mirroring iOS + * `exportWorkouts` and [HealthConnectTypeMappings.selectWorkoutSession]: + * - zero/negative duration can never become exportable → the exporter may advance the + * [PendingWorkouts.invalidHighWater] watermark past it (iOS advances its workout watermark the + * same way); + * - a future-dated `endedAt` (clock skew) **stops the pass** at that session — it is retried on + * the next run once its end passes, never leapfrogged (iOS `guard end <= now else break`). + * + * No demo/mock filter exists for this table: `ActivitySessionEntity` has no source column and + * the demo seeder never creates sessions (it seeds `activity_daily`, measurements and sleep + * only) — rows here are all real data: live-recorded, manually logged (Log Past Activity), + * coach-created, or archive-restored workouts. + * + * Pure DB → records: no client, no inserts — [com.pulseloop.health.HealthConnectExporter] owns + * the write path and decides which record types are both toggled on and permission-granted. + */ +class WorkoutExporter(private val db: PulseLoopDatabase) { + + /** + * Pending workout records with the parallel [highWaters] list: entry i is the source + * session's `updatedAt` that record i represents, so the exporter can advance the workouts + * watermark only to a value whose sessions all reached Health Connect. + * [invalidHighWater] is the max `updatedAt` of zero/negative-duration sessions (never + * exportable — safe to advance past); [blockedFuture] is set when the pass stopped at a + * future-dated session (the watermark must NOT be stamped to "now" in that case); and + * [skippedSessions] counts the invalid sessions dropped along the way. + */ + data class PendingWorkouts( + val records: List, + val highWaters: List, + val invalidHighWater: Long? = null, + val blockedFuture: Boolean = false, + val skippedSessions: Int = 0, + ) + + /** + * Builds the pending records. Sessions with `updatedAt <= [watermark]` were already + * exported; `null` means export everything (first-enable backfill). [withRoute] / + * [withEnergy] / [withDistance] mirror the live `WRITE_EXERCISE_ROUTE` / + * `WRITE_ACTIVE_CALORIES_BURNED` / `WRITE_DISTANCE` grants — the route is embedded, but the + * siblings are standalone records, so each is gated on its OWN permission: a chunk that + * carries a sibling its permission was never granted for would fail the whole insert + * (partial grants are first-class). This keeps the siblings exactly aligned with what the + * activity group actually writes — [com.pulseloop.health.exporters.ActivityExporter] nets a + * metric only when that same permission lets it write the day's record for it. + * [nowMs] is the pass time for the future-dated guard. + */ + suspend fun build( + watermark: Long?, + device: Device, + withRoute: Boolean, + withEnergy: Boolean, + withDistance: Boolean, + nowMs: Long, + zone: ZoneId = ZoneId.systemDefault(), + ): PendingWorkouts { + val wm = watermark ?: 0L + val sessions = db.activitySessionDao().finishedUpdatedSince(wm) + if (sessions.isEmpty()) return PendingWorkouts(emptyList(), emptyList()) + + // All GPS fixes for the pending sessions in one query — a first-enable backfill can + // select years of rows. + val pointsBySession = db.activityGpsPointDao() + .forSessions(sessions.map { it.id }) + .groupBy { it.sessionId } + + val records = mutableListOf() + val highWaters = mutableListOf() + var invalidHigh: Long? = null + var skipped = 0 + var blockedFuture = false + + for (session in sessions) { // updatedAt ASC, per the DAO + val start = session.startedAt + // The query filters to endedAt IS NOT NULL and the EXPORT selection below requires + // it non-null; the elvis is the type-system formality, not a live branch. + val end = session.endedAt ?: continue + when (HealthConnectTypeMappings.selectWorkoutSession(start, end, nowMs)) { + WorkoutSelection.INVALID -> { + skipped++ + // Can never become exportable: let the watermark move past it. The + // continue below is load-bearing, not cosmetic: without it this INVALID + // session (end <= start) falls through to the record-building code and + // ExerciseSessionRecord throws (startTime must be before endTime). That + // escapes build -> run -> doWork, which only catches SecurityException, + // failing the whole pass silently: workouts, resting HR and nutrition + // never export again. + invalidHigh = maxOf(invalidHigh ?: 0L, session.updatedAt) + continue + } + WorkoutSelection.FUTURE -> { + // Clock skew: stop the pass — retry on the next run once its end passes. + blockedFuture = true + break + } + WorkoutSelection.EXPORT -> Unit + } + + val version = session.updatedAt + val startInstant = Instant.ofEpochMilli(start) + val endInstant = Instant.ofEpochMilli(end) + val startOffset = HealthConnectTypeMappings.zoneOffsetAt(startInstant, zone) + val endOffset = HealthConnectTypeMappings.zoneOffsetAt(endInstant, zone) + + val route = if (withRoute) buildRoute(session, pointsBySession[session.id].orEmpty(), start, end) else null + + records += ExerciseSessionRecord( + startTime = startInstant, + startZoneOffset = startOffset, + endTime = endInstant, + endZoneOffset = endOffset, + // ACTIVELY recorded: a PulseLoop workout is user-initiated (the user pressed + // start) — Gadgetbridge marks its ACTIVITY-type records activelyRecorded for the + // same reason; the Phase 1-3 groups stay autoRecorded because ring data is + // collected automatically. + metadata = Metadata.activelyRecorded( + device, HealthConnectTypeMappings.workoutRecordId(session.id), version, + ), + exerciseType = HealthConnectTypeMappings.exerciseType(session.type), + title = ActivityMeta.label(session.type), + notes = session.notes, + exerciseRoute = route, + ) + highWaters += version + + // Sibling energy: netted for every finished session (workoutNetting's kcal rule), + // so the guard is the same plausibility test the daily record applies. + val kcal = session.calories + if (withEnergy && kcal != null && HealthConnectTypeMappings.isPlausibleActiveCalories(kcal)) { + records += ActiveCaloriesBurnedRecord( + startInstant, + startOffset, + endInstant, + endOffset, + Energy.kilocalories(kcal), + Metadata.activelyRecorded( + device, HealthConnectTypeMappings.workoutChildRecordId(session.id, WK_ENERGY), version, + ), + ) + highWaters += version + } + + // Sibling distance: netted for useGps sessions ONLY (Phase 3 amendment) — the same + // set ActivityRollup.credit folds into the daily row. + val meters = session.distanceMeters + if (withDistance && session.useGps && meters != null && HealthConnectTypeMappings.isPlausibleDistanceMeters(meters)) { + records += DistanceRecord( + startInstant, + startOffset, + endInstant, + endOffset, + Length.meters(meters), + Metadata.activelyRecorded( + device, HealthConnectTypeMappings.workoutChildRecordId(session.id, WK_DIST), version, + ), + ) + highWaters += version + } + } + return PendingWorkouts(records, highWaters, invalidHigh, blockedFuture, skipped) + } + + /** + * The session's route from its accepted fixes, or null when sanitisation leaves fewer than + * two points (the session then writes without a route — plan Phase 4). + */ + private fun buildRoute( + session: ActivitySessionEntity, + rawPoints: List, + startMs: Long, + endMs: Long, + ): ExerciseRoute? { + val points = rawPoints + .filter { it.accepted } + .map { + HealthConnectTypeMappings.GpsRoutePoint( + timeMs = it.timestamp, + latitude = it.latitude, + longitude = it.longitude, + horizontalAccuracyMeters = + it.horizontalAccuracy?.takeIf { a -> a.isFinite() && a >= 0.0 }, + altitudeMeters = it.altitude?.takeIf { a -> a.isFinite() }, + ) + } + val clean = HealthConnectTypeMappings.sanitizeRoutePoints(startMs, endMs, points) + if (clean.size < 2) return null + return ExerciseRoute( + clean.map { + ExerciseRoute.Location( + time = Instant.ofEpochMilli(it.timeMs), + latitude = it.latitude, + longitude = it.longitude, + horizontalAccuracy = it.horizontalAccuracyMeters?.let { m -> Length.meters(m) }, + altitude = it.altitudeMeters?.let { m -> Length.meters(m) }, + ) + }, + ) + } +} diff --git a/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt b/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt index 649dc72..82ac204 100644 --- a/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt +++ b/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt @@ -1,8 +1,10 @@ package com.pulseloop.service +import android.content.Context import androidx.room.withTransaction import com.pulseloop.data.PulseLoopDatabase import com.pulseloop.data.entity.* +import com.pulseloop.health.HealthConnectExportWorker import com.pulseloop.ring.* import kotlinx.coroutines.* @@ -11,6 +13,7 @@ import kotlinx.coroutines.* * Subscribes to PulseEventBus and persists ring data to Room. */ class EventPersistenceSubscriber( + private val context: Context, private val db: PulseLoopDatabase, /** * Fired after a data-bearing event lands in Room (measurements, activity, sleep) — the @@ -286,6 +289,10 @@ class EventPersistenceSubscriber( // mode. Self-throttled to every 6h, so calling it on every completed sync is // cheap. RestingHRBaselineService.refreshIfStale(db) + // Health Connect export trigger (Phase 1): a completed history sync is the + // moment new measurements can have landed. Debounced 15 s + REPLACE in the + // worker, so this is cheap to fire on every done. + HealthConnectExportWorker.enqueue(context) } } is PulseEvent.HeartRateComplete -> {} diff --git a/app/src/main/java/com/pulseloop/service/RingSyncWorker.kt b/app/src/main/java/com/pulseloop/service/RingSyncWorker.kt index 7cb68bb..134b4f0 100644 --- a/app/src/main/java/com/pulseloop/service/RingSyncWorker.kt +++ b/app/src/main/java/com/pulseloop/service/RingSyncWorker.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.ProcessLifecycleOwner import androidx.work.* import com.pulseloop.data.PulseLoopDatabase +import com.pulseloop.health.HealthConnectExportWorker import com.pulseloop.ring.PulseEvent import com.pulseloop.ring.PulseEventBus import com.pulseloop.ring.RingBLEClient @@ -119,6 +120,11 @@ class RingSyncWorker( if (isAppForeground()) return@withTimeout Result.success() } + // Health Connect export trigger (Phase 1): the background sync has streamed its + // data — run the (debounced) export pass. The foreground path triggers the same + // worker via the SyncProgress("done") event, so both sync owners cover it. + HealthConnectExportWorker.enqueue(applicationContext) + Result.success() } } catch (e: Exception) { diff --git a/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt b/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt index 3e37251..57abfee 100644 --- a/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt +++ b/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt @@ -70,7 +70,7 @@ fun PulseLoopApp() { val persistence = remember { // Every persisted ring-sync batch republishes the widget snapshot (debounced 2 s), // mirroring the iOS PulseDataChange → WidgetSnapshotPublisher pipeline. - EventPersistenceSubscriber(db) { + EventPersistenceSubscriber(context, db) { com.pulseloop.widgets.WidgetSnapshotPublisher.publishDebounced(context) } } @@ -586,6 +586,9 @@ fun PulseLoopApp() { paddedComposable("settings/strava") { StravaSettingsScreen(onBack = { navController.popBackStack() }) } + paddedComposable("settings/health-connect") { + HealthConnectSettingsScreen(onBack = { navController.popBackStack() }) + } paddedComposable("settings/nutrition") { NutritionSettingsScreen( onBack = { navController.popBackStack() }, diff --git a/app/src/main/java/com/pulseloop/ui/screens/DebugScreen.kt b/app/src/main/java/com/pulseloop/ui/screens/DebugScreen.kt index 04a2ac9..2319243 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/DebugScreen.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/DebugScreen.kt @@ -60,6 +60,8 @@ fun DebugScreen( } Scaffold( + // Insets are already applied by the route wrapper (see SettingsSubScreen). + contentWindowInsets = WindowInsets(0, 0, 0, 0), topBar = { TopAppBar( title = { Text("Debug") }, @@ -68,6 +70,7 @@ fun DebugScreen( Icon(Icons.Filled.ArrowBack, "Back") } }, + windowInsets = WindowInsets(0, 0, 0, 0), ) }, ) { padding -> diff --git a/app/src/main/java/com/pulseloop/ui/screens/NutritionScreen.kt b/app/src/main/java/com/pulseloop/ui/screens/NutritionScreen.kt index 50bfecb..8c3bec2 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/NutritionScreen.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/NutritionScreen.kt @@ -63,6 +63,8 @@ fun NutritionScreen(onBack: () -> Unit) { Scaffold( containerColor = PulseColors.background, + // Insets are already applied by the route wrapper (see SettingsSubScreen). + contentWindowInsets = WindowInsets(0, 0, 0, 0), topBar = { TopAppBar( title = { Text("Nutrition") }, @@ -71,6 +73,7 @@ fun NutritionScreen(onBack: () -> Unit) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } }, + windowInsets = WindowInsets(0, 0, 0, 0), colors = TopAppBarDefaults.topAppBarColors(containerColor = PulseColors.background), ) }, diff --git a/app/src/main/java/com/pulseloop/ui/screens/OnboardingScreen.kt b/app/src/main/java/com/pulseloop/ui/screens/OnboardingScreen.kt index bdaf2c0..4979720 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/OnboardingScreen.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/OnboardingScreen.kt @@ -148,10 +148,12 @@ fun OnboardingScreen( } Column( + // No statusBars padding here: the route wrapper (PulseLoopApp's paddedComposable) has + // already inset this subtree by the outer Scaffold's system bars. Applying it again is + // what left a tall dead band above "Step 1 of 5". Modifier .fillMaxSize() - .background(PulseColors.background) - .windowInsetsPadding(WindowInsets.statusBars), + .background(PulseColors.background), ) { OnboardingTopBar( step = step, diff --git a/app/src/main/java/com/pulseloop/ui/screens/SettingsScreen.kt b/app/src/main/java/com/pulseloop/ui/screens/SettingsScreen.kt index 1b636a0..ce7e13d 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/SettingsScreen.kt @@ -155,6 +155,9 @@ fun SettingsScreen( add(SettingsRowItem(Icons.Filled.TrendingUp, PulseColors.calories, "Strava") { navigate("settings/strava") }) + add(SettingsRowItem(Icons.Filled.Sync, PulseColors.success, "Health Connect") { + navigate("settings/health-connect") + }) add(SettingsRowItem(Icons.Filled.Info, PulseColors.textMuted, "About PulseLoop") { navigate("settings/about") }) diff --git a/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt b/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt index e0f86ba..42a5446 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt @@ -1,11 +1,17 @@ package com.pulseloop.ui.screens import android.Manifest +import android.content.Context import android.content.Intent import android.os.Build import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.health.connect.client.HealthConnectClient +import androidx.health.connect.client.PermissionController +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -41,6 +47,15 @@ import com.pulseloop.coach.config.OpenRouterModel import com.pulseloop.data.DemoDataSeeder import com.pulseloop.data.PulseLoopDatabase import com.pulseloop.data.entity.UserGoalEntity +import com.pulseloop.health.HealthConnectAvailability +import com.pulseloop.health.HealthConnectPermissions +import com.pulseloop.health.HealthConnectPrefs +import com.pulseloop.health.HealthConnectExportWorker +import com.pulseloop.health.HealthConnectPermissionReconcile +import com.pulseloop.health.HealthConnectPrefsStore +import com.pulseloop.health.HealthConnectRemovalWorker +import com.pulseloop.health.HealthConnectWatermarks +import com.pulseloop.health.HealthConnectSdk import com.pulseloop.notifications.CoachNotifications import com.pulseloop.ring.MeasurementKind import com.pulseloop.ring.RingBLEClient @@ -83,6 +98,11 @@ private fun SettingsSubScreen( ) { Scaffold( containerColor = PulseColors.background, + // The NavHost route wrapper (PulseLoopApp's paddedComposable) has ALREADY applied the + // outer Scaffold's system-bar padding to this whole subtree. Consuming the insets a + // second time here — once via contentWindowInsets, once via TopAppBar's own default — + // is what produced the tall dead band above every settings title. + contentWindowInsets = WindowInsets(0, 0, 0, 0), topBar = { TopAppBar( title = { Text(title) }, @@ -91,6 +111,7 @@ private fun SettingsSubScreen( Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } }, + windowInsets = WindowInsets(0, 0, 0, 0), colors = TopAppBarDefaults.topAppBarColors(containerColor = PulseColors.background), ) }, @@ -2394,9 +2415,495 @@ fun StravaSettingsScreen(onBack: () -> Unit) { } statusMessage?.let { msg -> - Card(Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = PulseColors.cardSoft)) { + Card( + Modifier.fillMaxWidth(), + // containerColor alone leaves contentColor Unspecified, so the Text falls back to + // LocalContentColor (dark) and renders near-invisible on this dark card. + colors = CardDefaults.cardColors( + containerColor = PulseColors.cardSoft, + contentColor = PulseColors.textPrimary, + ), + ) { + Text(msg, modifier = Modifier.padding(16.dp), style = MaterialTheme.typography.bodyMedium) + } + } + } +} + +// MARK: - Health Connect + +/** + * Health Connect export settings — the Android analogue of iOS' Apple Health settings + * (docs/health-connect-integration.md, Phase 0/1). Availability, permissions, and preferences; + * the Phase 1 worker owns the actual export. Granting permissions and answering the backfill + * dialog both enqueue the (debounced, hard-gated) export worker. + */ +@Composable +fun HealthConnectSettingsScreen(onBack: () -> Unit) { + val context = LocalContext.current + val store = remember { HealthConnectPrefsStore.get(context) } + val prefs by store.prefs.collectAsState() + // Re-read on every ON_RESUME (review pass 5): "Install / Update Health Connect" sends the + // user to Play and back, and a `remember {}` snapshot kept rendering the unavailable card — + // with LaunchedEffect(availability) never re-firing — until they left the screen and + // returned. Keying on it makes the whole screen recover by itself. + val lifecycleOwner = LocalLifecycleOwner.current + var availability by remember { mutableStateOf(HealthConnectSdk.availability(context)) } + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) availability = HealthConnectSdk.availability(context) + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + val granted = prefs.lastGrantedPermissions.toSet() + var statusMessage by remember { mutableStateOf(null) } + // Any leftover export state is what "Remove PulseLoop data" would actually clear. + val hasExportState = prefs.lastSyncAt != null || + HealthConnectWatermarks.Key.values().any { store.currentWatermarks.get(it) != null } + // Backfill dialog derived from persisted state (review MINOR): a rotation or process death + // while it is up would otherwise destroy a `remember`ed flag WITHOUT running + // onDismissRequest, stranding enabled=true + backfillChoice=NOT_ASKED — the hard-gated + // "Connected but nothing exports" state with no re-offer. Deriving it the way the revocation + // offer does makes recreation re-show it and makes the explicit dismiss redundant. Also gated + // on Health Connect being AVAILABLE (review pass 3): the old imperative flag could only be set + // from the permission-launcher callback, reachable only in the AVAILABLE branch, so a + // device whose provider is uninstalled / needs an update never saw this dialog on top of the + // "needs an update" card. + val showBackfillDialog = availability == HealthConnectAvailability.AVAILABLE && + prefs.enabled && prefs.isConnected && + prefs.backfillChoice == HealthConnectPrefs.BackfillChoice.NOT_ASKED + // Phase 6: full-revocation offer + "remove PulseLoop data" confirmation. + var showRevokeResetDialog by remember { mutableStateOf(false) } + var showRemoveDialog by remember { mutableStateOf(false) } + + // Master toggle → Health Connect permission sheet. Partial grants are first-class: + // any granted permission counts as connected. + val permissionLauncher = rememberLauncherForActivityResult( + PermissionController.createRequestPermissionResultContract() + ) { grantedPermissions -> + val got = HealthConnectPermissionReconcile.storedSetOf(grantedPermissions).toSet() + // Phase 6: diff against the previously stored grant. A grow resets the watermarks of the + // groups the new permissions belong to, so the newly grantable types backfill their history + // (their shared group watermark is already ahead of their rows). A full revoke offers a + // watermark reset below. + val outcome = HealthConnectPermissionReconcile.reconcile( + store.current.lastGrantedPermissions.toSet(), got, store, + ) + store.update { + it.copy( + enabled = got.isNotEmpty(), + lastGrantedPermissions = got.sorted(), + revocationOfferDismissed = if (got.isEmpty()) it.revocationOfferDismissed else false, + ) + } + statusMessage = if (got.isEmpty()) { + "No Health Connect permissions granted — nothing to export." + } else { + "Connected. ${got.size} of ${HealthConnectPermissions.all.size} permission types granted." + } + // First-enable backfill dialog: the derived showBackfillDialog above now turns true from + // the persisted state (enabled + connected + NOT_ASKED) this callback just set, so there + // is nothing to set imperatively. While NOT_ASKED the Phase 1 worker's hard gate keeps the + // export from running, so the enqueue below is a no-op until the choice is made. + // Grant trigger (Phase 1, extended in Phase 6): a grant — including a grow-reset — is the + // moment an export should attempt to run. Debounced 15 s + REPLACE, so cheap on re-grants. + if (got.isNotEmpty()) HealthConnectExportWorker.enqueue(context) + // hadSync guard (mirrors the state-based offer below): only offer a reset when there is + // prior export state to actually reset. A grant-then-immediate-revoke with no export yet + // has empty watermarks + null lastSyncAt, so the offer would be a no-op with misleading + // "re-exports your history" wording. + if (outcome.allRevoked && !store.current.revocationOfferDismissed) { + val cur = store.current + val hadSync = cur.lastSyncAt != null || + HealthConnectWatermarks.Key.values().any { store.currentWatermarks.get(it) != null } + if (hadSync) showRevokeResetDialog = true + } + } + + // Phase 6: on open, diff the live granted set against the stored one to catch out-of-band + // changes (a revocation in system Settings, a grant made elsewhere). A grow resets the affected + // watermarks and enqueues a pass. (onAppStart does the same on activity resume; this is the + // safety net for a change that landed after resume, and the home of the revocation offer.) + LaunchedEffect(availability) { + if (availability != HealthConnectAvailability.AVAILABLE) return@LaunchedEffect + val last = store.current.lastGrantedPermissions.toSet() + val client = runCatching { HealthConnectClient.getOrCreate(context) }.getOrNull() ?: return@LaunchedEffect + val live = runCatching { client.permissionController.getGrantedPermissions() }.getOrNull() ?: return@LaunchedEffect + val granted = HealthConnectPermissionReconcile.storedSetOf(live).toSet() + val outcome = HealthConnectPermissionReconcile.reconcile(last, granted, store) + if (granted != last) store.update { it.copy(lastGrantedPermissions = granted.sorted()) } + if (outcome.grewGroups.isNotEmpty()) { + HealthConnectExportWorker.enqueue(context) + // A re-grant (grow) clears the one-shot revocation-offer flag so a future full + // revocation offers again. + if (store.current.revocationOfferDismissed) store.update { it.copy(revocationOfferDismissed = false) } + } + // Full-revocation offer (Gadgetbridge pattern) — state-based, not diff-based: onAppStart + // may already have reconciled and stored the (now empty) live set before this screen + // opened, making the diff above empty-to-empty so allRevoked never fires. Offer when the + // master is on, nothing is granted now, a prior sync exists (something to reset), and the + // user has not already dismissed the offer. + val cur = store.current + val hadSync = cur.lastSyncAt != null || + HealthConnectWatermarks.Key.values().any { store.currentWatermarks.get(it) != null } + if (cur.enabled && granted.isEmpty() && hadSync && !cur.revocationOfferDismissed) { + showRevokeResetDialog = true + } + } + + SettingsSubScreen(title = "Health Connect", onBack = onBack) { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp)) { + Text("Health Connect", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(4.dp)) + Text( + "Export ring data to Health Connect so other apps and dashboards can show it. Write-only: PulseLoop never reads data back.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + + when (availability) { + HealthConnectAvailability.UNAVAILABLE -> { + Text( + "Health Connect isn't available on this device. Install the Health Connect app to use this feature.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + Spacer(Modifier.height(8.dp)) + Button(onClick = { openHealthConnectPlayStore(context) }, modifier = Modifier.fillMaxWidth()) { + Text("Install Health Connect") + } + } + + HealthConnectAvailability.PROVIDER_UPDATE_REQUIRED -> { + Text( + "The Health Connect app on this device needs an update before it can be used.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + Spacer(Modifier.height(8.dp)) + Button(onClick = { openHealthConnectPlayStore(context) }, modifier = Modifier.fillMaxWidth()) { + Text("Update Health Connect") + } + } + + HealthConnectAvailability.AVAILABLE -> { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("Export PulseLoop data", style = MaterialTheme.typography.bodyLarge) + Text( + if (prefs.isConnected) "Connected" else "Not connected", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = prefs.enabled, + onCheckedChange = { on -> + if (on) { + permissionLauncher.launch(HealthConnectPermissions.all) + } else { + store.update { it.copy(enabled = false) } + } + }, + ) + } + // Always reachable (review pass 5). Two states have no other way out: + // once every permission is revoked the app can no longer delete what it + // wrote (deletion needs WRITE), and after two denials the platform stops + // showing the permission sheet at all, so the master switch silently does + // nothing. Both are resolved in the Health Connect app. + Spacer(Modifier.height(8.dp)) + TextButton( + onClick = { openHealthConnectApp(context) }, + modifier = Modifier.align(Alignment.Start), + ) { Text("Open Health Connect") } + if (!prefs.isConnected) { + Text( + "Manage or re-grant permissions, and review or delete PulseLoop's data, in the Health Connect app.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + + if (availability == HealthConnectAvailability.AVAILABLE && prefs.isConnected) { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp)) { + Text("Data Types", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(4.dp)) + Text( + "Exported only when switched on. A row stays dim until its permission is granted.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + val rows = listOf( + HealthConnectPermissions.DataTypeRow.HEART_RATE to "Heart rate", + HealthConnectPermissions.DataTypeRow.OXYGEN_SATURATION to "Oxygen saturation (SpO₂)", + HealthConnectPermissions.DataTypeRow.HEART_RATE_VARIABILITY to "Heart rate variability (HRV)", + HealthConnectPermissions.DataTypeRow.BODY_TEMPERATURE to "Body temperature", + HealthConnectPermissions.DataTypeRow.SLEEP to "Sleep", + HealthConnectPermissions.DataTypeRow.STEPS_AND_ACTIVITY to "Steps & activity", + HealthConnectPermissions.DataTypeRow.WORKOUTS to "Workouts", + HealthConnectPermissions.DataTypeRow.NUTRITION to "Nutrition", + // Phase 5 (beyond iOS). + HealthConnectPermissions.DataTypeRow.BLOOD_PRESSURE to "Blood pressure", + HealthConnectPermissions.DataTypeRow.BLOOD_GLUCOSE to "Blood glucose", + HealthConnectPermissions.DataTypeRow.RESPIRATORY_RATE to "Respiratory rate", + HealthConnectPermissions.DataTypeRow.VO2_MAX to "VO\u2082 max", + HealthConnectPermissions.DataTypeRow.RESTING_HEART_RATE to "Resting heart rate", + ) + for ((row, label) in rows) { + val rowPerms = HealthConnectPermissions.permissionsForRow(row) + val rowGranted = rowPerms.isEmpty() || rowPerms.any { it in granted } + Row( + Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(label, style = MaterialTheme.typography.bodyMedium) + if (!rowGranted) { + Text( + "Awaiting permission", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Switch( + checked = prefs.toggleFor(row), + enabled = rowGranted, + onCheckedChange = { on -> + store.update { p -> p.withToggleFor(row, on) } + // Re-enabling a VITALS kind must backfill the readings recorded while it + // was off: the VITALS group watermark is driven by the *enabled* kinds + // and advances past a disabled kind's rows, so without this reset the + // off-period data sits below the watermark and never re-selects. This is + // the toggle-side equivalent of the permission grow-reset. The single- + // type groups (sleep/activity/workouts/nutrition/resting HR) freeze their + // watermark while off and backfill on re-enable, so they need no reset. + if (on && HealthConnectPermissionReconcile.groupFor(row) == HealthConnectWatermarks.Key.VITALS) { + store.resetWatermarks(setOf(HealthConnectWatermarks.Key.VITALS)) + HealthConnectExportWorker.enqueue(context) + } + }, + ) + } + if (row == HealthConnectPermissions.DataTypeRow.HEART_RATE_VARIABILITY) { + // Plan §7 open item: the rings' HRV metric is vendor-undocumented, and + // Health Connect has no SDNN type — we export it as RMSSD. + Text( + "HRV is exported as RMSSD — Health Connect has no SDNN type, and the rings' metric is vendor-undocumented.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + + } + + // Shown while there is anything to report or remove — deliberately NOT gated on + // isConnected (review pass 5). Revoking every permission in system Settings used to hide + // this whole section, which is exactly when a user wants to clear what was already + // exported; the card now stays, and says plainly what it can and cannot do without + // permissions. + if (availability == HealthConnectAvailability.AVAILABLE && (prefs.isConnected || hasExportState)) { + Card( + Modifier.fillMaxWidth(), + // containerColor alone leaves contentColor Unspecified, so the Text falls back to + // LocalContentColor (dark) and renders near-invisible on this dark card. + colors = CardDefaults.cardColors( + containerColor = PulseColors.cardSoft, + contentColor = PulseColors.textPrimary, + ), + ) { + val syncedAt = prefs.lastSyncAt + Text( + if (syncedAt != null) { + // The label promises a time, so show one (review pass 5): the summary + // alone read as "Last sync: skipped: …" with no indication of when. + "Last sync ${DeviceHeroStatus.relativeShort(syncedAt, System.currentTimeMillis())} — " + + (prefs.lastSyncSummary ?: "unknown") + } else { + "No export has run yet. With the master switch on and a backfill choice made, PulseLoop exports automatically." + }, + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyMedium, + ) + } + + // Phase 6: "Remove PulseLoop data from Health Connect" (iOS removeAllExportedData parity). + // Deletes only the records this app wrote, then clears every watermark + the last-sync + // stamp so the export state matches an empty Health store. + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp)) { + Text("Remove exported data", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(4.dp)) + Text( + "Delete every record PulseLoop wrote to Health Connect and reset the export to start fresh. Your ring data in PulseLoop is untouched.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (!prefs.isConnected) { + Spacer(Modifier.height(4.dp)) + Text( + "Deleting needs write permission, and PulseLoop has none right now — re-grant above, or delete the data in the Health Connect app.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + Spacer(Modifier.height(8.dp)) + val removing = prefs.removalStatus == HealthConnectPrefs.REMOVAL_IN_PROGRESS + Button( + onClick = { showRemoveDialog = true }, + enabled = !removing, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + modifier = Modifier.fillMaxWidth(), + ) { Text(if (removing) "Removing…" else "Remove PulseLoop data") } + } + } + } + + // Removal runs in a worker, so its outcome arrives here through the store rather than + // through this screen's own state — it survives navigating away and process death. + prefs.removalStatus?.takeIf { it != HealthConnectPrefs.REMOVAL_IN_PROGRESS }?.let { msg -> + Card( + Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = PulseColors.cardSoft, + contentColor = PulseColors.textPrimary, + ), + ) { + Row( + Modifier.padding(16.dp).fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(msg, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) + TextButton(onClick = { store.update { it.copy(removalStatus = null) } }) { Text("Dismiss") } + } + } + } + + statusMessage?.let { msg -> + Card( + Modifier.fillMaxWidth(), + // containerColor alone leaves contentColor Unspecified, so the Text falls back to + // LocalContentColor (dark) and renders near-invisible on this dark card. + colors = CardDefaults.cardColors( + containerColor = PulseColors.cardSoft, + contentColor = PulseColors.textPrimary, + ), + ) { Text(msg, modifier = Modifier.padding(16.dp), style = MaterialTheme.typography.bodyMedium) } } } + + if (showBackfillDialog) { + AlertDialog( + // Dismissing (back / outside tap) is a Cancel: iOS's equivalent sets masterEnabled = + // false. Turning the master off flips the derived showBackfillDialog above back to + // false (it no longer holds enabled + connected + NOT_ASKED), which also keeps the + // on-screen state honest — re-enabling re-offers the dialog. + onDismissRequest = { store.update { it.copy(enabled = false) } }, + title = { Text("How much history should we export?") }, + // No "change this later" promise: there is no UI to change backfillChoice short of + // "Remove PulseLoop data", which resets it to NOT_ASKED and re-offers this dialog. + text = { Text("Exporting all history can take a while on the first run.") }, + confirmButton = { + TextButton(onClick = { + store.update { it.copy(backfillChoice = HealthConnectPrefs.BackfillChoice.EXPORT_ALL) } + HealthConnectExportWorker.enqueue(context) // answer the gate → run the pass + }) { Text("Sync all history") } + }, + dismissButton = { + TextButton(onClick = { + store.update { it.copy(backfillChoice = HealthConnectPrefs.BackfillChoice.EXPORT_NEW_ONLY) } + HealthConnectExportWorker.enqueue(context) // stamps watermarks, exports nothing + }) { Text("Only new data from now on") } + }, + ) + } + + // Phase 6: full-revocation offer (Gadgetbridge HealthConnectResetDialogFragment pattern). A + // later re-grant is a "grow" from empty, so the automatic grow-reset re-exports regardless; + // this lets the user reset immediately and clears the last-sync stamp too. + if (showRevokeResetDialog) { + AlertDialog( + onDismissRequest = { showRevokeResetDialog = false; store.update { it.copy(revocationOfferDismissed = true) } }, + title = { Text("Health Connect permissions revoked") }, + text = { Text("PulseLoop no longer has any Health Connect permissions. Reset the export so a later re-grant re-exports your history?") }, + confirmButton = { + TextButton(onClick = { + store.clearWatermarks() + store.update { it.copy(lastSyncAt = null, lastSyncSummary = null, revocationOfferDismissed = true) } + showRevokeResetDialog = false + statusMessage = "Export reset — re-grant permissions to sync again." + }) { Text("Reset export") } + }, + dismissButton = { + TextButton(onClick = { showRevokeResetDialog = false; store.update { it.copy(revocationOfferDismissed = true) } }) { Text("Not now") } + }, + ) + } + + // Phase 6: "Remove PulseLoop data" confirmation. Deletion is per record type over the full + // time range (only types still granted), then all watermarks + the last-sync stamp clear. + if (showRemoveDialog) { + AlertDialog( + onDismissRequest = { showRemoveDialog = false }, + title = { Text("Remove PulseLoop data?") }, + text = { Text("This deletes every record PulseLoop wrote to Health Connect and resets the export. It cannot be undone, and your ring data in PulseLoop is not affected.") }, + confirmButton = { + TextButton(onClick = { + showRemoveDialog = false + // Enqueued, not launched in this screen's scope (review pass 5): a back-press + // mid-delete used to cancel the removal between record types, leaving some + // deleted, the rest alive, and the watermarks never cleared — unrepairable + // from a write-only client. The worker reports back via prefs.removalStatus. + HealthConnectRemovalWorker.enqueue(context) + }) { Text("Remove") } + }, + dismissButton = { + TextButton(onClick = { showRemoveDialog = false }) { Text("Cancel") } + }, + ) + } +} + +/** + * Opens PulseLoop's own page inside the Health Connect app — permissions plus Health Connect's + * own "delete app data". This is the way out of the two states PulseLoop cannot fix itself: after + * the platform stops showing the permission sheet (two denials) the master switch silently does + * nothing, and once every permission is revoked the app can no longer delete what it wrote + * (deletion needs WRITE). The client builds the right intent for both the API 34+ platform module + * and the pre-34 APK; if it cannot be resolved at all, fall back to the store listing. + */ +private fun openHealthConnectApp(context: Context) { + try { + context.startActivity(HealthConnectClient.getHealthConnectManageDataIntent(context)) + } catch (_: Exception) { + openHealthConnectPlayStore(context) + } } + +private fun openHealthConnectPlayStore(context: Context) { + // API < 34: Health Connect is a separate APK from Play (plan §2, caveat 1). + val market = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=com.google.android.apps.healthdata")) + val web = Intent(Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/details?id=com.google.android.apps.healthdata")) + try { + context.startActivity(market) + } catch (_: Exception) { + // Devices without Play (e.g. F-Droid) at least get the web listing. + runCatching { context.startActivity(web) } + } +} + diff --git a/app/src/main/java/com/pulseloop/ui/screens/WorkoutSummaryScreen.kt b/app/src/main/java/com/pulseloop/ui/screens/WorkoutSummaryScreen.kt index 9077caa..93b0e6a 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/WorkoutSummaryScreen.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/WorkoutSummaryScreen.kt @@ -153,6 +153,9 @@ fun WorkoutSummaryScreen( scope.launch { com.pulseloop.service.ActivityRollup.reverse(db, s) db.activitySessionDao().upsert(s.copy(statusRaw = "deleted", updatedAt = System.currentTimeMillis())) + // Health Connect: remove this workout's exported records (best-effort, + // never fails the local delete — plan Phase 4 deletion hook, iOS parity). + com.pulseloop.health.HealthConnectWorkoutDeletion.removeSessionRecords(context, s.id) onBack() } }) { Text("Delete", color = PulseColors.danger) } diff --git a/app/src/test/java/com/pulseloop/health/HealthConnectActivityMappingTest.kt b/app/src/test/java/com/pulseloop/health/HealthConnectActivityMappingTest.kt new file mode 100644 index 0000000..55167b9 --- /dev/null +++ b/app/src/test/java/com/pulseloop/health/HealthConnectActivityMappingTest.kt @@ -0,0 +1,273 @@ +package com.pulseloop.health + +import com.pulseloop.health.HealthConnectTypeMappings.ACT_DIST +import com.pulseloop.health.HealthConnectTypeMappings.ACT_ENERGY +import com.pulseloop.health.HealthConnectTypeMappings.ACT_STEPS +import com.pulseloop.health.HealthConnectTypeMappings.NettableSession +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate +import java.time.ZoneId +import java.time.ZoneOffset + +/** + * Phase 3 (docs/health-connect-integration.md): the daily-activity identity scheme, the day-span + * clamp, and the workout-netting port of iOS `HealthSyncService.workoutNetting` + * (`HealthSyncService.swift:315-331`). All pure — no database, no HealthConnectClient. + */ +class HealthConnectActivityMappingTest { + + private val utc = ZoneOffset.UTC + private val la = ZoneId.of("America/Los_Angeles") + private val hour = 3_600_000L + private val day = 24 * hour + + private fun dayStart(date: String, zone: ZoneId): Long = + LocalDate.parse(date).atStartOfDay(zone).toInstant().toEpochMilli() + + // ── id builder ── + + @Test + fun activityRecordIdUsesTheIosMetricTokens() { + assertEquals("pl-act-steps-1786896000000", HealthConnectTypeMappings.activityRecordId(ACT_STEPS, 1786896000000L)) + assertEquals("pl-act-energy-1786896000000", HealthConnectTypeMappings.activityRecordId(ACT_ENERGY, 1786896000000L)) + assertEquals("pl-act-dist-1786896000000", HealthConnectTypeMappings.activityRecordId(ACT_DIST, 1786896000000L)) + } + + @Test + fun activityRecordIdIsStableAcrossRebuilds() { + // The whole upsert story: the same day must produce byte-identical ids on every pass. + val a = HealthConnectTypeMappings.activityRecordId(ACT_STEPS, 1786896000000L) + val b = HealthConnectTypeMappings.activityRecordId(ACT_STEPS, 1786896000000L) + assertEquals(a, b) + } + + // ── day span clamp ── + + @Test + fun dayEndIsLastMillisecondOfAPastDay() { + val start = dayStart("2026-08-10", utc) + val now = dayStart("2026-08-16", utc) + assertEquals(start + day - 1L, HealthConnectTypeMappings.activityDayEndMs(start, now, utc)) + } + + @Test + fun dayEndClampsTodayToNowSoItNeverEndsInTheFuture() { + val start = dayStart("2026-08-16", utc) + val now = start + 9 * hour + assertEquals(now, HealthConnectTypeMappings.activityDayEndMs(start, now, utc)) + } + + @Test + fun dayEndIsNullWhenTheDayHasNotStarted() { + val start = dayStart("2026-08-17", utc) + val now = dayStart("2026-08-16", utc) + 9 * hour + assertNull(HealthConnectTypeMappings.activityDayEndMs(start, now, utc)) + } + + @Test + fun dayEndIsNullAtExactlyMidnight() { + // start == end would be rejected by every IntervalRecord constructor. + val start = dayStart("2026-08-16", utc) + assertNull(HealthConnectTypeMappings.activityDayEndMs(start, start, utc)) + } + + @Test + fun dayEndFollowsTheCalendarAcrossDstNotAFixed24Hours() { + // 2026-03-08 is the US spring-forward day: 23 hours long in America/Los_Angeles. A + // `+ 86_400_000` implementation would spill an hour into the next day. + val start = dayStart("2026-03-08", la) + val now = dayStart("2026-08-16", la) + assertEquals(start + 23 * hour - 1L, HealthConnectTypeMappings.activityDayEndMs(start, now, la)) + } + + @Test + fun dayEndFollowsTheCalendarOnTheFallBackDay() { + // 2026-11-01 is 25 hours long in America/Los_Angeles. + val start = dayStart("2026-11-01", la) + val now = dayStart("2026-12-01", la) + assertEquals(start + 25 * hour - 1L, HealthConnectTypeMappings.activityDayEndMs(start, now, la)) + } + + // ── plausibility guards (the platform rejects the insert otherwise) ── + + @Test + fun stepsGuardStopsAtTheAppsOwnCorruptionThreshold() { + assertFalse(HealthConnectTypeMappings.isPlausibleSteps(0L)) + assertFalse(HealthConnectTypeMappings.isPlausibleSteps(-5L)) + assertTrue(HealthConnectTypeMappings.isPlausibleSteps(1L)) + assertTrue(HealthConnectTypeMappings.isPlausibleSteps(200_000L)) + // EventPersistenceSubscriber self-heals a day above 200_000 as garbage — a write-only + // export must not publish one before that heal runs, even though the platform would + // accept it up to 1_000_000. + assertFalse(HealthConnectTypeMappings.isPlausibleSteps(200_001L)) + assertFalse(HealthConnectTypeMappings.isPlausibleSteps(900_000L)) + } + + @Test + fun energyAndDistanceGuardsRejectZeroAndNegative() { + assertFalse(HealthConnectTypeMappings.isPlausibleActiveCalories(0.0)) + assertFalse(HealthConnectTypeMappings.isPlausibleActiveCalories(-0.1)) + assertTrue(HealthConnectTypeMappings.isPlausibleActiveCalories(412.5)) + assertFalse(HealthConnectTypeMappings.isPlausibleDistanceMeters(0.0)) + assertFalse(HealthConnectTypeMappings.isPlausibleDistanceMeters(-1.0)) + assertTrue(HealthConnectTypeMappings.isPlausibleDistanceMeters(6_400.0)) + } + + // ── workout netting (iOS HealthSyncService.swift:315-331) ── + + /** A 10-minute session starting at the top of its day — comfortably credit-eligible, so + * the existing netting assertions test the sums, not the eligibility check. */ + private fun session(dayMs: Long, kcal: Double?, meters: Double?, gps: Boolean = true) = + NettableSession( + dayStartMs = dayMs, + calories = kcal, + distanceMeters = meters, + useGps = gps, + startedAtMs = dayMs + hour, + endedAtMs = dayMs + hour + 10 * 60_000L, + totalPauseSeconds = 0.0, + ) + + @Test + fun nettingSkipsSessionsActivityRollupNeverCredited() { + // ActivityRollup.credit early-returns when the session has no full active minute — its + // energy and metres never reach the daily row, so netting must not subtract them + // (Phase 3 imperfection #1, resolved in Phase 4). + val d = dayStart("2026-08-16", utc) + val out = HealthConnectTypeMappings.workoutNetting( + listOf( + // 59.9 s: credit() skips it → netting skips it too. + session2(d, 100.0, 3_000.0, started = d + hour, ended = d + hour + 59_900L, pause = 0.0), + // Exactly 60 s: credit() folds it in → netting subtracts it. + session2(d, 100.0, 3_000.0, started = d + hour, ended = d + hour + 60_000L, pause = 0.0), + // 90 s wall clock but 61 s of it paused: no full ACTIVE minute → skipped. + session2(d, 100.0, 3_000.0, started = d + hour, ended = d + hour + 90_000L, pause = 61.0), + // 2 minutes, 55 s paused: 65 s of active time → one full minute → credited. + session2(d, 100.0, 3_000.0, started = d + hour, ended = d + hour + 120_000L, pause = 55.0), + ), + ) + assertEquals(200.0, out.kcal(d), 0.0001) + assertEquals(6_000.0, out.meters(d), 0.0001) + } + + private fun session2( + dayMs: Long, + kcal: Double?, + meters: Double?, + started: Long, + ended: Long?, + pause: Double, + gps: Boolean = true, + ) = NettableSession(dayMs, kcal, meters, gps, started, ended, pause) + + @Test + fun creditedActiveMinutesPortsActivityRollupMinutesFor() { + val s = 1_700_000_000_000L + assertEquals(1, HealthConnectTypeMappings.creditedActiveMinutes(s, s + 60_000L, 0.0)) + assertEquals(0, HealthConnectTypeMappings.creditedActiveMinutes(s, s + 59_999L, 0.0)) + assertEquals(1, HealthConnectTypeMappings.creditedActiveMinutes(s, s + 120_000L, 60.0)) + assertEquals(0, HealthConnectTypeMappings.creditedActiveMinutes(s, s + 120_000L, 61.0)) + assertEquals(1, HealthConnectTypeMappings.creditedActiveMinutes(s, s + 120_000L, 59.9)) + assertEquals(0, HealthConnectTypeMappings.creditedActiveMinutes(s, null, 0.0)) + assertEquals(0, HealthConnectTypeMappings.creditedActiveMinutes(s, s - 5_000L, 0.0)) // negative clamps + } + + @Test + fun nettingSumsEnergyForEveryFinishedSessionOfTheDay() { + val d = dayStart("2026-08-16", utc) + val out = HealthConnectTypeMappings.workoutNetting( + listOf(session(d, 120.0, 1_000.0), session(d, 80.0, 500.0)), + ) + assertEquals(200.0, out.kcal(d), 0.0001) + } + + @Test + fun nettingCountsDistanceOnlyForGpsSessions() { + // ActivityRollup.credit folds a session's distance into the daily row only when useGps — + // netting must subtract exactly that set, no more. + val d = dayStart("2026-08-16", utc) + val out = HealthConnectTypeMappings.workoutNetting( + listOf(session(d, 100.0, 3_000.0, gps = true), session(d, 50.0, 2_000.0, gps = false)), + ) + assertEquals(3_000.0, out.meters(d), 0.0001) + // …but a non-GPS session's energy still nets: the ring's all-day figure covered it. + assertEquals(150.0, out.kcal(d), 0.0001) + } + + @Test + fun nettingKeepsDaysSeparate() { + val d1 = dayStart("2026-08-15", utc) + val d2 = dayStart("2026-08-16", utc) + val out = HealthConnectTypeMappings.workoutNetting( + listOf(session(d1, 100.0, 1_000.0), session(d2, 250.0, 4_000.0)), + ) + assertEquals(100.0, out.kcal(d1), 0.0001) + assertEquals(250.0, out.kcal(d2), 0.0001) + assertEquals(1_000.0, out.meters(d1), 0.0001) + assertEquals(4_000.0, out.meters(d2), 0.0001) + } + + @Test + fun nettingIgnoresNullAndNonPositiveValues() { + val d = dayStart("2026-08-16", utc) + val out = HealthConnectTypeMappings.workoutNetting( + listOf(session(d, null, null), session(d, 0.0, 0.0), session(d, -10.0, -10.0)), + ) + assertEquals(0.0, out.kcal(d), 0.0001) + assertEquals(0.0, out.meters(d), 0.0001) + } + + @Test + fun nettingReturnsZeroForADayWithNoWorkouts() { + val d = dayStart("2026-08-16", utc) + assertEquals(0.0, HealthConnectTypeMappings.WorkoutNetting.EMPTY.kcal(d), 0.0001) + assertEquals(0.0, HealthConnectTypeMappings.WorkoutNetting.EMPTY.meters(d), 0.0001) + val out = HealthConnectTypeMappings.workoutNetting(emptyList()) + assertEquals(0.0, out.kcal(d), 0.0001) + assertEquals(0.0, out.meters(d), 0.0001) + } + + // ── leftover subtraction (the part that can silently under-report) ── + + @Test + fun leftoverSubtractsTheWorkoutTotal() { + assertEquals(340.0, HealthConnectTypeMappings.activityLeftover(520.0, 180.0), 0.0001) + assertEquals(4_400.0, HealthConnectTypeMappings.activityLeftover(6_400.0, 2_000.0), 0.0001) + } + + @Test + fun leftoverIsUnchangedWhenNothingIsNetted() { + assertEquals(520.0, HealthConnectTypeMappings.activityLeftover(520.0, 0.0), 0.0001) + } + + @Test + fun leftoverGoesNegativeAndTheGuardDropsIt() { + // A workout claiming more energy than the ring's day total must not produce a record — + // the platform floor is 0 and a negative Energy is meaningless. + val leftover = HealthConnectTypeMappings.activityLeftover(400.0, 600.0) + assertEquals(-200.0, leftover, 0.0001) + assertFalse(HealthConnectTypeMappings.isPlausibleActiveCalories(leftover)) + } + + @Test + fun leftoverOfExactlyZeroIsDropped() { + val leftover = HealthConnectTypeMappings.activityLeftover(180.0, 180.0) + assertEquals(0.0, leftover, 0.0001) + assertFalse(HealthConnectTypeMappings.isPlausibleActiveCalories(leftover)) + assertFalse(HealthConnectTypeMappings.isPlausibleDistanceMeters(leftover)) + } + + @Test + fun nettingKeysOnTheDayTheSessionStarted() { + // A workout that crosses midnight nets entirely against the day it began (iOS keys on + // startedAt), so the caller's dayStartMs is authoritative here. + val d1 = dayStart("2026-08-15", utc) + val out = HealthConnectTypeMappings.workoutNetting(listOf(session(d1, 300.0, 5_000.0))) + assertEquals(300.0, out.kcal(d1), 0.0001) + assertEquals(0.0, out.kcal(dayStart("2026-08-16", utc)), 0.0001) + } +} diff --git a/app/src/test/java/com/pulseloop/health/HealthConnectExporterTest.kt b/app/src/test/java/com/pulseloop/health/HealthConnectExporterTest.kt new file mode 100644 index 0000000..3989a34 --- /dev/null +++ b/app/src/test/java/com/pulseloop/health/HealthConnectExporterTest.kt @@ -0,0 +1,413 @@ +package com.pulseloop.health + +import androidx.health.connect.client.records.ExerciseRoute +import androidx.health.connect.client.records.ExerciseRouteResult +import androidx.health.connect.client.records.ExerciseSessionRecord +import androidx.health.connect.client.records.Record +import androidx.health.connect.client.records.metadata.Device +import androidx.health.connect.client.records.metadata.Metadata +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +/** + * Phase 1 (docs/health-connect-integration.md): the chunking/retry engine, tested through the + * injected insert function — no HealthConnectClient, no database (the repo's no-mock convention). + * Pins Gadgetbridge's production constants: 200 records per call, 5 retries, backoff + * 1 / 2 / 4 / 8 / 16 s, SecurityException aborts on the first attempt. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class HealthConnectExporterTest { + + private class FakeRecord : Record { + override val metadata: Metadata = Metadata.autoRecorded( + Device(type = Device.TYPE_UNKNOWN, manufacturer = "test", model = "test"), + ) + } + + private fun records(n: Int): List = List(n) { FakeRecord() } + + private fun highWaters(n: Int): List = (1L..n.toLong()).toList() + + @Test + fun emptyInputIsTriviallyComplete() = runTest { + val p = healthConnectInsertChunked(emptyList(), emptyList()) { fail("no records, no insert") } + assertTrue(p.allCompleted) + assertEquals(0, p.attempts) + assertEquals(0, p.inserted) + assertEquals(0L, p.lastCompletedHighWater) + } + + @Test + fun chunksAtTwoHundred() = runTest { + val sizes = mutableListOf() + val p = healthConnectInsertChunked(records(450), highWaters(450)) { chunk -> + sizes += chunk.size + } + assertEquals(listOf(200, 200, 50), sizes) + assertTrue(p.allCompleted) + assertEquals(450, p.inserted) + assertEquals(3, p.attempts) + assertEquals(450L, p.lastCompletedHighWater) + } + + @Test + fun exactChunkBoundaryNeedsNoEmptyTailChunk() = runTest { + val sizes = mutableListOf() + val p = healthConnectInsertChunked(records(400), highWaters(400)) { chunk -> + sizes += chunk.size + } + assertEquals(listOf(200, 200), sizes) + assertTrue(p.allCompleted) + } + + @Test + fun transientFailuresAreRetriedWithBackoff() = runTest { + var failures = 0 + val p = healthConnectInsertChunked(records(10), highWaters(10)) { chunk -> + if (chunk.size == 10 && failures < 2) { + failures++ + throw IllegalStateException("flaky") + } + } + assertEquals(2, failures) + assertTrue(p.allCompleted) + assertEquals(3, p.attempts) // initial + 2 retries + assertEquals(10, p.inserted) + // virtual time advanced by the 1 s + 2 s backoff delays + assertEquals(3_000L, testScheduler.currentTime) + } + + @Test + fun securityExceptionAbortsImmediatelyWithoutRetry() = runTest { + var attempts = 0 + try { + healthConnectInsertChunked(records(10), highWaters(10)) { chunk -> + attempts += chunk.size + throw SecurityException("permission revoked") + } + fail("expected SecurityException") + } catch (e: SecurityException) { + assertEquals("permission revoked", e.message) + } + assertEquals(10, attempts) // one attempt only — never retried + assertEquals(0L, testScheduler.currentTime) // no backoff delay + } + + @Test + fun retriesAreExhaustedThenReportedNotThrown() = runTest { + val p = healthConnectInsertChunked(records(5), highWaters(5)) { + throw IllegalStateException("down") + } + assertFalse(p.allCompleted) + assertEquals(0, p.inserted) + assertEquals(0L, p.lastCompletedHighWater) + // 1 initial + 5 retries + assertEquals(6, p.attempts) + assertNotNull(p.lastError) + assertEquals("down", p.lastError?.message) + // 1+2+4+8+16 = 31 s of virtual backoff + assertEquals(31_000L, testScheduler.currentTime) + } + + @Test + fun watermarkStopsAtLastSuccessfulChunk() = runTest { + var chunk = 0 + val p = healthConnectInsertChunked(records(450), highWaters(450)) { c -> + chunk += c.size + if (chunk > 200) throw IllegalStateException("second chunk dies") + } + assertFalse(p.allCompleted) + assertEquals(200, p.inserted) + assertEquals(200L, p.lastCompletedHighWater) // highWaters(450) is 1..450 → first 200 max 200 + } + + @Test + fun mismatchedHighWatersIsRejected() = runTest { + try { + healthConnectInsertChunked(records(3), highWaters(2)) { } + fail("expected IllegalArgumentException") + } catch (e: IllegalArgumentException) { + // parallel-list contract + } + } + + // ── sleep (Phase 2): the sleep group's watermark is the max session updatedAt of what + // actually landed. Sleep is a single-kind group, so its group-min rule (min of per-kind + // highs) reduces to this value; independence from the vitals group is pinned in + // HealthConnectPrefsStoreTest (watermarksAreIndependentPerKey / sleepWatermarkNeverRewinds). + // The full pass (run()) is runtime-verified — same convention as Phase 1. + + @Test + fun sleepWatermarkAdvancesOnlyToLandedSessions() = runTest { + // Three pending sessions with NON-MONOTONIC updatedAt (a re-synced old night alongside + // newer ones). The watermark must land at the MAX of what was inserted — not the last + // record's, not a source-row ordering artifact. + val highWaters = listOf(700L, 900L, 500L) + val p = healthConnectInsertChunked(records(3), highWaters) { } + assertTrue(p.allCompleted) + assertEquals(3, p.inserted) + assertEquals(900L, p.lastCompletedHighWater) + } + + @Test + fun sleepWatermarkStopsAtMaxLandedChunkOnPartialFailure() = runTest { + // 450 records = three 200/200/50 chunks; chunks 2–3 die. The watermark may only advance + // over chunk 1's sessions — here the max of their updatedAt is 499, not 299 (the last + // landed in list order) and nothing past it — so the next pass re-reads exactly what + // was missed and re-upserts nothing that already landed. + val highWaters = (1..450).map { if (it <= 200) 500L - it else 1000L - it } + var chunk = 0 + val p = healthConnectInsertChunked(records(450), highWaters) { c -> + chunk += c.size + if (chunk > 200) throw IllegalStateException("provider down") + } + assertFalse(p.allCompleted) + assertEquals(200, p.inserted) + assertEquals(499L, p.lastCompletedHighWater) + } + + // ── Phase 3: one source row can emit several records sharing one high water ── + + @Test + fun activityWatermarkNeverStrandsASiblingRecordSplitAcrossAChunkBoundary() = runTest { + // A day writes steps + energy + distance, all stamped with the row's updatedAt. Build 201 + // records so the chunk boundary falls INSIDE the 67th day, then fail chunk 2. Advancing to + // that day's updatedAt would strand its unlanded record forever, because the DAO selects + // on `updatedAt > watermark`. + val highWaters = (0 until 201).map { (it / 3).toLong() + 1L } + val p = healthConnectInsertChunked(records(201), highWaters) { c -> + if (c.size < 200) throw IllegalStateException("second chunk fails") + } + assertFalse(p.allCompleted) + assertEquals(200, p.inserted) + // Day 67 (high water 67) straddles the boundary: 200 = 3*66 + 2, so two of its three + // records landed and one did not. The watermark must stop at day 66. + assertEquals(66L, p.lastCompletedHighWater) + } + + @Test + fun aFullyLandedRowStillAdvancesTheWatermark() = runTest { + // Same shape, but the boundary falls cleanly between rows — nothing is stranded, so the + // clamp must not cost us the last complete row. + val highWaters = (0 until 300).map { (it / 2).toLong() + 1L } + val p = healthConnectInsertChunked(records(300), highWaters) { c -> + if (c.size < 200) throw IllegalStateException("second chunk fails") + } + assertFalse(p.allCompleted) + assertEquals(100L, p.lastCompletedHighWater) + } + + // ── Phase 4: netting is live, gated on the workouts toggle + WRITE_EXERCISE ── + + private fun prefs(workouts: Boolean) = HealthConnectPrefs(workouts = workouts) + + @Test + fun nettingIsLiveTogetherWithTheWorkoutExporter() { + // Phase 4 flipped WORKOUTS_EXPORTED in the same commit that added WorkoutExporter (plan + // "Inherited from Phase 3"): netting and the compensating workout records turned on + // together, so no day is ever netted against records nothing writes. + assertTrue(HealthConnectExporter.WORKOUTS_EXPORTED) + assertTrue( + HealthConnectExporter.shouldNetWorkouts(prefs(workouts = true), HealthConnectPermissions.all), + ) + } + + @Test + fun nettingStillRequiresBothTheToggleAndTheExercisePermission() { + // The toggle alone is not enough: it can be on while WRITE_EXERCISE is denied (partial + // grants are first-class), and then no workout record is written to net against. + val granted = HealthConnectPermissions.all + val withoutExercise = granted - HealthConnectPermissions.exercise.first() + assertFalse(HealthConnectExporter.shouldNetWorkouts(prefs(workouts = false), granted)) + assertFalse(HealthConnectExporter.shouldNetWorkouts(prefs(workouts = true), withoutExercise)) + } + + // ── Phase 4: the 1 MB single-record route fallback ── + + /** [n] points at 60 ms spacing — fits inside the 60 s test record span. The + * `ExerciseSessionRecord` constructor rejects a route whose points leave the parent's + * [startTime, endTime], which is exactly what the exporter's sanitisation guarantees. */ + private fun routePoints(n: Int) = List(n) { + ExerciseRoute.Location( + time = java.time.Instant.ofEpochMilli(1_700_000_000_000L + it * 60L), + latitude = 37.0 + it * 0.0001, + longitude = -122.0 + it * 0.0001, + ) + } + + private fun exerciseRecord(points: List) = ExerciseSessionRecord( + startTime = java.time.Instant.ofEpochMilli(1_700_000_000_000L), + startZoneOffset = java.time.ZoneOffset.UTC, + endTime = java.time.Instant.ofEpochMilli(1_700_000_060_000L), + endZoneOffset = java.time.ZoneOffset.UTC, + metadata = Metadata.autoRecorded( + Device(type = Device.TYPE_PHONE, manufacturer = "test", model = "test"), + "pl-wk-test", 1L, + ), + exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_WALKING, + title = "Test walk", + exerciseRoute = ExerciseRoute(points), + ) + + private val oversizeError = RuntimeException("single record size limit: 1000000, was: 2000000") + + @Test + fun shrinkOversizedRouteDecimatesOnlyTheOffender() { + val small = FakeRecord() + val big = exerciseRecord(routePoints(1000)) + val out = shrinkOversizedRoute(listOf(small, big, small), oversizeError) + assertNotNull("should shrink", out) + val shrunk = out!! + assertEquals(3, shrunk.size) + assertTrue(shrunk[0] === small) // untouched records pass through by identity + assertTrue(shrunk[2] === small) + val shrunkRoute = (shrunk[1] as ExerciseSessionRecord).exerciseRouteResult + val points = (shrunkRoute as ExerciseRouteResult.Data).exerciseRoute.route + // 1000 * (1_000_000 / 2_000_000) * 0.9 = 450 + assertEquals(450, points.size) + val original = big.exerciseRouteResult as ExerciseRouteResult.Data + assertEquals(original.exerciseRoute.route.first(), points.first()) + assertEquals(original.exerciseRoute.route.last(), points.last()) + // first/last preserved, strictly increasing timestamps (HC rejects duplicates) + assertTrue(points.zipWithNext { a, b -> a.time.isBefore(b.time) }.all { it }) + } + + @Test + fun shrinkOversizedRouteIgnoresUnrelatedErrors() { + assertNull(shrinkOversizedRoute(listOf(exerciseRecord(routePoints(1000))), RuntimeException("something else"))) + } + + @Test + fun shrinkOversizedRouteIgnoresChunksWithoutShrinkableRoutes() { + val noRoute = ExerciseSessionRecord( + startTime = java.time.Instant.ofEpochMilli(1_700_000_000_000L), + startZoneOffset = java.time.ZoneOffset.UTC, + endTime = java.time.Instant.ofEpochMilli(1_700_000_060_000L), + endZoneOffset = java.time.ZoneOffset.UTC, + metadata = Metadata.autoRecorded( + Device(type = Device.TYPE_PHONE, manufacturer = "test", model = "test"), + "pl-wk-test", 1L, + ), + exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_WALKING, + ) + assertNull(shrinkOversizedRoute(listOf(noRoute, FakeRecord()), oversizeError)) + // A route at the 2-point floor "shrinks" to itself — not counted, so the caller falls + // back to its normal retry instead of spinning. + assertNull(shrinkOversizedRoute(listOf(exerciseRecord(routePoints(2))), oversizeError)) + } + + @Test + fun chunkInsertShrinksOnceThenSucceeds() = runTest { + val calls = mutableListOf>() + insertChunkWithRouteShrink(listOf(exerciseRecord(routePoints(1000)))) { chunk -> + calls += chunk + if (calls.size == 1) throw oversizeError + } + assertEquals(2, calls.size) + val second = (calls[1][0] as ExerciseSessionRecord).exerciseRouteResult as ExerciseRouteResult.Data + assertEquals(450, second.exerciseRoute.route.size) + } + + @Test + fun chunkInsertRethrowsWhenNothingCanShrink() = runTest { + val noRoute = ExerciseSessionRecord( + startTime = java.time.Instant.ofEpochMilli(1_700_000_000_000L), + startZoneOffset = java.time.ZoneOffset.UTC, + endTime = java.time.Instant.ofEpochMilli(1_700_000_060_000L), + endZoneOffset = java.time.ZoneOffset.UTC, + metadata = Metadata.autoRecorded( + Device(type = Device.TYPE_PHONE, manufacturer = "test", model = "test"), + "pl-wk-test", 1L, + ), + exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_WALKING, + ) + var attempts = 0 + try { + insertChunkWithRouteShrink(listOf(noRoute)) { chunk -> + attempts++ + throw oversizeError + } + fail("should rethrow") + } catch (e: RuntimeException) { + assertEquals("single record size limit: 1000000, was: 2000000", e.message) + } + assertEquals(1, attempts) // no point burning retries on a deterministic failure + } + + // ── Phase 4 observer stage B: the invalidHighWater leapfrog blocker ── + + @Test + fun invalidHighWaterAppliesOnlyOnACompletedPass() { + // Completed pass: every valid session landed, so the watermark may jump past the + // record-less invalid rows (they can never become exportable). + assertEquals(200L, watermarkAdvance(true, 150L, 200L)) + assertEquals(300L, watermarkAdvance(true, 300L, 200L)) + assertEquals(150L, watermarkAdvance(true, 150L, null)) + assertEquals(0L, watermarkAdvance(true, 0L, null)) + } + + @Test + fun invalidHighWaterNeverLeapfrogsUnlandedSessionsOnAPartialFailure() { + // The blocker: chunk failure after record-less INVALID rows must NOT advance the + // watermark past valid sessions whose records never landed — they stay pending. + assertEquals(150L, watermarkAdvance(false, 150L, 200L)) + assertEquals(0L, watermarkAdvance(false, 0L, 200L)) + // ...and when the invalid rows sit below what landed, nothing changes either way. + assertEquals(150L, watermarkAdvance(false, 150L, 100L)) + assertEquals(150L, watermarkAdvance(true, 150L, 100L)) + } + + @Test + fun chunkInsertAbortsOnSecurityExceptionWithoutShrinking() = runTest { + var attempts = 0 + try { + insertChunkWithRouteShrink(listOf(exerciseRecord(routePoints(1000)))) { _ -> + attempts++ + throw SecurityException("not granted") + } + fail("should rethrow") + } catch (e: SecurityException) { + // permission failures are never retried or shrunk + } + assertEquals(1, attempts) + } + + // ── Review pass 3: the read-site consent clamp (effectiveWatermark) ── + + @Test + fun effectiveWatermarkIsNoOpWithoutConsent() { + // EXPORT_ALL / NOT_ASKED: newOnlyConsentAt is null, so the clamp is a no-op — a null + // stored watermark still means export-from-epoch (the pre-NEW_ONLY behaviour). + assertEquals(0L, effectiveWatermark(null, null)) + assertEquals(5000L, effectiveWatermark(5000L, null)) + } + + @Test + fun effectiveWatermarkClampsNullStoredToConsent() { + // The core leak (finding 1 + 3): a null group watermark nulled by clearWatermarks + // (removal / revocation dialog) or by a racing reset would otherwise read as epoch. The + // clamp floors it at the consent instant, so pre-consent history is never selected. + assertEquals(1000L, effectiveWatermark(null, 1000L)) + } + + @Test + fun effectiveWatermarkFloorsAValueBelowConsent() { + // A stored watermark below the consent (e.g. a rewind) is floored back up to consent. + assertEquals(1000L, effectiveWatermark(500L, 1000L)) + } + + @Test + fun effectiveWatermarkKeepsStoredWhenAtOrAboveConsent() { + // A stored watermark that has advanced past the consent is kept — the monotonic advance + // is not perturbed by the clamp (only the SELECT floor is raised). + assertEquals(1000L, effectiveWatermark(1000L, 1000L)) + assertEquals(5000L, effectiveWatermark(5000L, 1000L)) + } +} diff --git a/app/src/test/java/com/pulseloop/health/HealthConnectPermissionReconcileTest.kt b/app/src/test/java/com/pulseloop/health/HealthConnectPermissionReconcileTest.kt new file mode 100644 index 0000000..3b70eeb --- /dev/null +++ b/app/src/test/java/com/pulseloop/health/HealthConnectPermissionReconcileTest.kt @@ -0,0 +1,187 @@ +package com.pulseloop.health + +import android.content.SharedPreferences +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Phase 6 (docs/health-connect-integration.md §4): the permission → export-group mapping and the + * grant/revocation reconciliation. Pure logic + an in-memory prefs store (repo convention: no + * mocking framework) — no client, no database. + */ +class HealthConnectPermissionReconcileTest { + + private class FakeSharedPreferences : SharedPreferences { + val map = HashMap() + override fun getAll(): MutableMap = map + override fun getString(key: String?, defValue: String?): String? = map[key] as? String ?: defValue + override fun getStringSet(key: String?, defValues: MutableSet?): MutableSet? = + map[key] as? MutableSet ?: defValues + override fun getInt(key: String?, defValue: Int): Int = map[key] as? Int ?: defValue + override fun getLong(key: String?, defValue: Long): Long = map[key] as? Long ?: defValue + override fun getFloat(key: String?, defValue: Float): Float = map[key] as? Float ?: defValue + override fun getBoolean(key: String?, defValue: Boolean): Boolean = map[key] as? Boolean ?: defValue + override fun contains(key: String?): Boolean = map.containsKey(key) + override fun edit(): SharedPreferences.Editor = FakeEditor(this) + override fun registerOnSharedPreferenceChangeListener(l: SharedPreferences.OnSharedPreferenceChangeListener?) {} + override fun unregisterOnSharedPreferenceChangeListener(l: SharedPreferences.OnSharedPreferenceChangeListener?) {} + + private class FakeEditor(private val prefs: FakeSharedPreferences) : SharedPreferences.Editor { + override fun putString(key: String?, value: String?): SharedPreferences.Editor { + if (value != null) prefs.map[key!!] = value + return this + } + override fun putStringSet(key: String?, values: MutableSet?): SharedPreferences.Editor = this + override fun putInt(key: String?, value: Int): SharedPreferences.Editor { prefs.map[key!!] = value; return this } + override fun putLong(key: String?, value: Long): SharedPreferences.Editor { prefs.map[key!!] = value; return this } + override fun putFloat(key: String?, value: Float): SharedPreferences.Editor = this + override fun putBoolean(key: String?, value: Boolean): SharedPreferences.Editor { prefs.map[key!!] = value; return this } + override fun remove(key: String?): SharedPreferences.Editor { prefs.map.remove(key); return this } + override fun clear(): SharedPreferences.Editor { prefs.map.clear(); return this } + override fun commit(): Boolean = true + override fun apply() {} + } + } + + private fun store(): HealthConnectPrefsStore = HealthConnectPrefsStore(FakeSharedPreferences()) + + private val V = HealthConnectWatermarks.Key.VITALS + private val S = HealthConnectWatermarks.Key.SLEEP + private val A = HealthConnectWatermarks.Key.ACTIVITY + private val W = HealthConnectWatermarks.Key.WORKOUTS + private val N = HealthConnectWatermarks.Key.NUTRITION + private val R = HealthConnectWatermarks.Key.RESTING_HR + + // ── mapping ── + + @Test + fun allSixteenPermissionsAreMappedToExactlyOneGroup() { + val all = HealthConnectPermissions.all + assertEquals(16, all.size) + // Every requested permission has a group, and the map has no stray keys. + assertTrue(all.all { HealthConnectPermissionReconcile.PERMISSION_GROUP.containsKey(it) }) + assertEquals(all, HealthConnectPermissionReconcile.PERMISSION_GROUP.keys) + } + + @Test + fun phase5KindsAndLegacyVitalsAllMapToVitals() { + // The four Phase 5 kinds share the advanced VITALS watermark (plan §4 Phase 6 + Phase 5 log). + assertEquals(V, HealthConnectPermissionReconcile.PERMISSION_GROUP[HealthConnectPermissions.bloodGlucose.first()]) + assertEquals(V, HealthConnectPermissionReconcile.PERMISSION_GROUP[HealthConnectPermissions.respiratoryRate.first()]) + assertEquals(V, HealthConnectPermissionReconcile.PERMISSION_GROUP[HealthConnectPermissions.vo2Max.first()]) + assertEquals(V, HealthConnectPermissionReconcile.PERMISSION_GROUP[HealthConnectPermissions.bloodPressure.first()]) + // ...and the four legacy vitals kinds the .name fix backfills on the same reset. + assertEquals(V, HealthConnectPermissionReconcile.PERMISSION_GROUP[HealthConnectPermissions.heartRate.first()]) + assertEquals(V, HealthConnectPermissionReconcile.PERMISSION_GROUP[HealthConnectPermissions.oxygenSaturation.first()]) + assertEquals(V, HealthConnectPermissionReconcile.PERMISSION_GROUP[HealthConnectPermissions.heartRateVariability.first()]) + assertEquals(V, HealthConnectPermissionReconcile.PERMISSION_GROUP[HealthConnectPermissions.bodyTemperature.first()]) + } + + @Test + fun activityWorkoutsSleepNutritionRestingMapCorrectly() { + assertEquals(A, HealthConnectPermissionReconcile.PERMISSION_GROUP[HealthConnectPermissions.steps.first()]) + assertEquals(A, HealthConnectPermissionReconcile.PERMISSION_GROUP[HealthConnectPermissions.activeCalories.first()]) + assertEquals(A, HealthConnectPermissionReconcile.PERMISSION_GROUP[HealthConnectPermissions.distance.first()]) + assertEquals(W, HealthConnectPermissionReconcile.PERMISSION_GROUP[HealthConnectPermissions.exercise.first()]) + assertEquals(W, HealthConnectPermissionReconcile.PERMISSION_GROUP[HealthConnectPermissions.exerciseRoute.first()]) + assertEquals(S, HealthConnectPermissionReconcile.PERMISSION_GROUP[HealthConnectPermissions.sleep.first()]) + assertEquals(N, HealthConnectPermissionReconcile.PERMISSION_GROUP[HealthConnectPermissions.nutrition.first()]) + assertEquals(R, HealthConnectPermissionReconcile.PERMISSION_GROUP[HealthConnectPermissions.restingHeartRate.first()]) + } + + @Test + fun groupsForDeduplicatesToDistinctKeys() { + val groups = HealthConnectPermissionReconcile.groupsFor(setOf( + HealthConnectPermissions.heartRate.first(), + HealthConnectPermissions.bloodGlucose.first(), // same VITALS group as heartRate + HealthConnectPermissions.sleep.first(), + )) + assertEquals(setOf(V, S), groups) + } + + // ── reconcile ── + + @Test + fun growResetsOnlyTheGroupsOfTheNewPermissions() { + val store = store() + store.setWatermark(V, 1000L) + store.setWatermark(S, 2000L) + val previous = setOf(HealthConnectPermissions.heartRate.first()) + // Granting glucose (a VITALS kind) is a grow that must reset VITALS, not SLEEP. + val current = previous + HealthConnectPermissions.bloodGlucose.first() + val outcome = HealthConnectPermissionReconcile.reconcile(previous, current, store) + assertTrue(V in outcome.grewGroups) + assertNull(store.currentWatermarks.get(V)) + assertEquals(2000L, store.currentWatermarks.get(S)) + assertFalse(outcome.allRevoked) + assertEquals(setOf(HealthConnectPermissions.bloodGlucose.first()), outcome.grew) + } + + @Test + fun fullRevokeFlagsAllRevokedButDoesNotReset() { + val store = store() + store.setWatermark(V, 1000L) + val previous = setOf(HealthConnectPermissions.heartRate.first(), HealthConnectPermissions.bloodGlucose.first()) + val outcome = HealthConnectPermissionReconcile.reconcile(previous, emptySet(), store) + assertTrue(outcome.allRevoked) + assertTrue(outcome.grewGroups.isEmpty()) + // A shrink never auto-resets: the watermark stays so the settings screen can OFFER the + // reset (a forced reset on mere revocation would discard the users data choice). + assertEquals(1000L, store.currentWatermarks.get(V)) + } + + @Test + fun unchangedSetIsANoOp() { + val store = store() + store.setWatermark(V, 1000L) + val same = setOf(HealthConnectPermissions.heartRate.first()) + val outcome = HealthConnectPermissionReconcile.reconcile(same, same, store) + assertTrue(outcome.grew.isEmpty()) + assertTrue(outcome.revoked.isEmpty()) + assertFalse(outcome.allRevoked) + assertEquals(1000L, store.currentWatermarks.get(V)) + } + + @Test + fun regrantAfterFullRevokeIsAGrowFromEmptyThatResets() { + val store = store() + store.setWatermark(S, 5000L) + // After a detected full revoke the stored set is empty; re-granting sleep is a grow from + // empty, so the automatic reset re-exports the sleep history (the plans backstop). + val outcome = HealthConnectPermissionReconcile.reconcile(emptySet(), setOf(HealthConnectPermissions.sleep.first()), store) + assertTrue(S in outcome.grewGroups) + assertNull(store.currentWatermarks.get(S)) + } + + // ── review pass 5: one definition of the stored granted set ── + + @Test + fun storedSetOfKeepsOnlyRequestedPermissionsSorted() { + val live = listOf( + HealthConnectPermissions.sleep.first(), + "android.permission.health.READ_HEART_RATE", // never requested (write-only app) + "android.permission.health.WRITE_BODY_FAT", // a health perm we don't declare + HealthConnectPermissions.heartRate.first(), + ) + val stored = HealthConnectPermissionReconcile.storedSetOf(live) + assertEquals( + listOf(HealthConnectPermissions.heartRate.first(), HealthConnectPermissions.sleep.first()).sorted(), + stored, + ) + } + + @Test + fun storedSetOfIsStableSoAnUnrequestedGrantIsNotSeenAsAGrowOrShrink() { + // The bug this closes: the permission-sheet callback filtered its result while the + // app-start / settings reconcile stored the live set verbatim, so a health permission + // granted outside `all` made the two disagree on every pass. + val live = HealthConnectPermissions.all + "android.permission.health.WRITE_BODY_FAT" + val first = HealthConnectPermissionReconcile.storedSetOf(live) + val second = HealthConnectPermissionReconcile.storedSetOf(first) + assertEquals(first, second) + assertEquals(HealthConnectPermissions.all.size, first.size) + } +} \ No newline at end of file diff --git a/app/src/test/java/com/pulseloop/health/HealthConnectPermissionsTest.kt b/app/src/test/java/com/pulseloop/health/HealthConnectPermissionsTest.kt new file mode 100644 index 0000000..8def159 --- /dev/null +++ b/app/src/test/java/com/pulseloop/health/HealthConnectPermissionsTest.kt @@ -0,0 +1,84 @@ +package com.pulseloop.health + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Phase 0 + Phase 5 (docs/health-connect-integration.md): the permission sets must be derived + * from the record classes, not hardcoded, and must contain exactly the sixteen WRITE permissions + * (ten Phase 1-4 + six Phase 5 "beyond iOS") - no READ_*. Asserting the official string + * literals pins the record-class -> permission mapping so a silent library change fails loudly + * here, not in the field. + */ +class HealthConnectPermissionsTest { + + @Test + fun allContainsExactlyTheSixteenWritePermissions() { + assertEquals(16, HealthConnectPermissions.all.size) + } + + @Test + fun everyPermissionIsAHealthWritePermission() { + for (p in HealthConnectPermissions.all) { + assertTrue("expected a health WRITE permission, got $p", p.startsWith("android.permission.health.WRITE_")) + } + } + + @Test + fun noReadPermissionAnyWhere() { + for (p in HealthConnectPermissions.all) { + assertFalse("found a read permission: $p", p.startsWith("android.permission.health.READ")) + } + } + + @Test + fun derivationMatchesTheOfficialPermissionStrings() { + // Official data-types reference; the route is singular on purpose (READ_EXERCISE_ROUTES + // is the plural one - the docs call out the asymmetry). + val phase1To4 = setOf( + "android.permission.health.WRITE_HEART_RATE", + "android.permission.health.WRITE_OXYGEN_SATURATION", + "android.permission.health.WRITE_HEART_RATE_VARIABILITY", + "android.permission.health.WRITE_BODY_TEMPERATURE", + "android.permission.health.WRITE_SLEEP", + "android.permission.health.WRITE_STEPS", + "android.permission.health.WRITE_ACTIVE_CALORIES_BURNED", + "android.permission.health.WRITE_DISTANCE", + "android.permission.health.WRITE_EXERCISE", + "android.permission.health.WRITE_EXERCISE_ROUTE", + ) + val phase5 = setOf( + "android.permission.health.WRITE_BLOOD_PRESSURE", + "android.permission.health.WRITE_BLOOD_GLUCOSE", + "android.permission.health.WRITE_RESPIRATORY_RATE", + "android.permission.health.WRITE_VO2_MAX", + "android.permission.health.WRITE_RESTING_HEART_RATE", + "android.permission.health.WRITE_NUTRITION", + ) + assertTrue(phase1To4.all { HealthConnectPermissions.all.contains(it) }) + assertTrue(phase5.all { HealthConnectPermissions.all.contains(it) }) + } + + @Test + fun everyRowMapsToItsPermissions() { + assertEquals(1, HealthConnectPermissions.permissionsForRow(HealthConnectPermissions.DataTypeRow.HEART_RATE).size) + assertEquals(3, HealthConnectPermissions.permissionsForRow(HealthConnectPermissions.DataTypeRow.STEPS_AND_ACTIVITY).size) + assertEquals(2, HealthConnectPermissions.permissionsForRow(HealthConnectPermissions.DataTypeRow.WORKOUTS).size) + // Phase 5: every type - including NUTRITION, whose permission landed in Phase 5 - now + // maps to exactly its single write permission. + for (row in listOf( + HealthConnectPermissions.DataTypeRow.NUTRITION, + HealthConnectPermissions.DataTypeRow.BLOOD_PRESSURE, + HealthConnectPermissions.DataTypeRow.BLOOD_GLUCOSE, + HealthConnectPermissions.DataTypeRow.RESPIRATORY_RATE, + HealthConnectPermissions.DataTypeRow.VO2_MAX, + HealthConnectPermissions.DataTypeRow.RESTING_HEART_RATE, + )) { + assertEquals("permissionsForRow($row)", 1, HealthConnectPermissions.permissionsForRow(row).size) + } + // And no row is empty any more (Phase 0 deferred NUTRITION; Phase 5 filled it). + assertTrue(HealthConnectPermissions.DataTypeRow.values().none { HealthConnectPermissions.permissionsForRow(it).isEmpty() }) + } +} diff --git a/app/src/test/java/com/pulseloop/health/HealthConnectPhase5MappingTest.kt b/app/src/test/java/com/pulseloop/health/HealthConnectPhase5MappingTest.kt new file mode 100644 index 0000000..c291c80 --- /dev/null +++ b/app/src/test/java/com/pulseloop/health/HealthConnectPhase5MappingTest.kt @@ -0,0 +1,225 @@ +package com.pulseloop.health + +import androidx.health.connect.client.records.MealType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Phase 5 (docs/health-connect-integration.md): the new clientRecordId builders and the + * plausibility guards for blood pressure, glucose, respiratory rate, VO2max and resting HR. + * All pure [HealthConnectTypeMappings] helpers - no database or client. + */ +class HealthConnectPhase5MappingTest { + + // ── id builders ── + + @Test + fun bloodPressureRecordIdIsBpPlusSharedTimestamp() { + assertEquals("pl-m-bp-1723700000123", HealthConnectTypeMappings.bloodPressureRecordId(1_723_700_000_123L)) + assertEquals("pl-m-bp-0", HealthConnectTypeMappings.bloodPressureRecordId(0L)) + } + + @Test + fun restingHrRecordIdIsAStableConstant() { + assertEquals("pl-resting-hr", HealthConnectTypeMappings.RESTING_HR_RECORD_ID) + } + + @Test + fun nutritionRecordIdIsMealPlusStableId() { + assertEquals("pl-meal-abc-123", HealthConnectTypeMappings.nutritionRecordId("abc-123")) + } + + // ── plausibility guards ── + + @Test + fun systolicUsesAppRange() { + assertTrue(HealthConnectTypeMappings.isPlausibleSystolic(120.0)) + assertTrue(HealthConnectTypeMappings.isPlausibleSystolic(60.0)) + // the platform ceiling is 200, not the app's decode ceiling of 250 + assertTrue(HealthConnectTypeMappings.isPlausibleSystolic(200.0)) + assertFalse(HealthConnectTypeMappings.isPlausibleSystolic(59.9)) + assertFalse(HealthConnectTypeMappings.isPlausibleSystolic(200.1)) + assertFalse(HealthConnectTypeMappings.isPlausibleSystolic(250.0)) + assertFalse(HealthConnectTypeMappings.isPlausibleSystolic(0.0)) + assertFalse(HealthConnectTypeMappings.isPlausibleSystolic(Double.NaN)) + } + + @Test + fun diastolicUsesAppRange() { + assertTrue(HealthConnectTypeMappings.isPlausibleDiastolic(80.0)) + assertTrue(HealthConnectTypeMappings.isPlausibleDiastolic(30.0)) + assertTrue(HealthConnectTypeMappings.isPlausibleDiastolic(150.0)) + assertFalse(HealthConnectTypeMappings.isPlausibleDiastolic(29.9)) + assertFalse(HealthConnectTypeMappings.isPlausibleDiastolic(150.1)) + assertFalse(HealthConnectTypeMappings.isPlausibleDiastolic(Double.POSITIVE_INFINITY)) + } + + @Test + fun glucoseIsCappedAtThePlatformCeiling() { + assertTrue(HealthConnectTypeMappings.isPlausibleBloodGlucose(100.0)) + assertTrue(HealthConnectTypeMappings.isPlausibleBloodGlucose(20.0)) + // the hard cap is 900.0 mg/dL (= 50 mmol/L at the client's 1/18 factor), NOT 900.91: + // 900.0 maps to 50.0 (accepted), 900.01 maps to 50.0006 (the ctor throws) + assertTrue(HealthConnectTypeMappings.isPlausibleBloodGlucose(900.0)) + assertFalse(HealthConnectTypeMappings.isPlausibleBloodGlucose(900.01)) + assertFalse(HealthConnectTypeMappings.isPlausibleBloodGlucose(19.9)) + // 0 is the not-measured sentinel - dropped, not clamped + assertFalse(HealthConnectTypeMappings.isPlausibleBloodGlucose(0.0)) + assertFalse(HealthConnectTypeMappings.isPlausibleBloodGlucose(Double.NaN)) + } + + @Test + fun respRateUsesAppRange() { + assertTrue(HealthConnectTypeMappings.isPlausibleRespRate(16.0)) + assertTrue(HealthConnectTypeMappings.isPlausibleRespRate(5.0)) + assertTrue(HealthConnectTypeMappings.isPlausibleRespRate(60.0)) + assertFalse(HealthConnectTypeMappings.isPlausibleRespRate(4.9)) + assertFalse(HealthConnectTypeMappings.isPlausibleRespRate(60.1)) + assertFalse(HealthConnectTypeMappings.isPlausibleRespRate(0.0)) + } + + @Test + fun vo2MaxUsesAppRange() { + assertTrue(HealthConnectTypeMappings.isPlausibleVo2Max(45.0)) + assertTrue(HealthConnectTypeMappings.isPlausibleVo2Max(1.0)) + assertTrue(HealthConnectTypeMappings.isPlausibleVo2Max(100.0)) + assertFalse(HealthConnectTypeMappings.isPlausibleVo2Max(0.9)) + assertFalse(HealthConnectTypeMappings.isPlausibleVo2Max(100.1)) + assertFalse(HealthConnectTypeMappings.isPlausibleVo2Max(Double.NaN)) + } + + @Test + fun restingHrUsesPlatformBound() { + assertTrue(HealthConnectTypeMappings.isPlausibleRestingHr(57.0)) + assertTrue(HealthConnectTypeMappings.isPlausibleRestingHr(1.0)) + assertTrue(HealthConnectTypeMappings.isPlausibleRestingHr(300.0)) + // the platform rejects 0, unlike the client + assertFalse(HealthConnectTypeMappings.isPlausibleRestingHr(0.0)) + assertFalse(HealthConnectTypeMappings.isPlausibleRestingHr(300.1)) + assertFalse(HealthConnectTypeMappings.isPlausibleRestingHr(Double.NEGATIVE_INFINITY)) + } + + // ── blood pressure pairing (the crux of Phase 5) ── + + @Test + fun bloodPressurePairsByExactTimestamp() { + val sys = listOf( + HealthConnectTypeMappings.BpSide(1000L, 120.0, 5000L), + HealthConnectTypeMappings.BpSide(2000L, 110.0, 6000L), + HealthConnectTypeMappings.BpSide(3000L, 130.0, 7000L), + ) + val dia = listOf( + HealthConnectTypeMappings.BpSide(1000L, 80.0, 5100L), // pairs with sys@1000 + HealthConnectTypeMappings.BpSide(2000L, 70.0, 5900L), // pairs with sys@2000 + // 3000 has no diastolic -> unpaired, dropped + ) + val result = HealthConnectTypeMappings.pairBloodPressure(sys, dia) + val pairs = result.pairs + assertEquals(2, pairs.size) + assertEquals(1000L, pairs[0].timestampMs) + assertEquals(120.0, pairs[0].systolic, 0.0) + assertEquals(80.0, pairs[0].diastolic, 0.0) + // highWater = max of the pair's two createdAts + assertEquals(5100L, pairs[0].highWater) + assertEquals(2000L, pairs[1].timestampMs) + // pair@2000: sys createdAt 6000, dia createdAt 5900 -> max = 6000 + assertEquals(6000L, pairs[1].highWater) + // ts=3000 has a systolic but no diastolic -> counted as unpaired, not out-of-range + assertEquals(1, result.unpaired) + assertEquals(0, result.outOfRange) + } + + @Test + fun bloodPressureDropsUnpairedAndOutOfRange() { + val sys = listOf( + HealthConnectTypeMappings.BpSide(1000L, 120.0, 100L), // ok + HealthConnectTypeMappings.BpSide(2000L, 210.0, 100L), // systolic above the 200 platform cap + HealthConnectTypeMappings.BpSide(3000L, 120.0, 100L), // ok systolic, but diastolic out of range + HealthConnectTypeMappings.BpSide(4000L, 50.0, 100L), // systolic below the 60 app floor + ) + val dia = listOf( + HealthConnectTypeMappings.BpSide(1000L, 80.0, 100L), // ok + HealthConnectTypeMappings.BpSide(2000L, 80.0, 100L), + HealthConnectTypeMappings.BpSide(3000L, 160.0, 100L), // diastolic above the 150 app ceiling + HealthConnectTypeMappings.BpSide(4000L, 80.0, 100L), + ) + val result = HealthConnectTypeMappings.pairBloodPressure(sys, dia) + assertEquals(1, result.pairs.size) + assertEquals(1000L, result.pairs[0].timestampMs) + // 3 out-of-range pairs (sys 210, dia 160, sys 50), 0 unpaired + assertEquals(0, result.unpaired) + assertEquals(3, result.outOfRange) + assertEquals(3, result.dropped) + } + + // ── review pass 5: out-of-range pairs must release the shared VITALS watermark ── + + @Test + fun bloodPressureReportsOutOfRangeHighWaterButNotUnpaired() { + val sys = listOf( + HealthConnectTypeMappings.BpSide(1000L, 120.0, 100L), // exports + HealthConnectTypeMappings.BpSide(2000L, 210.0, 900L), // out of range: permanently dead + HealthConnectTypeMappings.BpSide(3000L, 120.0, 5000L), // unpaired: its diastolic may still arrive + ) + val dia = listOf( + HealthConnectTypeMappings.BpSide(1000L, 80.0, 100L), + HealthConnectTypeMappings.BpSide(2000L, 80.0, 800L), + ) + val result = HealthConnectTypeMappings.pairBloodPressure(sys, dia) + assertEquals(1, result.pairs.size) + assertEquals(1, result.outOfRange) + assertEquals(1, result.unpaired) + // max createdAt of the OUT-OF-RANGE pair only (900 vs 800) — never the unpaired row's + // 5000, which must keep holding the watermark down so the pair can still form. + assertEquals(900L, result.outOfRangeHighWater) + } + + @Test + fun bloodPressureOutOfRangeHighWaterIsNullWhenNothingIsOutOfRange() { + val sys = listOf(HealthConnectTypeMappings.BpSide(1000L, 120.0, 100L)) + val dia = listOf(HealthConnectTypeMappings.BpSide(1000L, 80.0, 100L)) + assertEquals(null, HealthConnectTypeMappings.pairBloodPressure(sys, dia).outOfRangeHighWater) + // …and an unpaired-only selection reports none either. + assertEquals(null, HealthConnectTypeMappings.pairBloodPressure(sys, emptyList()).outOfRangeHighWater) + } + + @Test + fun bloodPressureEmptyOrOneSidedInputsProduceNoPairs() { + assertTrue(HealthConnectTypeMappings.pairBloodPressure(emptyList(), emptyList()).pairs.isEmpty()) + val sys = listOf(HealthConnectTypeMappings.BpSide(1000L, 120.0, 100L)) + assertTrue(HealthConnectTypeMappings.pairBloodPressure(sys, emptyList()).pairs.isEmpty()) + assertTrue(HealthConnectTypeMappings.pairBloodPressure(emptyList(), sys).pairs.isEmpty()) + } + + @Test + fun nutritionEnergyCapsAtPlatformCeilingInKcal() { + // platform cap is 100,000,000 small calories = 100,000 kcal, NOT 1e8 kcal + assertTrue(HealthConnectTypeMappings.isPlausibleNutritionEnergyKcal(500.0)) + assertTrue(HealthConnectTypeMappings.isPlausibleNutritionEnergyKcal(100_000.0)) + assertFalse(HealthConnectTypeMappings.isPlausibleNutritionEnergyKcal(100_001.0)) + assertFalse(HealthConnectTypeMappings.isPlausibleNutritionEnergyKcal(500_000.0)) // a 6-digit kcal typo + assertFalse(HealthConnectTypeMappings.isPlausibleNutritionEnergyKcal(0.0)) + assertFalse(HealthConnectTypeMappings.isPlausibleNutritionEnergyKcal(Double.NaN)) + } + + @Test + fun nutritionMassCapsAt100000() { + assertTrue(HealthConnectTypeMappings.isPlausibleNutritionMass(30.0)) + assertTrue(HealthConnectTypeMappings.isPlausibleNutritionMass(100_000.0)) + assertFalse(HealthConnectTypeMappings.isPlausibleNutritionMass(100_001.0)) + assertFalse(HealthConnectTypeMappings.isPlausibleNutritionMass(0.0)) + assertFalse(HealthConnectTypeMappings.isPlausibleNutritionMass(Double.POSITIVE_INFINITY)) + } + + @Test + fun nutritionMealTypeMapsTheAppsFour() { + assertEquals(MealType.MEAL_TYPE_BREAKFAST, HealthConnectTypeMappings.nutritionMealType("breakfast")) + assertEquals(MealType.MEAL_TYPE_LUNCH, HealthConnectTypeMappings.nutritionMealType("lunch")) + assertEquals(MealType.MEAL_TYPE_DINNER, HealthConnectTypeMappings.nutritionMealType("dinner")) + assertEquals(MealType.MEAL_TYPE_SNACK, HealthConnectTypeMappings.nutritionMealType("snack")) + assertEquals(MealType.MEAL_TYPE_UNKNOWN, HealthConnectTypeMappings.nutritionMealType("brunch")) + assertEquals(MealType.MEAL_TYPE_UNKNOWN, HealthConnectTypeMappings.nutritionMealType("")) + } +} diff --git a/app/src/test/java/com/pulseloop/health/HealthConnectPrefsStoreTest.kt b/app/src/test/java/com/pulseloop/health/HealthConnectPrefsStoreTest.kt new file mode 100644 index 0000000..9125f05 --- /dev/null +++ b/app/src/test/java/com/pulseloop/health/HealthConnectPrefsStoreTest.kt @@ -0,0 +1,405 @@ +package com.pulseloop.health + +import android.content.SharedPreferences +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Phase 0 (docs/health-connect-integration.md): the prefs store must survive schema drift in + * both directions — a blob written before a field existed, and one containing a field we + * don't know yet — and a corrupt blob must fall back to defaults rather than crash Settings. + * No mocking framework (repo convention): a hand-rolled in-memory SharedPreferences. + */ +class HealthConnectPrefsStoreTest { + + private class FakeSharedPreferences : SharedPreferences { + val map = HashMap() + + override fun getAll(): MutableMap = map + override fun getString(key: String?, defValue: String?): String? = map[key] as? String ?: defValue + override fun getStringSet(key: String?, defValues: MutableSet?): MutableSet? = + map[key] as? MutableSet ?: defValues + override fun getInt(key: String?, defValue: Int): Int = map[key] as? Int ?: defValue + override fun getLong(key: String?, defValue: Long): Long = map[key] as? Long ?: defValue + override fun getFloat(key: String?, defValue: Float): Float = map[key] as? Float ?: defValue + override fun getBoolean(key: String?, defValue: Boolean): Boolean = map[key] as? Boolean ?: defValue + override fun contains(key: String?): Boolean = map.containsKey(key) + override fun edit(): SharedPreferences.Editor = FakeEditor(this) + override fun registerOnSharedPreferenceChangeListener(l: SharedPreferences.OnSharedPreferenceChangeListener?) {} + override fun unregisterOnSharedPreferenceChangeListener(l: SharedPreferences.OnSharedPreferenceChangeListener?) {} + + private class FakeEditor(private val prefs: FakeSharedPreferences) : SharedPreferences.Editor { + override fun putString(key: String?, value: String?): SharedPreferences.Editor { + if (value != null) prefs.map[key!!] = value + return this + } + override fun putStringSet(key: String?, values: MutableSet?): SharedPreferences.Editor = this + override fun putInt(key: String?, value: Int): SharedPreferences.Editor { prefs.map[key!!] = value; return this } + override fun putLong(key: String?, value: Long): SharedPreferences.Editor { prefs.map[key!!] = value; return this } + override fun putFloat(key: String?, value: Float): SharedPreferences.Editor = this + override fun putBoolean(key: String?, value: Boolean): SharedPreferences.Editor { prefs.map[key!!] = value; return this } + override fun remove(key: String?): SharedPreferences.Editor { prefs.map.remove(key); return this } + override fun clear(): SharedPreferences.Editor { prefs.map.clear(); return this } + override fun commit(): Boolean = true + override fun apply() {} + } + } + + private fun storeWith(blob: String?): HealthConnectPrefsStore { + val fake = FakeSharedPreferences() + if (blob != null) fake.edit().putString("pulseloop.healthconnect.v1", blob).apply() + return HealthConnectPrefsStore(fake) + } + + /** Seeds both blobs, so an on-disk state from an older build can be reconstructed exactly. */ + private fun prefsWith(prefsBlob: String?, watermarksBlob: String?): FakeSharedPreferences { + val fake = FakeSharedPreferences() + if (prefsBlob != null) fake.edit().putString("pulseloop.healthconnect.v1", prefsBlob).apply() + if (watermarksBlob != null) fake.edit().putString("pulseloop.healthconnect.watermarks.v1", watermarksBlob).apply() + return fake + } + + @Test + fun defaultsWhenNoBlob() { + val store = storeWith(null) + assertEquals(HealthConnectPrefs.DEFAULT, store.current) + assertFalse(store.current.enabled) + assertTrue(store.current.heartRate) + assertEquals(HealthConnectPrefs.BackfillChoice.NOT_ASKED, store.current.backfillChoice) + } + + @Test + fun phase5TogglesDefaultTrueAndRoundTrip() { + // the five Phase 5 per-type toggles default ON under the master toggle (iOS parity) + val store = storeWith(null) + assertTrue(store.current.bloodPressure) + assertTrue(store.current.bloodGlucose) + assertTrue(store.current.respiratoryRate) + assertTrue(store.current.vo2Max) + assertTrue(store.current.restingHeartRate) + // a pre-Phase-5 blob decodes the new toggles to their default (true), not wiped + val legacy = storeWith("{\"enabled\":true,\"heartRate\":true,\"backfillChoice\":\"EXPORT_ALL\"}") + assertTrue(legacy.current.bloodPressure) + assertTrue(legacy.current.restingHeartRate) + // toggleFor / withToggleFor route the new rows + assertTrue(store.current.toggleFor(HealthConnectPermissions.DataTypeRow.BLOOD_PRESSURE)) + assertFalse(store.current.withToggleFor(HealthConnectPermissions.DataTypeRow.RESTING_HEART_RATE, false).restingHeartRate) + } + + @Test + fun restingHrWatermarkIsMonotonic() { + val store = storeWith(null) + assertNull(store.currentWatermarks.restingHr) + store.setWatermark(HealthConnectWatermarks.Key.RESTING_HR, 1000L) + assertEquals(1000L, store.currentWatermarks.restingHr) + store.setWatermark(HealthConnectWatermarks.Key.RESTING_HR, 500L) // a rewind is a no-op + assertEquals(1000L, store.currentWatermarks.restingHr) + store.setWatermark(HealthConnectWatermarks.Key.RESTING_HR, 2000L) + assertEquals(2000L, store.currentWatermarks.restingHr) + } + + @Test + fun unknownFutureKeyDoesNotWipeTheBlob() { + val store = storeWith("{\"enabled\":true,\"futureField\":123}") + assertTrue(store.current.enabled) + // Unknown key ignored; everything else kept its default. + assertEquals(HealthConnectPrefs.BackfillChoice.NOT_ASKED, store.current.backfillChoice) + assertTrue(store.current.sleep) + } + + @Test + fun blobMissingNewKeysFallsBackToPerFieldDefaults() { + val store = storeWith("{\"enabled\":true}") + assertTrue(store.current.enabled) + assertTrue(store.current.workouts) // field did not exist when the blob was written + assertNull(store.current.lastSyncAt) + assertFalse(store.current.isConnected) + } + + @Test + fun corruptBlobFallsBackToDefaults() { + val store = storeWith("not-json{") + assertEquals(HealthConnectPrefs.DEFAULT, store.current) + assertEquals(HealthConnectWatermarks.DEFAULT, store.currentWatermarks) + } + + @Test + fun watermarkNeverRewinds() { + val store = storeWith(null) + store.setWatermark(HealthConnectWatermarks.Key.VITALS, 200) + assertEquals(200L, store.currentWatermarks.vitals) + store.setWatermark(HealthConnectWatermarks.Key.VITALS, 100) + assertEquals(200L, store.currentWatermarks.vitals) + store.setWatermark(HealthConnectWatermarks.Key.VITALS, 300) + assertEquals(300L, store.currentWatermarks.vitals) + } + + @Test + fun sleepWatermarkNeverRewinds() { + // Phase 2: the sleep group's watermark (SleepSessionEntity.updatedAt) gets the same + // monotonic treatment as vitals — an interrupted backfill resumes, never re-exports. + val store = storeWith(null) + store.setWatermark(HealthConnectWatermarks.Key.SLEEP, 200) + assertEquals(200L, store.currentWatermarks.sleep) + store.setWatermark(HealthConnectWatermarks.Key.SLEEP, 150) + assertEquals(200L, store.currentWatermarks.sleep) + store.setWatermark(HealthConnectWatermarks.Key.SLEEP, 300) + assertEquals(300L, store.currentWatermarks.sleep) + } + + @Test + fun watermarksAreIndependentPerKey() { + val store = storeWith(null) + store.setWatermark(HealthConnectWatermarks.Key.VITALS, 200) + store.setWatermark(HealthConnectWatermarks.Key.SLEEP, 50) + assertEquals(200L, store.currentWatermarks.vitals) + assertEquals(50L, store.currentWatermarks.sleep) + assertNull(store.currentWatermarks.activity) + } + + @Test + fun clearWatermarksResetsToNull() { + val store = storeWith(null) + store.setWatermark(HealthConnectWatermarks.Key.WORKOUTS, 42) + store.clearWatermarks() + assertEquals(HealthConnectWatermarks.DEFAULT, store.currentWatermarks) + } + + @Test + fun updatePersistsAndSkipsNoOps() { + val store = storeWith(null) + store.update { it.copy(enabled = true) } + assertTrue(store.current.enabled) + val before = store.current + store.update { it } + assertSame(before, store.current) + } + + @Test + fun lastGrantedPermissionsTracksPartialGrant() { + val store = storeWith(null) + store.update { it.copy(enabled = true, lastGrantedPermissions = listOf("android.permission.health.WRITE_HEART_RATE")) } + assertTrue(store.current.isConnected) + } + + // ── Phase 4: the one-time netting-flip marker + targeted watermark reset ── + + @Test + fun nettingFlipMarkerDefaultsFalseForPhase3Blobs() { + // A blob written by the Phase 3 build (no nettingFlipDone key) must decode as + // "flip not done yet" — that is the state that triggers the reset on first run. + val store = storeWith("{\"enabled\":true,\"backfillChoice\":\"EXPORT_ALL\"}") + assertFalse(store.current.nettingFlipDone) + } + + @Test + fun nettingFlipMarkerPersistsOnceSet() { + val store = storeWith(null) + assertFalse(store.current.nettingFlipDone) + store.update { it.copy(nettingFlipDone = true) } + assertTrue(store.current.nettingFlipDone) + } + + @Test + fun resetWatermarksNullsOnlyTheNamedKeys() { + val store = storeWith(null) + store.setWatermark(HealthConnectWatermarks.Key.VITALS, 111) + store.setWatermark(HealthConnectWatermarks.Key.SLEEP, 222) + store.setWatermark(HealthConnectWatermarks.Key.ACTIVITY, 333) + store.setWatermark(HealthConnectWatermarks.Key.WORKOUTS, 444) + store.resetWatermarks(setOf(HealthConnectWatermarks.Key.ACTIVITY, HealthConnectWatermarks.Key.WORKOUTS)) + val wm = store.currentWatermarks + assertEquals(111L, wm.vitals) + assertEquals(222L, wm.sleep) + assertNull(wm.activity) + assertNull(wm.workouts) + } + + @Test + fun resetWatermarksIsNotRewindProtectionBait() { + // The monotonic guard belongs to setWatermark only: after an explicit reset the + // watermark can start again from whatever value the next landed pass reports — even + // one lower than the pre-reset value, which is the whole point of re-exporting. + val store = storeWith(null) + store.setWatermark(HealthConnectWatermarks.Key.ACTIVITY, 500) + store.resetWatermarks(setOf(HealthConnectWatermarks.Key.ACTIVITY)) + assertNull(store.currentWatermarks.activity) + store.setWatermark(HealthConnectWatermarks.Key.ACTIVITY, 400) + assertEquals(400L, store.currentWatermarks.activity) + } + + @Test + fun resetWatermarksPersistsToTheBlob() { + // The process can die between the flip reset and the re-export, so the reset nulls have + // to be on disk: assert against the backing blob AND a fresh store over the same prefs. + val fake = FakeSharedPreferences() + val store = HealthConnectPrefsStore(fake) + store.setWatermark(HealthConnectWatermarks.Key.ACTIVITY, 333) + store.resetWatermarks(setOf(HealthConnectWatermarks.Key.ACTIVITY)) + val reloaded = HealthConnectPrefsStore(fake) + assertNull(reloaded.currentWatermarks.activity) + assertEquals( + "{\"vitals\":null,\"sleep\":null,\"activity\":null,\"workouts\":null,\"nutrition\":null,\"restingHr\":null}", + fake.map["pulseloop.healthconnect.watermarks.v1"] as String, + ) + } + + // ── Review pass 2 (PR #50): EXPORT_NEW_ONLY consent clamp + upgrade-boundary seed ── + + @Test + fun resetWatermarksClampsToConsentInstantForNewOnly() { + // A NEW_ONLY user's grow-reset must NOT null the watermark (null = export-from-epoch = + // the pre-consent history the user declined leaks). It clamps to the consent instant. + val store = storeWith(null) + store.update { + it.copy(backfillChoice = HealthConnectPrefs.BackfillChoice.EXPORT_NEW_ONLY, newOnlyConsentAt = 1000L) + } + store.setWatermark(HealthConnectWatermarks.Key.VITALS, 5000) // advanced well past consent + store.setWatermark(HealthConnectWatermarks.Key.SLEEP, 2000) + store.resetWatermarks(setOf(HealthConnectWatermarks.Key.VITALS)) + assertEquals(1000L, store.currentWatermarks.vitals) // clamped, not null + assertEquals(2000L, store.currentWatermarks.sleep) // not named -> untouched + } + + @Test + fun resetWatermarksStillNullsForNonNewOnly() { + // EXPORT_ALL / NOT_ASKED users keep the original null-and-backfill-from-epoch behaviour — + // the consent clamp applies only to EXPORT_NEW_ONLY. + val store = storeWith("{\"enabled\":true,\"backfillChoice\":\"EXPORT_ALL\"}") + store.setWatermark(HealthConnectWatermarks.Key.VITALS, 5000) + store.resetWatermarks(setOf(HealthConnectWatermarks.Key.VITALS)) + assertNull(store.currentWatermarks.vitals) + } + + @Test + fun resetWatermarksClampsEveryNamedKeyToConsent() { + val store = storeWith(null) + store.update { + it.copy(backfillChoice = HealthConnectPrefs.BackfillChoice.EXPORT_NEW_ONLY, newOnlyConsentAt = 700L) + } + store.setWatermark(HealthConnectWatermarks.Key.VITALS, 9000) + store.setWatermark(HealthConnectWatermarks.Key.NUTRITION, 8000) + store.setWatermark(HealthConnectWatermarks.Key.ACTIVITY, 9500) + store.resetWatermarks(setOf(HealthConnectWatermarks.Key.VITALS, HealthConnectWatermarks.Key.NUTRITION)) + assertEquals(700L, store.currentWatermarks.vitals) + assertEquals(700L, store.currentWatermarks.nutrition) + assertEquals(9500L, store.currentWatermarks.activity) // not named -> untouched + } + + @Test + fun resetWatermarksClampIsIdempotentAndPersists() { + // The clamp value is on disk (the process can die between reset and re-export) and a + // fresh store over the same prefs sees it. + val fake = FakeSharedPreferences() + val store = HealthConnectPrefsStore(fake) + store.update { + it.copy(backfillChoice = HealthConnectPrefs.BackfillChoice.EXPORT_NEW_ONLY, newOnlyConsentAt = 1200L) + } + store.setWatermark(HealthConnectWatermarks.Key.VITALS, 4000) + store.resetWatermarks(setOf(HealthConnectWatermarks.Key.VITALS)) + val reloaded = HealthConnectPrefsStore(fake) + assertEquals(1200L, reloaded.currentWatermarks.vitals) + } + + // ── Review pass 4 (PR #50): atomic read-modify-write + legacy consent-instant recovery ── + + @Test + fun concurrentUpdatesDoNotDropEachOthersFields() { + // update() is a read-modify-write and there are genuinely concurrent writers (export + // worker / removal scope / settings on the main thread). Without a lock, two writers that + // read the same snapshot silently drop each other's field — the settings toggle flipped + // mid-backfill snaps back on. The transforms below both read, then sleep, so the + // interleaving is forced rather than hoped for. + val store = storeWith(null) + val start = java.util.concurrent.CountDownLatch(1) + fun writer(body: (HealthConnectPrefs) -> HealthConnectPrefs) = Thread { + start.await() + store.update { cur -> Thread.sleep(50); body(cur) } + } + val a = writer { it.copy(enabled = true) } + val b = writer { it.copy(heartRate = false) } + a.start(); b.start() + start.countDown() + a.join(5_000); b.join(5_000) + assertTrue(store.current.enabled) + assertFalse(store.current.heartRate) + } + + @Test + fun concurrentWatermarkAdvancesAllSurvive() { + // Same defect class on the watermark blob: two groups advancing at once must not drop + // each other (a lost advance re-exports; a lost reset leaks pre-consent history). + val store = storeWith(null) + val start = java.util.concurrent.CountDownLatch(1) + val threads = HealthConnectWatermarks.Key.values().mapIndexed { i, key -> + Thread { + start.await() + repeat(50) { n -> store.setWatermark(key, (i + 1) * 1000L + n) } + } + } + threads.forEach { it.start() } + start.countDown() + threads.forEach { it.join(5_000) } + HealthConnectWatermarks.Key.values().forEachIndexed { i, key -> + assertEquals((i + 1) * 1000L + 49, store.currentWatermarks.get(key)) + } + } + + @Test + fun legacyStampedBlobWithoutConsentInstantRecoversFromOldestWatermark() { + // A dogfood install that ran the build with newOnlyStamped but not newOnlyConsentAt + // decodes to stamped = true, consentAt = null with every watermark already stamped: the + // sentinel never re-fires, so nothing would ever populate the consent instant and the + // read clamp would be a permanent no-op. Recover it from the oldest surviving watermark — + // the sentinel wrote one instant to all six groups, so the minimum is that instant. + val fake = prefsWith( + "{\"enabled\":true,\"backfillChoice\":\"EXPORT_NEW_ONLY\",\"newOnlyStamped\":true}", + "{\"vitals\":5000,\"sleep\":1000,\"activity\":3000,\"workouts\":null,\"nutrition\":2000,\"restingHr\":null}", + ) + val store = HealthConnectPrefsStore(fake) + assertEquals(1000L, store.current.newOnlyConsentAt) + // and it is on disk, so the recovery runs once rather than on every process start + assertEquals(1000L, HealthConnectPrefsStore(fake).current.newOnlyConsentAt) + // the clamp is live again: a revocation-dialog clearWatermarks() no longer means epoch + store.clearWatermarks() + assertEquals(1000L, effectiveWatermark(store.currentWatermarks.vitals, store.current.newOnlyConsentAt)) + } + + @Test + fun consentRecoveryLeavesEveryOtherBlobAlone() { + // EXPORT_ALL, not-yet-stamped and already-recorded blobs are untouched — the recovery is + // for exactly one on-disk shape. + val exportAll = HealthConnectPrefsStore( + prefsWith("{\"backfillChoice\":\"EXPORT_ALL\",\"newOnlyStamped\":true}", "{\"vitals\":1000}") + ) + assertNull(exportAll.current.newOnlyConsentAt) + + val notStamped = HealthConnectPrefsStore( + prefsWith("{\"backfillChoice\":\"EXPORT_NEW_ONLY\"}", "{\"vitals\":1000}") + ) + assertNull(notStamped.current.newOnlyConsentAt) + + val alreadyRecorded = HealthConnectPrefsStore( + prefsWith( + "{\"backfillChoice\":\"EXPORT_NEW_ONLY\",\"newOnlyStamped\":true,\"newOnlyConsentAt\":7000}", + "{\"vitals\":1000}", + ) + ) + assertEquals(7000L, alreadyRecorded.current.newOnlyConsentAt) // NOT lowered to the watermark + } + + @Test + fun consentRecoveryNoOpsWhenThereIsNothingToRecoverFrom() { + // All watermarks cleared (post-removal shape): nothing to reconstruct from, so the flag is + // left alone and the next sentinel pass re-stamps normally. + val store = HealthConnectPrefsStore( + prefsWith("{\"backfillChoice\":\"EXPORT_NEW_ONLY\",\"newOnlyStamped\":true}", null) + ) + assertNull(store.current.newOnlyConsentAt) + assertTrue(store.current.newOnlyStamped) + } +} diff --git a/app/src/test/java/com/pulseloop/health/HealthConnectTypeMappingsTest.kt b/app/src/test/java/com/pulseloop/health/HealthConnectTypeMappingsTest.kt new file mode 100644 index 0000000..e72f9af --- /dev/null +++ b/app/src/test/java/com/pulseloop/health/HealthConnectTypeMappingsTest.kt @@ -0,0 +1,369 @@ +package com.pulseloop.health + +import androidx.health.connect.client.records.SleepSessionRecord +import com.pulseloop.health.HealthConnectTypeMappings.HrSample +import com.pulseloop.health.HealthConnectTypeMappings.SleepDaySession +import com.pulseloop.health.HealthConnectTypeMappings.SleepStageSpan +import com.pulseloop.ring.SleepStage +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.ZoneOffset + +/** + * Phase 1 (docs/health-connect-integration.md): the clientRecordId scheme and the heart-rate + * series segmentation are the parts that must stay stable or the upsert silently duplicates — + * pinned here without a database or a HealthConnectClient (HealthConnectTypeMappings is pure on + * purpose). + */ +class HealthConnectTypeMappingsTest { + + private val utc = ZoneOffset.UTC + + // ── id builders ── + + @Test + fun vitalsRecordIdIsKindPlusEpochMillis() { + assertEquals("pl-m-spo2-1723700000123", HealthConnectTypeMappings.vitalsRecordId("spo2", 1_723_700_000_123L)) + assertEquals("pl-m-hr-0", HealthConnectTypeMappings.vitalsRecordId("hr", 0L)) + } + + @Test + fun hrBucketIdPlainForSingleSegmentSuffixedOtherwise() { + assertEquals("pl-hr-1723700000000", HealthConnectTypeMappings.hrRecordId(1_723_700_000_000L)) + assertEquals("pl-hr-1723700000000-0", HealthConnectTypeMappings.hrRecordId(1_723_700_000_000L, 0)) + assertEquals("pl-hr-1723700000000-3", HealthConnectTypeMappings.hrRecordId(1_723_700_000_000L, 3)) + } + + // ── hour bucketing ── + + @Test + fun hourStartOfTruncatesToLocalHour() { + // 2024-08-15T05:33:20.123Z + val base = 1_723_700_000_123L + assertEquals(1_723_698_000_000L, HealthConnectTypeMappings.hourStartOf(base, utc)) // 05:00:00Z + // +05:30 zone: local 11:03:20 → local hour 11:00 = 05:30:00Z + val zone = ZoneOffset.ofHoursMinutes(5, 30) + assertEquals(1_723_699_800_000L, HealthConnectTypeMappings.hourStartOf(base, zone)) + } + + @Test + fun zoneOffsetAtReflectsZone() { + assertEquals(ZoneOffset.ofHoursMinutes(5, 30), + HealthConnectTypeMappings.zoneOffsetAt(java.time.Instant.ofEpochMilli(1_723_700_000_123L), ZoneOffset.ofHoursMinutes(5, 30))) + } + + // ── plausibility guards (platform validation rejects out-of-range inserts) ── + + @Test + fun hrPlausibilityIsOneToThreeHundred() { + assertTrue(HealthConnectTypeMappings.isPlausibleHr(1.0)) + assertTrue(HealthConnectTypeMappings.isPlausibleHr(300.0)) + assertFalse(HealthConnectTypeMappings.isPlausibleHr(0.99)) + assertFalse(HealthConnectTypeMappings.isPlausibleHr(300.01)) + assertFalse(HealthConnectTypeMappings.isPlausibleHr(0.0)) // "not measured" + } + + @Test + fun spo2PlausibilityExcludesArtifacts() { + assertTrue(HealthConnectTypeMappings.isPlausibleSpO2(20.0)) + assertTrue(HealthConnectTypeMappings.isPlausibleSpO2(100.0)) + assertFalse(HealthConnectTypeMappings.isPlausibleSpO2(19.9)) + assertFalse(HealthConnectTypeMappings.isPlausibleSpO2(0.0)) // "not measured" + } + + @Test + fun hrvPlausibilityMatchesPlatformBounds() { + assertTrue(HealthConnectTypeMappings.isPlausibleHrvRmssd(1.0)) + assertTrue(HealthConnectTypeMappings.isPlausibleHrvRmssd(200.0)) + assertFalse(HealthConnectTypeMappings.isPlausibleHrvRmssd(0.5)) + assertFalse(HealthConnectTypeMappings.isPlausibleHrvRmssd(200.5)) + } + + @Test + fun temperaturePlausibilityIsCoreRange() { + assertTrue(HealthConnectTypeMappings.isPlausibleBodyTemperature(30.0)) + assertTrue(HealthConnectTypeMappings.isPlausibleBodyTemperature(42.0)) + assertFalse(HealthConnectTypeMappings.isPlausibleBodyTemperature(29.9)) + assertFalse(HealthConnectTypeMappings.isPlausibleBodyTemperature(42.1)) + } + + // ── heart-rate series segmentation (Gadgetbridge rules) ── + + @Test + fun emptyInputGivesNoSegments() { + assertTrue(HealthConnectTypeMappings.splitHrSegments(emptyList(), utc).isEmpty()) + } + + @Test + fun singleSampleIsOneSegment() { + val segments = HealthConnectTypeMappings.splitHrSegments(listOf(HrSample(1000L, 70L)), utc) + assertEquals(1, segments.size) + assertEquals(1, segments[0].size) + } + + @Test + fun gapUpToFifteenMinutesStaysOneSegment() { + // exactly 15:00 apart — the rule is STRICTLY longer + val segments = HealthConnectTypeMappings.splitHrSegments( + listOf(HrSample(0L, 70L), HrSample(15 * 60_000L, 75L)), utc) + assertEquals(1, segments.size) + } + + @Test + fun gapOverFifteenMinutesSplits() { + val segments = HealthConnectTypeMappings.splitHrSegments( + listOf(HrSample(0L, 70L), HrSample(15 * 60_000L + 1000L, 75L)), utc) + assertEquals(2, segments.size) + assertEquals(1, segments[0].size) + assertEquals(1, segments[1].size) + } + + @Test + fun localDateChangeSplitsEvenWithoutAGap() { + // 23:59:59.999 → 00:00:00.001: a 2 ms gap, but a new local day + val segments = HealthConnectTypeMappings.splitHrSegments( + listOf(HrSample(86_399_999L, 70L), HrSample(86_400_001L, 71L)), utc) + assertEquals(2, segments.size) + } + + @Test + fun thousandSampleCapSplits() { + // 1001 samples at 30 s spacing = 8.33 h — one local day, no >15 min gaps, so only the + // sample cap can split: [1000, 1] + val samples = (0 until 1001).map { HrSample(it * 30_000L + 30_000L, 70L + (it % 30)) } + val segments = HealthConnectTypeMappings.splitHrSegments(samples, utc) + assertEquals(2, segments.size) + assertEquals(1000, segments[0].size) + assertEquals(1, segments[1].size) + } + + @Test + fun unsortedInputIsSortedBeforeSegmenting() { + val segments = HealthConnectTypeMappings.splitHrSegments( + listOf(HrSample(20_000L, 70L), HrSample(10_000L, 65L), HrSample(30_000L, 72L)), utc) + assertEquals(1, segments.size) + assertEquals(listOf(10_000L, 20_000L, 30_000L), segments[0].map { it.timeMs }) + } + + // ── positive-duration rule ── + + @Test + fun singleSampleSegmentEndBumpedByOneSecond() { + assertEquals(1001L, HealthConnectTypeMappings.seriesEndMs(1000L, 1000L)) + } + + @Test + fun normalSpanUnchanged() { + assertEquals(200_000L, HealthConnectTypeMappings.seriesEndMs(100_000L, 200_000L)) + } + + // ── sleep (Phase 2) ── + + // A fixed waking day and the sessions it can hold: a 14:00–15:00 nap (60 min) and a + // 23:40–07:10 night (450 min). The day key itself is arbitrary — the id is the raw value. + private val day0 = 1_755_955_200_000L + private val napStart = day0 + 14L * 3_600_000L + private val nightStart = day0 + 23L * 3_600_000L + 40 * 60_000L + + // ── stage-type map (client constants, never raw ints) ── + + @Test + fun sleepStageTypeMapsAllFiveStages() { + assertEquals(SleepSessionRecord.STAGE_TYPE_DEEP, HealthConnectTypeMappings.sleepStageType(SleepStage.DEEP.name)) + assertEquals(SleepSessionRecord.STAGE_TYPE_LIGHT, HealthConnectTypeMappings.sleepStageType(SleepStage.LIGHT.name)) + assertEquals(SleepSessionRecord.STAGE_TYPE_REM, HealthConnectTypeMappings.sleepStageType(SleepStage.REM.name)) + assertEquals(SleepSessionRecord.STAGE_TYPE_AWAKE, HealthConnectTypeMappings.sleepStageType(SleepStage.AWAKE.name)) + assertEquals(SleepSessionRecord.STAGE_TYPE_UNKNOWN, HealthConnectTypeMappings.sleepStageType(SleepStage.UNKNOWN.name)) + // Pin the connect-client 1.1.0 constant values (javap + decompiled source) so a silent + // library change fails here, not in the field. + assertEquals(0, SleepSessionRecord.STAGE_TYPE_UNKNOWN) + assertEquals(1, SleepSessionRecord.STAGE_TYPE_AWAKE) + assertEquals(4, SleepSessionRecord.STAGE_TYPE_LIGHT) + assertEquals(5, SleepSessionRecord.STAGE_TYPE_DEEP) + assertEquals(6, SleepSessionRecord.STAGE_TYPE_REM) + } + + @Test + fun sleepStageTypeFallsBackToUnknownForUnrecognizedRaw() { + assertEquals(SleepSessionRecord.STAGE_TYPE_UNKNOWN, HealthConnectTypeMappings.sleepStageType("garbage")) + assertEquals(SleepSessionRecord.STAGE_TYPE_UNKNOWN, HealthConnectTypeMappings.sleepStageType("")) + // Case matters: stageRaw persists SleepStage.name, all uppercase. + assertEquals(SleepSessionRecord.STAGE_TYPE_UNKNOWN, HealthConnectTypeMappings.sleepStageType("deep")) + } + + // ── pl-sleep id builder (identity trap #1: never the block UUID, never the session UUID) ── + + @Test + fun sleepRecordIdIsPlainDayEpochForTheMainSession() { + assertEquals("pl-sleep-$day0", HealthConnectTypeMappings.sleepSessionRecordId(day0)) + } + + @Test + fun sleepRecordIdIsSuffixedForNonMainSessions() { + assertEquals("pl-sleep-$day0-1", HealthConnectTypeMappings.sleepSessionRecordId(day0, 1)) + assertEquals("pl-sleep-$day0-2", HealthConnectTypeMappings.sleepSessionRecordId(day0, 2)) + } + + @Test + fun sleepIdIsStableAcrossResyncsAndBlockUuidChurn() { + // The id is a pure function of the session's waking-day date: the same date yields the + // same id on every re-sync, no matter that upsertSleepSessionAtomic replaced the stage + // blocks with fresh random UUIDs — block ids never enter the input at all. + val firstPass = HealthConnectTypeMappings.sleepSessionRecordId(day0) + val resyncPass = HealthConnectTypeMappings.sleepSessionRecordId(day0) + assertEquals(firstPass, resyncPass) + assertTrue(firstPass.startsWith("pl-sleep-")) + assertTrue(firstPass.removePrefix("pl-sleep-").all { it.isDigit() }) + // Recomputed from the day's (re-segmented) session shape after the churn: still equal. + val day = listOf(SleepDaySession(nightStart, 450L)) + assertEquals(firstPass, HealthConnectTypeMappings.sleepSessionRecordId(day0, HealthConnectTypeMappings.sleepSessionSuffix(day, 0))) + } + + // ── multi-session waking days: main keeps the plain id, naps get deterministic suffixes ── + + @Test + fun mainSleepIsTheLongestSession() { + val day = listOf( + SleepDaySession(napStart, 60L), + SleepDaySession(nightStart, 450L), + ) + assertEquals(1, HealthConnectTypeMappings.mainSleepIndex(day)) + } + + @Test + fun mainSleepTieGoesToTheEarliestStart() { + val day = listOf( + SleepDaySession(nightStart, 300L), + SleepDaySession(napStart, 300L), + ) + assertEquals(1, HealthConnectTypeMappings.mainSleepIndex(day)) + } + + @Test + fun mainSleepIndexOfEmptyDayIsNull() { + assertNull(HealthConnectTypeMappings.mainSleepIndex(emptyList())) + } + + @Test + fun singleSessionDayKeepsThePlainId() { + val day = listOf(SleepDaySession(nightStart, 450L)) + assertNull(HealthConnectTypeMappings.sleepSessionSuffix(day, 0)) + assertEquals("pl-sleep-$day0", HealthConnectTypeMappings.sleepSessionRecordId(day0, HealthConnectTypeMappings.sleepSessionSuffix(day, 0))) + } + + @Test + fun nightKeepsPlainIdNapTakesSuffixedId() { + val day = listOf( + SleepDaySession(napStart, 60L), + SleepDaySession(nightStart, 450L), + ) + assertNull(HealthConnectTypeMappings.sleepSessionSuffix(day, 1)) // night is main + assertEquals(1, HealthConnectTypeMappings.sleepSessionSuffix(day, 0)) // nap + assertEquals("pl-sleep-$day0", HealthConnectTypeMappings.sleepSessionRecordId(day0, HealthConnectTypeMappings.sleepSessionSuffix(day, 1))) + assertEquals("pl-sleep-$day0-1", HealthConnectTypeMappings.sleepSessionRecordId(day0, HealthConnectTypeMappings.sleepSessionSuffix(day, 0))) + } + + @Test + fun twoNapsOnOneDayGetStableStartOrderedSuffixes() { + val nap2Start = day0 + 16L * 3_600_000L + val day = listOf( + SleepDaySession(nightStart, 450L), + SleepDaySession(nap2Start, 45L), + SleepDaySession(napStart, 60L), + ) + assertNull(HealthConnectTypeMappings.sleepSessionSuffix(day, 0)) // night is main + assertEquals(2, HealthConnectTypeMappings.sleepSessionSuffix(day, 1)) // 16:00 nap starts later + assertEquals(1, HealthConnectTypeMappings.sleepSessionSuffix(day, 2)) // 14:00 nap starts earlier + } + + // ── stage normalization: sort, clamp, drop overlaps, drop zero/negative ── + + // Normalization window: 09:00–13:00 (minutes relative to s0). + private val s0 = 9L * 3_600_000L + private val s1 = 13L * 3_600_000L + + private fun span(startMin: Long, endMin: Long, type: Int = SleepSessionRecord.STAGE_TYPE_LIGHT): SleepStageSpan = + SleepStageSpan(s0 + startMin * 60_000L, s0 + endMin * 60_000L, type) + + @Test + fun normalizeEmptyInputIsEmpty() { + assertTrue(HealthConnectTypeMappings.normalizeSleepStages(s0, s1, emptyList()).isEmpty()) + } + + @Test + fun normalizeRejectsNonPositiveSessionSpan() { + assertTrue(HealthConnectTypeMappings.normalizeSleepStages(s0, s0, listOf(span(0, 60))).isEmpty()) + assertTrue(HealthConnectTypeMappings.normalizeSleepStages(s1, s0, listOf(span(0, 60))).isEmpty()) + } + + @Test + fun normalizeSortsByStart() { + val out = HealthConnectTypeMappings.normalizeSleepStages(s0, s1, listOf(span(120, 180), span(0, 60), span(60, 120))) + assertEquals(listOf(0L, 60 * 60_000L, 120 * 60_000L), out.map { it.startMs - s0 }) + assertEquals(listOf(60 * 60_000L, 120 * 60_000L, 180 * 60_000L), out.map { it.endMs - s0 }) + } + + @Test + fun normalizeClampsToSessionBounds() { + val out = HealthConnectTypeMappings.normalizeSleepStages(s0, s1, listOf(span(-30, 30), span(200, 330))) + assertEquals(2, out.size) + assertEquals(s0, out[0].startMs) // clamped up to the session start + assertEquals(s0 + 30 * 60_000L, out[0].endMs) + assertEquals(s0 + 200 * 60_000L, out[1].startMs) + assertEquals(s1, out[1].endMs) // clamped down to the session end + } + + @Test + fun normalizeDropsOverlapsKeepingEarlierTruncatingLater() { + val out = HealthConnectTypeMappings.normalizeSleepStages( + s0, s1, + listOf(span(0, 120, SleepSessionRecord.STAGE_TYPE_DEEP), span(60, 240)), + ) + assertEquals(2, out.size) + assertEquals(SleepStageSpan(s0, s0 + 120 * 60_000L, SleepSessionRecord.STAGE_TYPE_DEEP), out[0]) + assertEquals(SleepStageSpan(s0 + 120 * 60_000L, s0 + 240 * 60_000L, SleepSessionRecord.STAGE_TYPE_LIGHT), out[1]) + } + + @Test + fun normalizeDropsStageFullyCoveredByAnEarlierOne() { + val out = HealthConnectTypeMappings.normalizeSleepStages(s0, s1, listOf(span(0, 240), span(60, 120))) + assertEquals(1, out.size) + assertEquals(SleepStageSpan(s0, s0 + 240 * 60_000L, SleepSessionRecord.STAGE_TYPE_LIGHT), out[0]) + } + + @Test + fun normalizeDropsLaterStageWhenEarlierReachesTheSessionEnd() { + val out = HealthConnectTypeMappings.normalizeSleepStages( + s0, s1, + listOf(span(0, 240, SleepSessionRecord.STAGE_TYPE_DEEP), span(230, 300)), + ) + assertEquals(1, out.size) + assertEquals(s1, out[0].endMs) + } + + @Test + fun normalizeAllowsTouchingStages() { + // The record's constructor validation rejects a stage ending AFTER the next stage's + // start; end == next start is legal, so touching blocks must survive. + val out = HealthConnectTypeMappings.normalizeSleepStages(s0, s1, listOf(span(0, 60), span(60, 120))) + assertEquals(2, out.size) + assertEquals(s0 + 60 * 60_000L, out[0].endMs) + assertEquals(s0 + 60 * 60_000L, out[1].startMs) + } + + @Test + fun normalizeDropsZeroAndNegativeLengthStages() { + val out = HealthConnectTypeMappings.normalizeSleepStages(s0, s1, listOf(span(60, 60), span(120, 90), span(0, 30))) + assertEquals(1, out.size) + assertEquals(SleepStageSpan(s0, s0 + 30 * 60_000L, SleepSessionRecord.STAGE_TYPE_LIGHT), out[0]) + } + + @Test + fun normalizeDropsStagesEntirelyOutsideTheSession() { + val out = HealthConnectTypeMappings.normalizeSleepStages(s0, s1, listOf(span(-120, -60), span(300, 360))) + assertTrue(out.isEmpty()) + } +} diff --git a/app/src/test/java/com/pulseloop/health/HealthConnectWorkoutMappingTest.kt b/app/src/test/java/com/pulseloop/health/HealthConnectWorkoutMappingTest.kt new file mode 100644 index 0000000..f7513d4 --- /dev/null +++ b/app/src/test/java/com/pulseloop/health/HealthConnectWorkoutMappingTest.kt @@ -0,0 +1,217 @@ +package com.pulseloop.health + +import androidx.health.connect.client.records.ExerciseSessionRecord +import com.pulseloop.health.HealthConnectTypeMappings.GpsRoutePoint +import com.pulseloop.health.HealthConnectTypeMappings.WorkoutSelection +import com.pulseloop.ui.components.ActivityMeta +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Phase 4 (docs/health-connect-integration.md): the workout identity scheme, the exercise-type + * map, the per-session export guards, route sanitisation and the 1 MB decimation math. All pure + * — no database, no HealthConnectClient (HealthConnectTypeMappings is pure on purpose). + */ +class HealthConnectWorkoutMappingTest { + + private val s0 = 1_700_000_000_000L // an arbitrary fixed start + + // ── id builders (plan §3 identity table) ── + + @Test + fun workoutRecordIdsFollowThePlanScheme() { + assertEquals("pl-wk-abc-123", HealthConnectTypeMappings.workoutRecordId("abc-123")) + assertEquals( + "pl-wk-abc-123-energy", HealthConnectTypeMappings.workoutChildRecordId("abc-123", HealthConnectTypeMappings.WK_ENERGY), + ) + assertEquals( + "pl-wk-abc-123-dist", HealthConnectTypeMappings.workoutChildRecordId("abc-123", HealthConnectTypeMappings.WK_DIST), + ) + } + + // ── exercise type map (plan Phase 4) ── + + @Test + fun exerciseTypeMapsEveryPulseLoopType() { + assertEquals(ExerciseSessionRecord.EXERCISE_TYPE_WALKING, HealthConnectTypeMappings.exerciseType("walk")) + assertEquals(ExerciseSessionRecord.EXERCISE_TYPE_RUNNING, HealthConnectTypeMappings.exerciseType("run")) + assertEquals(ExerciseSessionRecord.EXERCISE_TYPE_BIKING, HealthConnectTypeMappings.exerciseType("cycle")) + assertEquals(ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING, HealthConnectTypeMappings.exerciseType("gym")) + assertEquals(ExerciseSessionRecord.EXERCISE_TYPE_SQUASH, HealthConnectTypeMappings.exerciseType("squash")) + assertEquals(ExerciseSessionRecord.EXERCISE_TYPE_YOGA, HealthConnectTypeMappings.exerciseType("yoga")) + assertEquals(ExerciseSessionRecord.EXERCISE_TYPE_DANCING, HealthConnectTypeMappings.exerciseType("dance")) + assertEquals(ExerciseSessionRecord.EXERCISE_TYPE_HIKING, HealthConnectTypeMappings.exerciseType("hike")) + assertEquals(ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT, HealthConnectTypeMappings.exerciseType("sport")) + } + + @Test + fun recordTitleUsesTheActivityMetaLabel() { + // The exporter sets ExerciseSessionRecord.title = ActivityMeta.label(type) — pin the + // titles the user will see in Health Connect for every type the map covers. + assertEquals("Walking", ActivityMeta.label("walk")) + assertEquals("Running", ActivityMeta.label("run")) + assertEquals("Cycling", ActivityMeta.label("cycle")) + assertEquals("Gym", ActivityMeta.label("gym")) + assertEquals("Squash", ActivityMeta.label("squash")) + assertEquals("Yoga", ActivityMeta.label("yoga")) + assertEquals("Dance", ActivityMeta.label("dance")) + assertEquals("Hiking", ActivityMeta.label("hike")) + assertEquals("Sport", ActivityMeta.label("sport")) + assertEquals("Other", ActivityMeta.label("other")) + } + + @Test + fun exerciseTypeFallsBackToOtherWorkout() { + assertEquals(ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT, HealthConnectTypeMappings.exerciseType("other")) + assertEquals(ExerciseSessionRecord.EXERCISE_TYPE_OTHER_WORKOUT, HealthConnectTypeMappings.exerciseType("something-new")) + } + + // ── per-session guards (plan Phase 4: endedAt > startedAt, not future) ── + + @Test + fun selectWorkoutSessionAcceptsAFinishedSession() { + assertEquals(WorkoutSelection.EXPORT, HealthConnectTypeMappings.selectWorkoutSession(s0, s0 + 60_000L, s0 + 3_600_000L)) + // endedAt == now is not future. + assertEquals(WorkoutSelection.EXPORT, HealthConnectTypeMappings.selectWorkoutSession(s0, s0 + 60_000L, s0 + 60_000L)) + } + + @Test + fun selectWorkoutSessionRejectsZeroAndNegativeDurations() { + assertEquals(WorkoutSelection.INVALID, HealthConnectTypeMappings.selectWorkoutSession(s0, s0, s0 + 60_000L)) + assertEquals(WorkoutSelection.INVALID, HealthConnectTypeMappings.selectWorkoutSession(s0 + 60_000L, s0, s0 + 120_000L)) + assertEquals(WorkoutSelection.INVALID, HealthConnectTypeMappings.selectWorkoutSession(s0, null, s0 + 60_000L)) + } + + @Test + fun selectWorkoutSessionFlagsFutureDatedEnds() { + assertEquals(WorkoutSelection.FUTURE, HealthConnectTypeMappings.selectWorkoutSession(s0, s0 + 60_000L, s0 + 59_999L)) + assertEquals(WorkoutSelection.FUTURE, HealthConnectTypeMappings.selectWorkoutSession(s0, s0 + 3_600_000L, s0 + 60_000L)) + } + + // ── route sanitisation (plan Phase 4; Gadgetbridge buildSanitisedRoute) ── + + private fun point(timeMs: Long, lat: Double = 37.0, lon: Double = -122.0) = + GpsRoutePoint(timeMs = timeMs, latitude = lat, longitude = lon) + + @Test + fun routeSanitisationKeepsTheWindowInclusive() { + val out = HealthConnectTypeMappings.sanitizeRoutePoints(s0, s0 + 100_000L, listOf( + point(s0 - 1), // before the window + point(s0), // boundary — kept + point(s0 + 50_000L), + point(s0 + 100_000L), // boundary — kept + point(s0 + 100_001L), // after the window + )) + assertEquals(listOf(s0, s0 + 50_000L, s0 + 100_000L), out.map { it.timeMs }) + } + + @Test + fun routeSanitisationDropsNonFiniteAndOutOfRangeCoordinates() { + val out = HealthConnectTypeMappings.sanitizeRoutePoints(s0, s0 + 100_000L, listOf( + point(s0, Double.NaN, -122.0), + point(s0 + 1_000L, 37.0, Double.POSITIVE_INFINITY), + point(s0 + 2_000L, 90.5, -122.0), + point(s0 + 3_000L, -90.1, -122.0), + point(s0 + 4_000L, 37.0, 180.5), + point(s0 + 5_000L, 37.0, -181.0), + point(s0 + 6_000L, 90.0, 180.0), // exact bounds — kept + point(s0 + 7_000L, -90.0, -180.0), // exact bounds — kept + )) + assertEquals(listOf(s0 + 6_000L, s0 + 7_000L), out.map { it.timeMs }) + } + + @Test + fun routeSanitisationDropsDuplicateTimestampsKeepingTheFirst() { + val out = HealthConnectTypeMappings.sanitizeRoutePoints(s0, s0 + 100_000L, listOf( + point(s0 + 10_000L, lat = 1.0), + point(s0 + 10_000L, lat = 2.0), // same timestamp — dropped + point(s0 + 20_000L, lat = 3.0), + point(s0 + 20_000L, lat = 4.0), // same timestamp — dropped + )) + assertEquals(listOf(1.0, 3.0), out.map { it.latitude }) + } + + @Test + fun routeSanitisationSortsUnsortedInput() { + val out = HealthConnectTypeMappings.sanitizeRoutePoints(s0, s0 + 100_000L, listOf( + point(s0 + 30_000L), point(s0 + 10_000L), point(s0 + 20_000L), + )) + assertEquals(listOf(s0 + 10_000L, s0 + 20_000L, s0 + 30_000L), out.map { it.timeMs }) + } + + @Test + fun routeSanitisationRejectsAnInvertedWindow() { + assertTrue(HealthConnectTypeMappings.sanitizeRoutePoints(s0 + 100, s0, listOf(point(s0 + 50))).isEmpty()) + } + + @Test + fun routeSanitisationCanEmptyARoute() { + // One clean point is not a route (≥ 2 required) — the exporter treats that as "no route". + val out = HealthConnectTypeMappings.sanitizeRoutePoints(s0, s0 + 100_000L, listOf(point(s0 + 10_000L))) + assertEquals(1, out.size) + // ...and everything-but-one dropped: + assertTrue( + HealthConnectTypeMappings.sanitizeRoutePoints(s0, s0 + 100_000L, + listOf(point(s0 + 10_000L), point(s0 - 1), point(s0 + 10_000L))).size == 1, + ) + } + + // ── 1 MB single-record limit (plan §3 robustness constants) ── + + @Test + fun recordSizeLimitParsesThePlatformMessage() { + // Gadgetbridge's production format. + assertEquals(1_000_000L to 1_700_644L, + HealthConnectTypeMappings.parseRecordSizeLimit( + "Failed to insert records: single record size limit: 1000000, was: 1700644", + )) + assertEquals(1_000_000L to 2_000_000L, + HealthConnectTypeMappings.parseRecordSizeLimit("single record size limit: 1000000, was: 2000000")) + assertNull(HealthConnectTypeMappings.parseRecordSizeLimit("some other insert error")) + assertNull(HealthConnectTypeMappings.parseRecordSizeLimit(null)) + // was <= limit is not an oversize — nothing to shrink. + assertNull(HealthConnectTypeMappings.parseRecordSizeLimit("single record size limit: 1000000, was: 999999")) + assertNull(HealthConnectTypeMappings.parseRecordSizeLimit("single record size limit: 1000000, was: 1000000")) + } + + @Test + fun decimationPreservesFirstAndLast() { + val points = (0 until 100).map { it.toLong() } + val out = HealthConnectTypeMappings.decimateToSize(points, 10) + assertEquals(10, out.size) + assertEquals(0L, out.first()) + assertEquals(99L, out.last()) + // Strictly increasing — no duplicated timestamp, which HC would reject. + assertTrue(out.zipWithNext { a, b -> a < b }.all { it }) + } + + @Test + fun decimationIsNoOpAtOrBelowTarget() { + val points = listOf(1L, 2L, 3L) + assertEquals(points, HealthConnectTypeMappings.decimateToSize(points, 3)) + assertEquals(points, HealthConnectTypeMappings.decimateToSize(points, 5)) + } + + @Test + fun decimationClampsToTwoPoints() { + val points = (0 until 50).map { it.toLong() } + val out = HealthConnectTypeMappings.decimateToSize(points, 1) // target < 2 clamps to 2 + assertEquals(listOf(0L, 49L), out) + } + + @Test + fun decimationNeverDuplicatesTheLastIndex() { + // The coerceAtMost(lastIndex - 1) clamp exists for exactly this: a stride that would + // land on (or past) the last index must not add the last point twice. + val points = (0 until 20).map { it.toLong() } + for (target in 2..19) { + val out = HealthConnectTypeMappings.decimateToSize(points, target) + assertEquals(target, out.size) + assertTrue("target=$target produced duplicates", out.distinct().size == out.size) + assertEquals(points.last(), out.last()) + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index e500a43..2b8fcb6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - id("com.android.application") version "8.7.0" apply false + id("com.android.application") version "8.9.1" apply false id("org.jetbrains.kotlin.android") version "2.0.21" apply false id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false diff --git a/docs/health-connect-integration.md b/docs/health-connect-integration.md index e1af497..88ddc01 100644 --- a/docs/health-connect-integration.md +++ b/docs/health-connect-integration.md @@ -1,7 +1,17 @@ # Health Connect integration — design and implementation plan -Status: **not started.** This document exists so a future session can pick the work up cold. -Implementation begins at Phase 0. +Status: **Phases 0–6 complete** (2026-08-17) — vitals, sleep, daily activity, workouts ++ GPS route, and the beyond-iOS set (blood pressure, blood glucose, respiratory rate, +VO₂max, resting HR, nutrition) all export; 16 `WRITE_*` permissions (0 `READ_*`). Phase 6 +adds "remove PulseLoop data" (owner-scoped per-type time-range deletion), grant/revocation +watermark resets (uniform grow-reset over the 6 watermark groups), the meal `updatedAt` +migration (v20→v21), and the archive-restore watermark stamp. Runtime-verified on +`emulator-5554` (API 35), including a foreign-canary removal proof (our records deleted, a +foreign app's record untouched). Both final gates passed: the release R8 smoke (the +minified build ran the Health Connect client with no class-stripping crash) and the API 30 +no-Health-Connect-image graceful-degradation check (provider-less API 30 AVD: no crash, the +Health Connect screen shows the actionable "update/install" state). +This document's §8 session log is the running record — read the newest entry first when resuming. ## Context @@ -230,7 +240,7 @@ Ported directly from `/PulseLoop/Health/HealthKitTypeMappings.swift:100-139 ``` pl-hr- HeartRateRecord series bucket version = max(createdAt) in bucket pl-m-- instantaneous vitals version = 1 -pl-sleep- SleepSessionRecord version = session.updatedAt +pl-sleep-[-] SleepSessionRecord version = session.updatedAt pl-act-- daily aggregates version = row.updatedAt pl-wk- ExerciseSessionRecord version = session.updatedAt pl-wk-- workout child records version = session.updatedAt @@ -284,7 +294,9 @@ trailer. ### Phase 0 — Foundation (no data written yet) - `app/build.gradle.kts`: `implementation("androidx.health.connect:connect-client:1.1.0")`. No version - catalog in this repo — add the coordinate as a literal alongside the others. Add + catalog in this repo — add the coordinate as a literal alongside the others. **Toolchain note + (2026-08-15):** 1.1.0 is the only stable and its AAR requires compileSdk 36 + AGP 8.9.1 (Gradle + 8.11.1+), which forced a toolchain bump — see §8. Add `` **only if** the manifest merger complains (`minSdk = 26` should already satisfy it; Gadgetbridge needs it because it's on 23). - `AndroidManifest.xml`: the `WRITE_*` permissions for Phases 1–4 only (add Phase 5's when Phase 5 @@ -354,11 +366,37 @@ per day, spanning `startOfDay … min(endOfDay, now)`. Port iOS's `workoutNettin distance only for walk/run-type sessions, so Health Connect consumers don't double-count against Phase 4's records. +**Two amendments made when this shipped** (see §8, 2026-08-16): + +- **Distance netting must NOT keep iOS's walk/run filter.** iOS needs it because HealthKit splits + distance across `.distanceWalkingRunning` / `.distanceCycling`. Health Connect has a single + `DistanceRecord` that every workout's distance lands in, and `ActivityRollup.credit` folds in + every `useGps` session regardless of type — so the netting set is `useGps` sessions of any type. + Keeping the filter would double-count a GPS ride. +- **Netting stays switched off until Phase 4 exists.** iOS gates netting on `exportWorkouts` alone + because its workout exporter already ships; subtracting here before `WorkoutExporter` exists + would under-report every day containing a workout with nothing writing the difference back, and + a write-only export cannot repair it. `HealthConnectExporter.WORKOUTS_EXPORTED` is the single + flag Phase 4 flips, and netting also requires `WRITE_EXERCISE` to be granted (the toggle can be + on while the permission is denied). + **Verify:** a day with a recorded workout shows daily totals *plus* the workout, without the workout's -calories appearing twice. +calories appearing twice. (Only fully checkable once Phase 4 lands; Phase 3 verified the netting +arithmetic on-device with the flag forced on, then verified the shipped un-netted behavior.) ### Phase 4 — Workouts + GPS route +**Inherited from Phase 3 — do these in this phase:** flip +`HealthConnectExporter.WORKOUTS_EXPORTED` to `true` in the same commit that adds the exporter; and +decide the stale-record question, which only becomes live once netting is on: when a day's netted +leftover falls to ≤ 0 the record is *dropped*, so any previously exported un-netted record for that +day stays in Health Connect un-overwritten (write-only: it cannot be deleted). Writing a floor-value +record would overwrite it, at the cost of asserting a zero the write-data guide says to omit. Two +known imperfections in "netted set == credited set" also want resolving here: +`applyActivityBucketAtomic` overwrites a past day's `distanceMeters` with the ring's bucket sum +(discarding credited GPS metres, so netting would over-subtract after a history re-sync), and +`ActivityRollup.credit` skips sub-minute sessions that netting would still subtract. + `health/exporters/WorkoutExporter.kt` — `ExerciseSessionRecord` with the type map, `title` from `ActivityMeta.label`, embedded `ExerciseRoute` from accepted `ActivityGpsPointEntity` rows, plus sibling energy/distance records. Route sanitisation: drop points outside the session window, @@ -381,7 +419,18 @@ manifest permissions in this phase, not earlier. ### Phase 6 — Lifecycle, removal, docs - **"Remove PulseLoop data from Health Connect"** — `deleteRecords` by record type over our own - records, then clear all watermarks. Mirrors iOS's `removeAllExportedData`. + records, then clear all watermarks, turn the export **OFF**, and reset the backfill choice to + `NOT_ASKED` + the `newOnlyStamped` first-enable marker. Mirrors iOS's `removeAllExportedData` + in spirit but deliberately stricter: leaving it enabled with `backfillChoice=EXPORT_ALL` would let + the very next background trigger (ring sync, app-start grow, settings open) re-export the whole + history within ~15 s, silently undoing the destructive removal; re-enabling re-offers the + backfill dialog so the user picks fresh. +- **Newly-granted permissions need a watermark reset, not just revoked ones.** Each group has one + watermark but several independently grantable record types (activity covers steps, active + calories and distance). Grant only `WRITE_STEPS` and the activity watermark advances past every + historical day; granting `WRITE_ACTIVE_CALORIES_BURNED` later never backfills them, because the + DAO selects on `updatedAt > watermark`. Diff the granted set in both directions and reset the + affected group's watermark when it *grows*. (Phase 1's vitals group has the same shape.) - **Revocation detection** — store the last-granted permission set; on app start and on settings-screen open, diff against `permissionController.getGrantedPermissions()`. If everything was revoked, offer to reset the watermarks so a later re-grant re-exports (Gadgetbridge's @@ -462,3 +511,529 @@ around it. - **Background persistence gap** (pre-existing, noted above): `EventPersistenceSubscriber` lives in the composable while `RingSyncWorker` runs backgrounded. Not this plan's problem, but it bounds how much data a background-only user actually accumulates to export. + +--- + +## 8. Session log + +Append a dated entry here as work progresses (persistent memory via `mcp_memory` is the parallel +record). Status: **Phases 0–6 complete** on `feat/health-connect-foundation` (pre-merge) — see the newest entry below and the top STATUS line. + +- **2026-08-14 — prep, no code.** Plan read and re-verified against the live official docs. + - Official Google Health Connect guide indexed into the `mcp_docs` server as library + `android-health-connect` (scrape job `09c779f9-e338-44ef-90f5-bed6bca31bd2`, from + `developer.android.com/health-and-fitness/health-connect?hl=en`). Gotcha: URLs **without** + `?hl=en` fall into a redirect loop for the crawler. + - Version pin confirmed current: `androidx.health.connect:connect-client:1.1.0` is the latest + **stable** (1.2.0-alpha05 is the newest alpha). The official write-data guide targets the + 1.1.0 series, so the guide is a faithful reference for the pinned version. + - Task context and the working agreement (verify against official docs via the MCP servers; + log progress here *and* in `mcp_memory`) saved to persistent memory. +- **2026-08-15 — Phase 0 implemented** (branch `feat/health-connect-foundation`). + - **Toolchain bump (user-approved "bump the toolchain"):** `connect-client:1.1.0` is the only + stable, and its AAR metadata requires compileSdk 36 + AGP 8.9.1 (verified for 1.1.0, rc01–rc03, + beta02; only 1.1.0-beta01 and 1.1.0-alpha12 fit SDK 35 / AGP 8.7). Bumped: AGP 8.7.0 → 8.9.1 + (`android/build.gradle.kts`), Gradle wrapper 8.9 → 8.11.1, compileSdk 35 → 36 (**targetSdk stays + 35**), `platforms;android-36` installed locally. **CI implication:** the PulseLoopAndroid release + pipeline now needs platform-36 (AGP auto-download requires accepted SDK licenses in CI). + - Shipped: the 1.1.0 dependency; manifest (exactly the ten Phase 1–4 `WRITE_*` permissions — no + `READ_*`, no Phase 5 types; `` for `com.google.android.apps.healthdata` + the rationale + action; `HealthConnectRationaleActivity` and the API 34+ `ViewPermissionUsageActivity` alias + guarded by `START_VIEW_PERMISSION_USAGE`); the `health/` package (`HealthConnectSdk` three-state + `getSdkStatus` wrapper, `HealthConnectPermissions` derived via `HealthPermission.getWritePermission` + — the route uses `PERMISSION_WRITE_EXERCISE_ROUTE` since `ExerciseRoute` is an embedded type, + `HealthConnectPrefsStore` in the MetricPrefsStore pattern with a **separate watermark key** and + monotonic `setWatermark`, `HealthConnectRationaleActivity` — in-app rationale, no hosted URL); + `HealthConnectSettingsScreen` (master toggle → permission sheet, partial grants first-class, + per-type toggles, availability install/update rows with Play deep link, first-enable backfill + dialog, last-sync row, HRV RMSSD caveat), the Settings row, and the `settings/health-connect` + route. + - API gotchas found by inspecting the 1.1.0 jar (`javap`), beyond the docs: `PermissionController` + is top-level `androidx.health.connect.client.PermissionController` (NOT in `.permission`), and + `createRequestPermissionResultContract()` is `ActivityResultContract, Set>` — + the launcher input is a **`Set`**, so `launch(HealthConnectPermissions.all)` (no `.toTypedArray()`). + - Verified: `compileDebugKotlin`, `testDebugUnitTest` (15 new tests; full suite **890 green**), + `assembleDebug`, and the **merged debug manifest** (exactly ten WRITE permissions, zero READ, + queries + both activities present, no `overrideLibrary` needed at minSdk 26). + - **Runtime verification (API 35) DONE on the user's `pulseloop_test` AVD** (Pixel 7, arm64-v8a, + `android-35/google_apis`, headless; UI driven with `uiautomator dump` + `input tap`). On + Android 15 Health Connect ships as the `com.android.healthfitness` **APEX** with + `com.google.android.healthconnect.controller` as the provider/permission-flow package — there is + **no** `com.google.android.apps.healthdata` app on this image (so "connected apps list" was + verified at the OS level instead), and the 1.1.0 client's `getSdkStatus` correctly reports the + provider **available**. Verified end-to-end: + 1. Settings → Health Connect row and screen render in the AVAILABLE state (no install prompt). + 2. Master toggle launches the official HC permission sheet ("Allow PulseLoop Debug to access + Health Connect?"), privacy-policy link present. + 3. Tapping the privacy link fired the system `VIEW_PERMISSION_USAGE` intent, which routed to + **our `ViewPermissionUsageActivity` alias** (the alias also shows up in the package + manager's resolution for that action); the rationale screen rendered with title + body. + 4. "Allow" returned to our screen and showed the first-enable backfill dialog (chose + "Sync all history"); screen then read **"Connected. 10 of 10 permission types granted."** + 5. All eight per-type rows + switches present (default ON), HRV RMSSD caveat visible, + "No export has run yet — the export engine lands in the next phase." + 6. State survived force-stop + relaunch; the persisted prefs blob was exactly + `pulseloop.healthconnect.v1` with `enabled=true`, all toggles true, `backfillChoice= + EXPORT_ALL`, and all ten `lastGrantedPermissions`. + 7. `dumpsys package` shows all ten `android.permission.health.WRITE_*` as + `granted=true, USER_SET` (plus the library's own `FOREGROUND_SERVICE_HEALTH` from the AAR). + - **Still pending:** the API 30 graceful-degradation check (no-Health-Connect-APK image) — needs a + second AVD/image; and the CI platform-36 confirmation on first push. + - Note: the subagent runtime (worker/observer pattern) was broken this session — even a trivial + "reply ok" prompt failed with `subagent run failed` — so Phase 0 ran in the main session with a + self-review pass against the plan, the official docs, and Gadgetbridge instead. +- **2026-08-15 — Phase 1 implemented (vitals export engine + worker)** (branch + `feat/health-connect-foundation`). + - Shipped: `health/HealthConnectTypeMappings.kt` (pure, unit-testable: the `pl-hr-` + / `pl-hr--` and `pl-m--` clientRecordId scheme ported from iOS + `HealthKitTypeMappings.swift`; local-hour bucketing; Gadgetbridge HR segmentation — split on + local-date change, >15 min gap, or 1000 samples; plausibility guards matching platform insert + validation; `demo`/`mock` source exclusion); `health/HealthConnectExporter.kt` (the pass + orchestrator + `healthConnectInsertChunked`: 200 records/call, 5 retries, 1/2/4/8/16 s backoff, + SecurityException aborts immediately, watermark = max high-water of the last *successful* chunk); + `health/exporters/VitalsExporter.kt` (HR as per-hour series of `HeartRateRecord`s with per-segment + clientRecordId and version = max `createdAt` in the hour; SpO₂, HRV-as-RMSSD, and body + temperature as one instantaneous record per row, version 1); `health/HealthConnectExportWorker.kt` + (debounced OneTimeWorkRequest, `REPLACE`; hard gate on `NOT_ASKED`; live per-kind re-check of the + granted set on every pass; group watermark = min of per-kind highs); DAO `createdSince` / + `rangeReal` queries; triggers on ring-sync done, background-sync done, **first-enable grant**, + and **backfill-dialog answer** (the last two so a user who enables the export with history + already in the DB gets an export without waiting for the next ring sync). + - 1.1.0 API gotchas found by `javap` (beyond Phase 0's): the client factory is the synchronous + companion `HealthConnectClient.getOrCreate(Context)` — there is **no suspend `get`** in stable + 1.1.0 (Gadgetbridge's 1.1.0 provider matches); `PermissionController` is an **interface** + obtained as `client.permissionController` (no `(client)` constructor); `Metadata`'s factories + require a **non-null** `Device`, so with no real ring paired the export is attributed to an app + device (`TYPE_PHONE`/"PulseLoop"/"app") — the Android form of iOS' "attributed to the app only"; + the HRV record class is `HeartRateVariabilityRmssdRecord` (not `HeartRateVariabilityRecord` — + the plan's name is wrong; platform bounds 1.0–200.0 ms match `isPlausibleHrvRmssd`). + - Verified: `testDebugUnitTest` — 25 new tests (17 type-mappings: id formats, hour bucketing in + UTC and +05:30, all four plausibility bound pairs, segmentation edge cases including the exact + 15:00 gap, the 1000-sample cap, and unsorted input; 8 chunk/retry: chunk sizes, the 400-boundary, + backoff timing under virtual time, SecurityException no-retry, retry exhaustion, watermark + stopping at the last successful chunk) — full suite **915 green**; `installDebug`. + - **Runtime verification (API 35) DONE on `pulseloop_test`** by injecting known rows through + `run-as … sqlite3 pulseloop.db` (5 live HR rows in one local hour — 3 samples + a 38-min gap + + 2 samples, so exactly two segments; 1 **demo** HR row; SpO₂ 97 %; HRV 42 ms; temp 36.6 °C), then + driving the real worker (grant-result trigger; 15 s debounce) and reading `logcat -s + HealthConnectExport` + the persisted prefs: + 1. Pass 1 (backfill, `EXPORT_ALL`): `pass done: exported hr 2, spo2 1, hrv 1, temp 1` — the two + HR series records carry the suffixed segment ids, the demo row was excluded, and the + platform accepted every insert (HC validates value/units on insert). + 2. Pass 2 (immediate re-run): `pass done: nothing new to export` — the vitals watermark + advanced to the injected rows' `createdAt`, so no rework. + 3. Pass 3 (watermark reset to null in the prefs file, app restarted): the **same five** records + re-exported — identical deterministic clientRecordIds re-inserted without error, i.e. the + upsert identity holds for the write-only path. + 4. Group watermark observed as `min(per-kind highs)` in the persisted blob after each pass. + - Bug found and fixed during verification: the worker wrote `lastSyncSummary` but not + `lastSyncAt`, so the settings "Last sync" card could never update; now both are stamped and the + card renders (`Last sync: nothing new to export` observed on-screen). + - Verification limitation (honest report): this image has no Health Connect data-browser app + (no Play Store), so "record count unchanged on re-run" was verified as identical-id re-upserts + accepted by the platform (Pass 3) plus the logcat counts, not by visually inspecting the HC + store. The API 30 no-HC graceful-degradation check and the CI platform-36 confirmation remain + pending (see Phase 0 entry). + - Subagents still broken (`subagent run failed` on the trivial probe) — Phase 1 also ran solo with + the self-review pass; the `read_image` capability declaration for this model still rejects image + input, so UI verification stayed on `uiautomator dump` + coordinate taps. +- **2026-08-16 — Phase 2 implemented (sleep export)** (branch `feat/health-connect-foundation`). + - Shipped: `health/exporters/SleepExporter.kt` (one `SleepSessionRecord` per `SleepSessionEntity`, + stages from that session's `SleepStageBlockEntity` rows; stage map DEEP/LIGHT/REM/AWAKE/UNKNOWN + → the client's `SleepSessionRecord.STAGE_TYPE_*` constants — never raw ints; normalization: + sort by start, clamp to session bounds, drop overlaps keeping the earlier, drop + zero/negative-length stages; sessions with no valid stages are skipped and counted, with a + later re-sync re-selecting them via `updatedAt`); `HealthConnectTypeMappings` gains the pure + sleep helpers (`sleepSessionRecordId`, `mainSleepIndex`/`sleepSessionSuffix`, `sleepStageType`, + `normalizeSleepStages`); `HealthConnectExporter.run()` gains the sleep group pass (its own + `SLEEP` watermark, per-kind toggle + live permission gate, same chunk/retry and + advance-only-to-landed watermark semantics); `SleepSessionDao.updatedSince(watermark)` — + `updatedAt > :watermark AND sourceRaw NOT IN ('demo','mock')` (mirrors Phase 1's + `createdSince`). No manifest change: `WRITE_SLEEP` was already declared in Phase 0 and + `HealthConnectPermissions.sleep` already derived it via + `HealthPermission.getWritePermission(SleepSessionRecord::class)`. + - **Identity decision (deviation from the plan's letter, in its spirit):** the plan says key on + the session's `date` because it is "uniquely indexed and stable" — but `date` is the waking + day's local midnight and is **not unique**: `reconcileWakingDay`/`SleepSegmentation` can hold a + main night plus a daytime nap on one waking day (see `SleepSessionDao.byDay`'s "a day can now + hold several sessions"). Two sessions sharing one `clientRecordId` would silently replace each + other in Health Connect. Resolution: the day's **main** session (longest, ties to earliest + start — the same main sleep `byDay()` surfaces) keeps the plain `pl-sleep-`; + additional sessions take deterministic suffixes `pl-sleep--` (1-based, startAt + order among non-main). Single-session days — the common case — are exactly the plan's id. + Accepted edge (mirrors the HR hour-split edge): if a nap later joins/leaves the day, suffixed + ids shift and one superseded record remains in Health Connect (write-only: cannot delete it; + its content is re-exported under the new ids on the same pass). + - API verified against the 1.1.0 jar (`javap`) + decompiled source: constructor is + `(Instant startTime, ZoneOffset startZoneOffset, Instant endTime, ZoneOffset endZoneOffset, + Metadata, String title, String notes, List)`, `Stage(Instant, Instant, int)`; the + constructor enforces sorted, non-overlapping (touching allowed) stages inside the session + bounds — `normalizeSleepStages` is written to satisfy exactly that. Stage constants pinned in + tests: UNKNOWN=0, AWAKE=1, LIGHT=4, DEEP=5, REM=6. `clientRecordVersion` = + `session.updatedAt` (plan identity table) — a re-synced, fuller night always wins the upsert. + The official-docs MCP index only holds the Health Connect landing page, so upsert semantics + rest on plan §3 + Phase 1's runtime proof; Gadgetbridge's `SleepSyncer` (frozen-id + + grow-in-place, `Metadata.autoRecorded`) informed the shape. + - Verified: `testDebugUnitTest` — 24 new tests (all five stage mappings + pinned constant + values + unrecognized-raw fallback; plain/suffixed id formats; id stability across re-sync and + block-UUID churn; main-sleep selection incl. ties and empty day; single-session / nap / + two-nap suffix determinism; normalization: sort, clamp, overlap-truncate, fully-covered drop, + reach-session-end drop, touching allowed, zero/negative drop, out-of-bounds drop, + non-positive session span; sleep watermark = max landed session `updatedAt`, non-monotonic + input, partial-failure stop; `SLEEP` watermark monotonicity) — full suite **939 green**; + `assembleDebug`. + - **Runtime verification (API 35) DONE on `pulseloop_test`** (fresh install this session — + onboarding completed with "Explore without ring", BT permission allowed, HC permission sheet + "Allow all", backfill "Sync all history"). This image has **no HC data-browser app**, so + records were confirmed **directly in the provider's store** (`adb root` → + `sqlite3 /data/system_ce/0/healthconnect/healthconnect.db` — `sleep_session_record_table` + + `sleep_stages_table`), a stronger check than Phase 1's acceptance-based one. Injected two + `sourceRaw='ring'` nights via `run-as … sqlite3`: Night A 23:40→07:10 (450 min, 10 blocks + covering LIGHT/DEEP/REM/AWAKE) with waking-day `date` = today's local midnight, and older + Night B 23:40→06:30 (410 min, 5 blocks). Emulator TZ is America/Los_Angeles (PDT, offset + −25200 s) — the injected `date`/`startAt`/`endAt` are true local-midnight/local instants. + 1. Pass 1: `pass done: exported sleep 2` — store shows exactly + `pl-sleep-` and `pl-sleep-` with the correct spans, 15 stages total with the + right types (4/5/6/1), versions = each session's `updatedAt`, zone offsets −25200. + 2. Pass 2 (toggle off→on re-run): `pass done: nothing new to export` — counts unchanged + (2 records / 15 stages). + 3. Growth (Night A re-sync: last LIGHT block 25→55 min, session end 07:10→07:40, + `updatedAt` bumped): `pass done: exported sleep 1` — the SAME `pl-sleep-` record + updated in place (version advanced, end extended to 07:40, last stage extended), record + count still 2 — no orphan second session. Sleep watermark advanced to exactly the landed + `updatedAt`. + 4. Block-UUID churn (Night B: 5 blocks deleted, re-inserted with fresh ids, same times, + `updatedAt` bumped): `pass done: exported sleep 1` — the SAME `pl-sleep-` record + re-upserted in place, stages byte-identical, record count still 2 — no duplicates from the + churned block UUIDs. + 5. Bonus observation (mid-4, when Night B's blocks were deleted before the re-insert landed): + the pass reported `skipped: sleep: 1 session(s) without valid stages` and left the + watermark untouched — the empty-stages guard + re-selection path working as designed. + 6. Final no-op pass: `nothing new to export`. Watermarks persisted monotonically + (`sleep`: 1786892400000 → 1786896000000 → 1786899000000, each exactly the max landed + session `updatedAt`); `vitals` watermark stamped to the pass time (empty-kind rule); + `lastSyncSummary` live on the settings card. + - Sandbox note (for the next phase): this agent's file sandbox is workspace-write — + `~/.gradle` and the SDK are read-only, so the build ran with + `GRADLE_USER_HOME=/tmp/dsh-gradle-home` (one-time 7.8 G copy of the warm cache) and the + extracted wrapper dist's `bin/gradle` directly. `adb root` works on this AVD (google_apis), + which is what made direct store inspection possible. + - Left behind on the AVD: app connected (10/10 permissions, all toggles on, `EXPORT_ALL`), + the two test nights in the app DB (grown Night A + churned Night B), 2 records in the HC + store, `adb` running as root. +- **2026-08-16 — Phase 3 implemented (daily activity export)** (branch + `feat/health-connect-foundation`). + - Shipped: `health/exporters/ActivityExporter.kt` (one `StepsRecord` + + `ActiveCaloriesBurnedRecord` + `DistanceRecord` per `ActivityDailyEntity`, spanning the local + day clamped to `min(endOfDay, now)`); `HealthConnectTypeMappings` gains the pure activity + helpers (`activityRecordId`, `activityDayEndMs`, the three range guards, `activityLeftover`, + `workoutNetting` + `NettableSession`/`WorkoutNetting`); `HealthConnectExporter.run()` gains the + activity group with its own `ACTIVITY` watermark and **per-metric** permission gating (three + record types, three independently grantable permissions); + `ActivityDailyDao.updatedSince(watermark)` and + `ActivitySessionDao.finishedStartedBetween(from, to)`. No manifest change — all three + `WRITE_*` permissions landed in Phase 0. + - **Identity is simpler than sleep's:** `activity_daily.date` genuinely *is* uniquely indexed + (`CoreEntities.kt:74`), so `pl-act--` needs no suffix scheme; + `clientRecordVersion = row.updatedAt`. `date` is re-normalized through + `TimeUtil.startOfDayLocal` before use (iOS does the same at `HealthSyncService.swift:270`) — + it is local midnight *in the zone it was written in*, and a stored off-zone midnight would + otherwise emit a second overlapping record for one calendar day, which Health Connect sums + rather than de-duplicates for an app's own records. + - **Workout netting — ported, but deliberately switched off until Phase 4.** The port itself + diverges from iOS in one way (drop the walk/run distance filter: Health Connect has a single + `DistanceRecord`, and `ActivityRollup.credit` folds in every `useGps` session regardless of + type). The gate is the bigger call: iOS nets on `exportWorkouts` alone because its workout + exporter already ships, whereas here `WorkoutExporter` does not exist yet, so netting on the + toggle would subtract energy and metres nothing writes back — silent, unrepairable + under-reporting on every day containing a workout. `HealthConnectExporter.WORKOUTS_EXPORTED` + (false) is the one flag Phase 4 flips; netting also requires `WRITE_EXERCISE` granted, which + still matters after Phase 4 because the toggle can be on while the permission is denied. + - **Observer review found 13 items; 5 were fixed as real defects**, the rest documented: + 1. Netting active with no workout exporter → the gate above (**blocker**). + 2. `ORDER BY date ASC` broke the chunked watermark invariant — `healthConnectInsertChunked` + advances to the max high water of the last successful chunk, which is only sound when + records arrive in high-water order, and a history re-sync restamps an *old* day's + `updatedAt`. Now `ORDER BY updatedAt ASC`. **The same defect was in Phase 2's + `SleepSessionDao.updatedSince` and is fixed in this commit too.** + 3. Phase 3 is the first group where one source row emits several records sharing one high + water, so a chunk boundary falling inside a day could strand an unlanded sibling below an + advanced watermark. `healthConnectInsertChunked` now clamps a failed pass to the largest + completed high water strictly below everything still pending. + 4. The steps guard now stops at the app's own corruption threshold (200 000, + `EventPersistenceSubscriber.kt:374`) rather than the platform's 1 000 000 — a write-only + store cannot be retracted, and the app self-heals such rows only on the next sync. + 5. Comments that overclaimed were corrected: "netted set == credited set" is imperfect in two + known ways (`applyActivityBucketAtomic` overwrites a past day's distance, discarding the + GPS credit; `ActivityRollup.credit` skips sub-minute sessions), the raw `calories` column is + only the ring's device figure when `source != ring_history && calories > 0`, and the + empty-pass watermark stamp does not retire a zero-metric day as previously claimed. + Deferred with a written home: the ≤ 0-leftover stale-record window → Phase 4; newly-granted + permissions needing a watermark reset → Phase 6. + - API verified against the 1.1.0 jar (`javap`): all three records are `IntervalRecord`s with the + constructor `(Instant, ZoneOffset, Instant, ZoneOffset, , Metadata)`; steps is a bare + `Long`, energy is `Energy.kilocalories`, distance is `Length.meters`. Validation is a union of + two validators — Jetpack's `require`s below Android 14 and the platform's `requireInRange` from + 14 up (`StepsRecord.kt:44-52` branches on SDK level): steps `1..1_000_000` (Jetpack's floor is + 1, the platform's 0), energy and distance `0..1_000_000`, `startTime` strictly before `endTime` + pre-U, and on U+ `startTime` must not be in the future. Guards drop rather than clamp + (Gadgetbridge's style, so one bad row cannot sink its 200-record chunk); NaN and ±∞ fall out of + every comparison for free. Note **Gadgetbridge is not a precedent here** — it writes per-minute + activity records, not day aggregates, and avoids double counting by *suppressing* the workout + side (`RecordedWorkoutSyncer.kt:330-334`) rather than netting. + - Verified: `testDebugUnitTest` — 24 new tests (id tokens and stability; the day-span clamp for a + past day, today, a future day and exact midnight, plus both DST directions in + America/Los_Angeles; all three range guards including the 200 000 step ceiling; netting sums, + the GPS-only distance rule, day separation, null/non-positive inputs; leftover subtraction + including the negative and exactly-zero cases; the chunk-boundary sibling clamp in both the + stranding and clean-boundary shapes; and the netting gate) — full suite **963 green**; + `assembleDebug`, `installDebug`. + - **Runtime verification (API 35) DONE on `pulseloop_test`** (fresh image this session: + onboarding via "Explore without ring", location/BT/notification permissions, HC sheet + "Allow all" → 10/10, backfill "Sync all history"). TZ America/Los_Angeles (−25200). Injected + five days and three workouts through `run-as … sqlite3`, chosen so each netting rule has a + discriminating case: day A = today (8 500 steps / 520 kcal / 6 400 m) with a **GPS run** + (180 kcal / 2 000 m) and a **still-recording** walk (999 kcal / 9 999 m); day B = 3 days ago, + no workout; day C = `ring_history` with a **non-GPS gym** session (90 kcal / 1 500 m); + day D = `demo`; day E = all-zero. + 1. First trigger: `nothing new to export` — correct, and a useful confirmation: the earlier + empty pass had already stamped the activity watermark to *now*, so the backdated injected + rows fell below it. Re-stamping `updatedAt` (what a real sync does) released them. + 2. With netting forced on: `exported activity 9 · skipped: activity: 1 day(s) with nothing to + export`. Records confirmed **directly in the provider store** (`adb root` → + `sqlite3 /data/system_ce/0/healthconnect/healthconnect.db`): day A steps 8 500 (**not** + netted — no per-workout step record exists to double against), energy 340 kcal + (520 − 180 ✓), distance 4 400 m (6 400 − 2 000 ✓), `end_time` clamped to *now* rather than + end-of-day; day C energy 160 kcal (250 − 90 ✓) but distance **1 200 m un-netted** — the + discriminating case for the `useGps` rule; day B whole; day D absent (demo excluded, and + not counted as skipped since the source filter drops it before the loop); day E absent and + counted as the one skipped day. The recording session's 999 kcal / 9 999 m never applied. + Zone offsets −25200, versions = each row's `updatedAt`. + 3. Re-run: `nothing new to export`, counts unchanged. + 4. Growth (day A → 11 200 steps / 640 kcal / 7 900 m, `updatedAt` bumped): + `exported activity 3` — the **same three ids** updated in place (11 200 / 460 kcal / + 5 900 m), record counts still 3 / 3 / 3, no orphans. + 5. Shipped configuration re-verified after the observer fixes (netting gated off): + `exported activity 9`, day A now 640 kcal / 7 900 m and day C 250 kcal / 1 200 m — the full + totals — still 3 / 3 / 3 records, ids unchanged by the `startOfDayLocal` normalization. + 6. Final pass `nothing new to export`; activity watermark 1786885973764, above the max row + `updatedAt` 1786885888790 — the empty-pass stamp, monotonic. + - **Toolchain gotcha (cost ~20 min):** a stale untracked `.kotlin/` directory (Kotlin 2.x + project-level incremental state, carried over from another machine's build) made + `compileDebugUnitTestKotlin` fail with **63** bogus errors — `internal` members of `main` + unresolved from the test source set (`isConnectTransition`, `connectPurge`, `colorToHex`, …) + plus a phantom opt-in error. It reproduced at HEAD with every Phase 3 change stashed, which is + what proved it environmental. `rm -rf .kotlin app/build/kotlin` fixed it outright. Separately, + this session's Bash sandbox blocks the Kotlin compile daemon's writes to + `~/Library/Application Support/kotlin/daemon`, and the silent in-process fallback loses + `-Xfriend-paths` (the same internal-visibility symptom) — Gradle needs the sandbox escalation + here, as the emulator does for `~/.android`. + - Left behind on the AVD: app connected (10/10, all toggles on, `EXPORT_ALL`), five activity days + + three sessions in the app DB, 9 activity records in the HC store (un-netted), `adb` as root. + +- **2026-08-16 — Phase 4 implemented (workouts + GPS route)** (branch + `feat/health-connect-foundation`, commit `44d9794`, on top of Phase 3's `1ec0c1e`). + - Shipped: `health/exporters/WorkoutExporter.kt` (one `ExerciseSessionRecord` per finished + `ActivitySessionEntity` — type map, `ActivityMeta.label` title, session notes, embedded + `ExerciseRoute` from the session's `accepted` GPS fixes plus sibling + `ActiveCaloriesBurnedRecord` / `DistanceRecord` over the session window); + `health/HealthConnectWorkoutDeletion.kt` (the Phase 4 deletion hooks); the workouts group in + `HealthConnectExporter.run()`; `insertChunkWithRouteShrink` + `shrinkOversizedRoute` (the + 1 MB single-record fallback); the pure helpers in `HealthConnectTypeMappings` + (`workoutRecordId`/`workoutChildRecordId`, `exerciseType`, `selectWorkoutSession`, + `GpsRoutePoint`/`sanitizeRoutePoints`, `parseRecordSizeLimit`, `decimateToSize`, + `creditedActiveMinutes`); `ActivitySessionDao.finishedUpdatedSince` + (`ORDER BY updatedAt ASC` — the chunked-watermark invariant) and + `ActivityGpsPointDao.forSessions` (one query for the whole pending set); deletion hooks in + `WorkoutSummaryScreen` (UI trash) and `PendingActionExecutor` (coach + `delete_activity_session` — the Android confirm flow is not wired to a UI yet, so that path is + dead code today; the hook is in place for when it lands). No manifest change — + `WRITE_EXERCISE` + `WRITE_EXERCISE_ROUTE` landed in Phase 0, and there are still **no + READ_* permissions**. + - **Siblings == netting set, enforced structurally:** energy is written for every finished + session with plausible calories (the exact set `workoutNetting` subtracts); distance is + written only for `useGps` sessions (the Phase 3 amendment — the set `ActivityRollup.credit` + folds into the daily row). Each sibling is also gated on its **own** write permission — the + first observer-flagged defect of the phase, fixed before the first runtime pass. + - **WORKOUTS_EXPORTED flipped to true in this commit** (plan: same commit as the exporter), with + one addition the plan's stale-record question forced: a **one-time netting-flip reset**. The + ≤ 0-leftover case (decided: **drop, do not floor** — the floor cannot reliably repair the stale + record because the stale day is not re-selected, and the write-data guide says to omit zero + values) is one window; the *other* pre-flip staleness is that every daily record exported under + the Phase 3 build is UN-netted, and with the workout siblings now live, every such day containing + a workout over-counts by the workout's own energy/distance until the day happens to be + re-selected. `HealthConnectPrefs.nettingFlipDone` (absent from Phase 3 blobs → false, which is + exactly the "still needs the flip" state for upgrading users) + + `HealthConnectPrefsStore.resetWatermarks(setOf(ACTIVITY, WORKOUTS))` (monotonic + `setWatermark` can never rewind, so the reset is its own method) re-export every day and + session on the first netting-live pass; the re-upsert is idempotent — same clientRecordIds, + higher-or-equal versions. + - **The two Phase 3 netting imperfections, resolved:** (1) `workoutNetting` now skips the same + sub-minute sessions `ActivityRollup.credit` never credits — the credit-eligibility arithmetic + is ported verbatim (`creditedActiveMinutes` = `minutesFor`: full active minutes after + pauses, `minutes <= 0` → skip); (2) `applyActivityBucketAtomic`'s past-day distance + overwrite is **self-healing, no code change**: it stamps `updatedAt`, so the day is + re-selected and re-exported with the ring-only leftover while the workout's distance sibling + restores the credited metres — the consumer sum becomes the ring's own day total, the correct + reading for a past day (the only residual is the accepted ≤ 0 window). + - 1.1.0 API facts confirmed by `javap` on the AAR (and by the research subagent): + `ExerciseSessionRecord(Instant, ZoneOffset, Instant, ZoneOffset, Metadata, int exerciseType, + String? title, String? notes, List, List, ExerciseRoute?, + String? plannedExerciseSessionId)` with defaults from `title` on; the constructor (and + `ExerciseRoute(List)`) **client-side reject** a route whose points leave the + parent's [startTime, endTime] or repeat a timestamp (`IllegalArgumentException`) — which is + exactly what the sanitiser guarantees before construction; `Record` is a bare interface (no + child-sample types in 1.1.0 — siblings are the right Android pattern); + `deleteRecords(KClass, recordIdsList, clientRecordIdsList)` with **both lists non-null and no + defaults**, and **no client-side permission check** (the granted-set diff in the deletion hook + is the only guard). + - Verified: `testDebugUnitTest` — 31 new tests (the full ten-way exercise-type map + fallback; + title pinning against `ActivityMeta.label`; id scheme; the INVALID/FUTURE guard boundaries + incl. `endedAt == now` not-future; route sanitisation — window bounds inclusive, NaN/±∞, + latitude ±90 / longitude ±180 bounds, duplicate-timestamp keep-first, unsorted input, inverted + window, the 1-point "no route" boundary; `parseRecordSizeLimit` against the platform message + format; decimation first/last preservation, strictly-increasing indices, the target < 2 clamp, + and no-index-duplication for every target 2..size−1; the shrink wrapper — offender-only + decimation by ratio, unrelated errors pass through as null, the 2-point no-op floor that + prevents retry loops, immediate-retry-on-shrink, no-backoff, SecurityException aborts; the + credit-eligibility port and its netting skip; the netting gate now asserting the flipped + constant; the flip marker's tolerant decode and `resetWatermarks` semantics incl. persistence + across a store reload) — full suite **994 green**; `assembleDebug`, `installDebug`. + - **Runtime verification (API 35) DONE on `pulseloop_test`**. Deviation from the plan's + literal "record a real GPS walk via `adb emu geo fix`": **this emulator build's console + `geo fix` parser only accepts integer coordinates** (`37.8037` → "KO: invalid latitude", + `10 20 30` → OK) and its NMEA path (`geo nmea`) accepts sentences with valid checksums + (GP and GN talkers, GLL/RMC/GGA/GSA all tried) but produces no provider fix — verified via + `dumpsys location` (last fix unchanged after ~10 injected sentences). The walk-profile speed + cap (5 m/s) also rules out integer-degree jumps (one step = 111 km). So the route itself was + verified with a fixture session shaped exactly as `GpsRouteRecorder.ingest` persists one + (accepted/rejected/out-of-window/duplicate rows), while the **recording flow was real**: a + live UI walk (start via the type picker, GPS on) that ingested the console's integer fixes — + 1 accepted + 3 speed-rejected points, persisted with reasons — then finished through the + summary's Finish button (28:44, 117.33 kcal, no distance: one accepted point). + 1. First pass after the flip: `exported activity 9, workouts 7` + the flip reset fired + (`nettingFlipDone` false → true, ACTIVITY/WORKOUTS watermarks nulled and re-advanced). + The pre-flip staleness was repaired live: day C's stale un-netted energy record + (250 kcal, exported under Phase 3) was **overwritten in place at the same + clientRecordId** with its netted value (160 = 250 − 90), and the same-version re-upsert of + unchanged rows was accepted by the platform (9 activity records, zero insert errors). + 2. Store state (`adb root` → provider DB): 3 sessions — `pl-wk-wkA` (store code 33 = + EXERCISE_TYPE_RUNNING (library constant 56) normalized provider-side; version = + session.updatedAt; 7:00–7:30 PDT), `pl-wk-wkC` (store code 45 = EXERCISE_TYPE_STRENGTH_TRAINING + (70), no distance sibling: useGps=0), the real walk (store code 53 = EXERCISE_TYPE_WALKING + (79), **has_route=0** — one accepted point < 2, session still written, exactly the plan's + rule; titles — "Running"/"Gym"/"Walking" — confirm the map landed); today's daily records netted to + the milli: energy 342.672 kcal = 640 − 180 − 117.328 (sibling records 180 + 117.328), + distance 5 900 m = 7 900 − 2 000 (sibling 2 000), steps 11 200 un-netted; consumer sums + 342.672 + 180 + 117.328 = 640.000 and 5 900 + 2 000 = 7 900. + 3. Route fixture (24 rows: 20 clean 30 s-interval points at walk speed, 1 duplicate + timestamp, 1 speed-rejected, 1 before start, 1 after end): **20 route points** landed in + `exercise_route_table` (accepted-only, window-inclusive, duplicate dropped, rejected and + out-of-window excluded), `has_route=1`, notes exported; its day (1 200 → 2 000 m stored, + +800 credited, −800 netted → daily 1 200 + sibling 800 = 2 000) and energy (250 → 100 = + 250 − 90 − 60, siblings 90 + 60) both balance. + 4. **≤ 0-leftover drop verified live:** a day holding 10 kcal with a 50 kcal finished session + produced **no** daily records at all (energy 10 − 50 = −40 dropped; steps/distance 0 + dropped) while the session + its 50 kcal sibling exported — the documented accepted window. + 5. Re-run: counts unchanged (5 sessions / 8 energy / 5 distance / 3 steps / 20 route points), + no duplicates; the zero-metric day re-queries once and self-retires on the next + empty-pass stamp (the documented bounded re-query). Count reconciliation: 5 sessions = + wkA + wkC + the live walk + wkRoute + wkTiny; 8 energy = those 5's siblings (all have + plausible kcal) + the 3 daily ActiveCalories records (days 08-13, 08-14, 08-16); 5 + distance = the 2 useGps siblings (wkA 2 000 m, wkRoute 800 m) + the 3 daily Distance + records (9 000 / 1 200 / 5 900); 3 steps = the 3 daily Steps records; 20 route points + all under wkRoute. + 6. **Deletion hooks verified through the real UI trash:** deleting the live walk removed + `pl-wk-504a…` + its energy sibling from the store (local row → `statusRaw='deleted'`, + no longer re-selected by the finished-only query); deleting the route session removed the + session, **all 20 route rows** (provider-side cascade on the parent) and both siblings + (3 / 6 / 4 / 3 counts after). WRITE-only deletion (no READ_* held) works on the provider. + - Sandbox/toolchain: same as Phase 3 — Gradle runs need the one-shot sandbox escalation + (`~/.gradle` + the Kotlin daemon dir are read-only under workspace-write); the emulator's + `adb` plumbing likewise. No stale-incremental-state incident this time. + - Left behind on the AVD: app connected (10/10, all toggles on, `EXPORT_ALL`, + `nettingFlipDone=true`), 7 sessions (3 finished + 1 recording) and 7 daily rows in the app + DB, 3 exercise sessions + 6 energy + 5 distance + 3 steps records and 0 route rows in the HC + store (the route session was the one deleted), `adb` as root. + - Accepted residuals left on the record (observer stage B, both match iOS semantics, neither + a code change): (a) a FUTURE-dated session blocks the workouts pass while its energy and + distance are still netted out of its day (`finishedStartedBetween` has no now-check) — + bounded under-report on that day until the day row restamps; future-dated sessions are + clock-skew pathology only. (b) a corrupt INVALID row carrying calories > 0 is + watermark-leapfrogged yet still netted — same under-report shape, same low exposure (the + coach update path recomputes kcal to null/0 for zero-duration sessions). + - **Observer stage-B review (49-item checklist): 1 BLOCKER + 2 SHOULD-FIX, all fixed in the + same-day follow-up.** (1) BLOCKER — on a partial chunk failure, the workouts watermark could + leapfrog unlanded valid sessions via the record-less INVALID rows' `invalidHighWater` (same + defect class as the Phase 3 chunk-stranding bug, reached through the record-less path): + the advance decision is now the pure `workoutsWatermarkAdvance`, and `invalidHighWater` + applies only when the pass fully completed. (2) The deletion hook gated on the master export + toggle, so a delete made with the export off would have left ghost records — it now gates on + availability + the per-class permission only (iOS parity: availability only). (3) The + netting-flip reset now additionally requires `backfillChoice == EXPORT_ALL` — a "Only new + data from now on" user's consent boundary is not re-opened by a full-history re-export (their + narrower pre-flip residual is accepted and documented at the flip condition). Also adopted + from the research report: workout records are `Metadata.activelyRecorded` (user-initiated — + Gadgetbridge marks its ACTIVITY-type records actively recorded; the Phase 1–3 ring-data + groups stay `autoRecorded`). + +- **2026-08-16 — Phase 5 implemented (beyond iOS: BP, glucose, resp rate, VO₂max, resting HR, nutrition)** (branch `feat/health-connect-foundation`). + - **Scope (plan §4):** the six beyond-iOS types + their `WRITE_*` manifest permissions (added this phase, not earlier). One research subagent, one observer subagent (stage A pre-, stage B post-implementation), I coded directly — same split as Phases 0–4. + - **Shipped:** `HealthConnectTypeMappings.kt` pure helpers (record-id builders, plausibility guards, `pairBloodPressure`, `nutritionMealType`); `VitalsExporter` now builds glucose (`BloodGlucoseRecord`), resp rate, VO₂max (`MEASUREMENT_METHOD_OTHER`) + new `buildBloodPressure` (pairs systolic/diastolic `MeasurementEntity` rows by exact timestamp into one `BloodPressureRecord`, id `pl-m-bp-`, drops unpaired + out-of-range + demo); new `RestingHeartRateExporter.kt` (single constant-id `pl-resting-hr`, version = `hrRestingBaselineUpdatedAt`, `Math.round` bpm, guard 1..300); new `NutritionExporter.kt` (`MealEntryEntity` → `NutritionRecord` interval start + 60 s, id `pl-meal-`, version = createdAt, `Mass.milligrams` sodium, skips empty meal, validates before build); the RESTING_HR + NUTRITION groups in `HealthConnectExporter.run()`; the 6 `WRITE_*` manifest permissions (16 total, 0 `READ_*`); the 6 per-type toggles (default on) + `RESTING_HR` watermark in `HealthConnectPrefsStore`; 5 new settings rows; `MealEntryDao.createdSince`. + - **Glucose cap is 900.0 mg/dL, not the plan's 900.91** — verified from the 1.1.0 AAR: the `BloodGlucoseRecord` level cap is 50 mmol/L and the mg/dL→mmol/L factor is 1/18, so 900.0 → 50.0 mmol/L (exactly at the cap) but 900.01 → 50.0006 (throws). The plan's 900.91 (and Gadgetbridge's) is *looser* than this client — flagged in review so nobody "corrects" it back. The guard drops out-of-range samples (20..900.0) rather than clamping, consistent with the other types. + - **Observer stage-B BLOCKER (fixed): the nutrition energy cap was 1000× too loose.** I had read the platform's `Energy.calories(100_000_000)` cap as a *kcal* value, but `Energy.calories` is **small calories**, so the real cap is 1e8 cal = **100,000 kcal**. A 6-digit kcal typo (500,000) would have passed the guard and thrown from the `NutritionRecord` ctor, wedging the whole export work on every retry (the exact "one typo sinks the chunk" failure the guard exists to prevent). Fixed `MAX_NUTRITION_ENERGY_KCAL` 1e8 → 100_000 + boundary tests. + - **Observer stage-B SHOULD-FIX (fixed): the nutrition export now also gates on the app's nutrition-feature master toggle (`UserGoalEntity.nutritionEnabled`, default false), not just the Health Connect per-type toggle** — iOS gates nutrition on the feature toggle too, and the "Open Nutrition Log" entry is un-gated, so off-feature meals would otherwise leak to Health Connect. Both must be on. + - **Phase 1 latent-bug fix (committed `054b5d2` before this phase's work):** every write path stores `kindRaw = MeasurementKind..name`, but `VitalsExporter` queried `createdSince` by `.key` — the two never matched, so the VITALS group silently exported nothing. Replaced with a shared `kindRaw` map (`.name`) for query + write. Runtime-verified this phase: the 4 new VITALS kinds (glucose/resp/vo2/bp) all use `.name` and export correctly, exercising the fixed query path. + - **Runtime-verified on emulator-5554 (API 35, google_apis, `adb root`):** granted all 16 perms via `pm grant` (the export's live `getGrantedPermissions()` reads the OS grant; the sheet flow is unchanged); merged manifest = 16 `WRITE` / 0 `READ`. Injected fixtures via host sqlite3 pull/modify/push (the device toybox `sqlite3` cannot read the WAL-mode DB): paired BP 120/80 + 110/70, an orphan systolic, an out-of-range 210/80 pair, a `demo` pair (excluded), glucose 100 + 900.0 + 950 + 0, resp 16 + 70, VO₂ 45 + 150, 2 meals, resting-HR baseline 57.5. Pass result: `glucose 2, resp_rate 1, vo2max 1, bp 2, resting_hr 1, nutrition 2` — every guard/pair/exclusion behaved as designed. Provider store inspected (`/data/system_ce/0/healthconnect/healthconnect.db`): correct `clientRecordId`s, versions, values (glucose 100→5.556 mmol/L, 900.0→50.0 mmol/L; BP 120/80 + 110/70; resting `pl-resting-hr` 58 bpm = round(57.5), version=updatedAt; nutrition `pl-meal-` meal_type 1/2, energy in small-cal, `Mass.milligrams` sodium, NULL sodium ≈ 0). **Re-run → no duplicates** (rows = distinct `client_record_id` in all six tables) — the upsert idempotency the design depends on. Resting-HR re-learn (baseline 57.5→60.0 with a newer `hrRestingBaselineUpdatedAt`) upserted the same `pl-resting-hr` in place (value 60, strictly higher version, count still 1). (The release R8 smoke is the plan's *final* gate per the status line, not a Phase 5 block — R8 is already proven on the 1.1.0 client in Phases 0–4 and the six new record classes are the same AAR public API.) + - **Nutrition watermark uses `createdAt` — the Stage-A acceptable fallback, NOT the recommended `updatedAt` migration:** `MealEntryEntity` has no `updatedAt` and Android has no in-place meal-edit path yet (meals are insert-once today), so `createdAt` is safe now. The first meal-edit PR MUST add `MealEntryEntity.updatedAt` + a Room migration (`ALTER TABLE meal_entries ADD COLUMN updatedAt`, backfill `= createdAt`), then switch the nutrition watermark + version to `updatedAt` — otherwise in-place edits are invisible to the exporter (createdAt unchanged) and the stale `NutritionRecord` never re-selects; write-only means no repair. iOS's twin already carries `updatedAt` for exactly this. + - **Stage-B NICE-TO-HAVE polish (applied):** BP drops are now counted + reported — `pairBloodPressure` returns a `BpPairingResult` with `unpaired`/`outOfRange`, and the pass summary gets a "blood pressure: N reading(s) dropped (unpaired or out of range)" line, matching the other groups' skipped counters (stage-A #2). Nutrition records are `Metadata.activelyRecorded` (meals are user-initiated — Phase 4 consistency) and clamp a future-dated `meal.timestamp` to now (iOS parity, clock-skew guard). Fixed the stale Phase-1-bug-fingerprint comment in `HealthConnectPermissions.WRITE_PERMISSION_BY_KIND` (keys are kindKey tokens, not kindRaw) and the VitalsExporter KDoc (now lists the Phase 5 kinds); the glucose KDoc now notes the ceiling is the platform cap (900.0), not the app bridge (600). + - **Phase 6 input (observer-flagged):** the permission→group reset mapping must map the four new kinds (glucose/resp/vo2/**bp**) → **VITALS** (they share the advanced VITALS watermark). On an *upgrading* install the VITALS watermark is already ahead of historical glucose/resp/vo2/bp rows, so those legacy rows won't backfill until Phase 6's newly-granted-permission reset; fresh installs (all watermarks null) backfill fully — consistent with the Phase 3 precedent. That one VITALS reset also backfills the `.name`-fixed legacy kinds. + - **Gotcha (device tooling):** `adb push` into `/data/data/…` lands the file root-owned, which crashes the app on the next DB open (Room `SQLiteDatabase.openInner`). Push to `/data/local/tmp` then `run-as … cp` into place (app-owned), or `chown u0_a208:u0_a208` + `chmod 660` after a root push. + +- **2026-08-17 — Phase 6 implemented (lifecycle, removal, docs)** (branch `feat/health-connect-foundation`). + - **Scope (plan §4):** "remove PulseLoop data" (iOS `removeAllExportedData` parity), grant/revocation watermark resets (16 perms → 6 groups, the four Phase-5 kinds → VITALS), meal `updatedAt` migration + nutrition watermark/version on `updatedAt`, archive-restore watermark stamp, `ios-sync.md` update. One research subagent, one observer subagent (pass 1), I coded directly. + - **Shipped:** `health/HealthConnectPermissionReconcile.kt` (new — `PERMISSION_GROUP` 16→6 map, `groupsFor`, `reconcile`, `onAppStart`; **uniform grow-reset**: any group whose granted set grows gets its watermark nulled — NUTRITION/RESTING_HR "no reset" is just the no-op instance, no key is special-cased); `health/HealthConnectRemoval.kt` (new — `RECORD_TYPES`: 15 record classes × their single WRITE perm (exercise route is embedded, not a 16th class); `removeAll`: cancel the unique export work → per-class WRITE-gated `deleteRecords(class, TimeRangeFilter.after(Instant.EPOCH))` with per-class catch/log/continue → `clearWatermarks()` + null `lastSyncAt`/`lastSyncSummary`); meal `updatedAt` (`NutritionEntities` += `updatedAt`, `PulseLoopDatabase` v20→v21 `ADD COLUMN` + `= createdAt` backfill inside the upgrade transaction, `MealEntryDao.updatedSince`, `NutritionExporter` watermark + record version on `updatedAt`, `DataArchive` DTO round-trip with `createdAt` backfill for old archives); archive-restore stamp (`DataArchiveService.importFile`, after the Room transaction and before return, gated on the device's `enabled`, stamps all six watermarks to `now` — iOS `DataArchiveService.swift:458-461` parity); `HealthConnectPrefs` += `revocationOfferDismissed` (one-shot revoke-offer flag, tolerant decode); `HealthConnectExportWorker` companion `cancel(context)` + the SecurityException path now re-reads the live grant set, reconciles, and corrects the stored set; `SettingsSubScreens.kt` — "Remove PulseLoop data" card + confirm dialog (creation `runCatching`-guarded), the state-based one-shot revocation offer, the LaunchedEffect-on-open reconcile, and the launcher reconcile. New `HealthConnectPermissionReconcileTest.kt` (8 tests, hand-rolled `FakeSharedPreferences`, no mocks per repo constraint). + - **Deletion uses the time-range overload, not `clientRecordIds`** — research pinned both from primary sources: the 1.1.0 KDoc states the range overload is "automatically filtered to [Record] belonging to the calling application", and AOSP `HealthConnectServiceImpl.java:1128-1131` force-overwrites the data-origin filter to `callerPackageName`; **WRITE-only is required** (`DataPermissionEnforcer.enforceRecordIdsWritePermissions`, no read fallback — unlike the read path). The `clientRecordId` path is infeasible: write-only means stored record IDs can't be enumerated, and the ID overload aborts the whole transaction on any unknown ID. + - **Design locks:** grow-reset is uniform for all 6 groups (revoke→re-grant re-exports; idempotent-safe); on revocation the (possibly empty) live set is stored (grow-from-empty backstop); the revocation offer is a Gadgetbridge-pattern UX courtesy, one-shot via `revocationOfferDismissed` (cleared on grow, set on all three dismissal paths); the archive stamp is `now` gated on `enabled`; deletion is platform owner-scoped, permission-guarded, per-class isolated, work-cancelled-first (the iOS `isSyncing` latch analogue); and removal also turns the export OFF + resets `backfillChoice` to `NOT_ASKED` and the `newOnlyStamped` first-enable marker (a pass-1 MAJOR fix — an `EXPORT_ALL` re-export would otherwise silently undo the removal within ~15 s; re-enabling re-offers the backfill dialog, deliberately stricter than iOS parity). + - **Runtime-verified on `emulator-5554` (API 35, arm64, adb root):** + - Migration 20→21: `user_version=21`, `meal_entries.updatedAt` present, seed meals backfilled (`updatedAt == createdAt` exactly). + - Grow-reset: stored set narrowed to ACTIVITY (3 perms) + sentinel watermarks → relaunch → `onAppStart` detected the grow → vitals/sleep/workouts/nutrition/restingHr reset to null, activity kept its sentinel, `lastGrantedPermissions` updated to the live 16; a subsequent export then advanced the reset groups. + - **Removal (closes the observer's MAJOR finding):** planted a *foreign* canary (a hand-inserted `application_info_table` row `com.canary.test` + a `steps_record_table` row under its `app_info_id`) → Settings → Health Connect → "Remove PulseLoop data" → confirm. Result: **all our records gone** (`app_info_id=1` = 0 across all 15 record tables), **the canary survived** (owner-scoping proven at runtime, not only statically), all six watermarks null, `lastSyncAt`/`lastSyncSummary` null, and the granted set preserved. (At the time of this check `enabled`/`backfillChoice` were still preserved too; the pass-1 MAJOR fix since then made `removeAll` also turn the export OFF and reset `backfillChoice`→`NOT_ASKED` + `newOnlyStamped`, so a re-enable re-offers the dialog instead of a background trigger silently re-exporting the whole history.) The status line read "Removed PulseLoop data from Health Connect." (0 failed types). The canary was then removed from the provider store. + - **Observer pass 1: 1 MAJOR + 4 MINOR, all addressed.** MAJOR — the removal KDoc overclaimed WRITE-only as "verified" with no recorded runtime proof: now runtime-proven (above) and the KDoc states what is statically established (1.1.0 KDoc + AOSP force-filter) versus runtime-confirmed. MINOR — (a) the full-revocation reset offer was unreachable (diff-based; `onAppStart` stores the empty live set before the screen opens): now state-based (`enabled && granted empty && hadSync && !dismissed`) + the one-shot flag; (b) `ios-sync.md`'s "Phases 0–6 complete" was premature: it is finalized only now, with this §8 entry + the final gates; (c) unguarded `HealthConnectClient.getOrCreate` in the remove dialog: now `runCatching` + a status message; (d) stale untracked `android/dist/`: added to `.gitignore`. + - **Final gates (plan §5.6): release R8 smoke — PASS.** `assembleRelease` (`isMinifyEnabled = true`) built clean — `minifyReleaseWithR8` ran and `mapping.txt`/`seeds.txt`/`usage.txt` are present. The R8 release APK (`com.pulseloop`) was installed on the API 35 emulator with its HC prefs seeded (enabled + 16 granted) and launched: `onAppStart` made the real `getGrantedPermissions` IPC to the Health Connect provider and reconciled the stored set 16→0 (the device's actual grants for `com.pulseloop`), rewriting the prefs — with **no** `NoClassDefFoundError`/`ClassNotFound`/FATAL anywhere in logcat. R8 did not strip the Health Connect client. The **API 30 no-Health-Connect-image graceful-degradation check — PASS.** On a fresh API 30 `google_apis` AVD (`pl_api30`, no Health Connect provider present — confirmed via `pm list packages`), the R8 release APK installed and launched cleanly (no FATAL/`NoClassDefFoundError`), and Settings → Health Connect rendered the actionable unavailable state — "The Health Connect app on this device needs an update before it can be used." with an "Update Health Connect" button — instead of a broken toggle or a crash. The availability guard (`getSdkStatus` → not `SDK_AVAILABLE` → no HC client calls) holds on a provider-less device. + - **Pre-merge observer pass (commit boundary): NO BLOCKERS, NO SHOULD-FIX — ready to merge on correctness.** The observer independently re-verified the AOSP claim rather than trusting the citation (fetched `HealthConnectServiceImpl.java` from googlesource `main`: `deleteUsingFiltersForSelf` force-sets the package filter to the caller at line 1130 and enforces **WRITE-only** at 1139; the non-self `deleteUsingFilters` requires the platform `MANAGE_HEALTH_DATA_PERMISSION` — so a regular app has exactly one delete path, caller-scoped + WRITE, corroborating the KDoc/§8; the API 35 AVD canary remains the authoritative runtime proof). Two new items: **(1) MINOR** — `revocationOfferDismissed` was cleared on grow only by the two UI paths (settings LaunchedEffect + launcher), not by `onAppStart`'s out-of-band grow, so a pure out-of-band dismiss → re-grant → revoke cycle left the offer suppressed (no correctness impact — the grow-from-empty backstop always re-exports). **Fixed:** `onAppStart` now clears the flag in the same `store.update` when a grow is detected, so the "cleared on grow" contract holds on all three paths (the §8 wording is now fully true). **(2) NIT** — the launcher-path offer lacked the `hadSync` guard the state-based path has (a user who never exported could get a "reset" offer for already-empty state; a harmless no-op, wording slightly off). **Fixed:** the launcher path now applies the same `hadSync` guard, aligning the two offer paths. + +- **2026-08-19 — PR #50 review pass 2 (1 MAJOR, 2 MINOR, 1 NIT), all addressed** (branch `feat/health-connect-foundation`). + - **MAJOR (3811754530) — `EXPORT_NEW_ONLY` pre-consent history leak via a watermark reset.** The pass-1 fix replaced the `wm0.vitals == null` sentinel with a dedicated `newOnlyStamped` flag, but `resetWatermarks` (the grow-reset on a re-granted permission, or the re-enable-vitals-toggle reset) still *nulled* the watermark. A null group watermark means "export from epoch" (`createdSince(kind, 0)`), so a NEW_ONLY user who re-granted a permission out of band — or flipped a vitals row off→on — would have their **pre-consent** history re-exported, exactly what the consent was meant to prevent (the Phase 4 netting flip is deliberately gated on `EXPORT_ALL` for this same reason). **Fix:** persist the consent instant — `HealthConnectPrefs.newOnlyConsentAt` is recorded by the first-enable sentinel (the stamp pass), and `resetWatermarks` now clamps a NEW_ONLY group reset to it instead of nulling (`EXPORT_ALL`/`NOT_ASKED` keep the null-and-backfill-from-epoch behaviour; `consent` is null for a NEW_ONLY user whose sentinel has not stamped yet, all watermarks null). `removeAll` clears it so a fresh re-enable re-stamps. + - **MINOR (3811754542) — activity recreation stranded the hard-gated state.** `showBackfillDialog` was `remember`, so a rotation/process death while it was up destroyed it WITHOUT running `onDismissRequest`, leaving `enabled=true` + `backfillChoice=NOT_ASKED` — "Connected" but nothing ever exports, with no re-offer. **Fix:** derive it from persisted state (`enabled && isConnected && backfillChoice == NOT_ASKED`) the way the revocation offer already does — recreation re-shows it, and the explicit dismiss becomes redundant. + - **MINOR (3811754548) — the `false` default reproduced the original bug once at the upgrade boundary.** A pre-fix blob decodes `newOnlyStamped` to `false`; an install that already ran a pre-fix build with EXPORT_NEW_ONLY (watermarks already stamped by the old null-inference sentinel) would take the first-enable branch once after updating and re-stamp every group to now, silently dropping the rows pending since the last pass. **Fix:** in `load()`, when the key is absent AND any watermark is non-null, seed `newOnlyStamped=true` + `newOnlyConsentAt=min(watermarks)` (every watermark was stamped to the consent instant and only ever advanced forward, so the min is ≥ the true consent — safe, never a pre-consent leak). A fresh NEW_ONLY choice (all-null watermarks) is never suppressed. + - **NIT (3811754553) — doc drift after the pass-1 removal fix.** Updated the Phase 6 plan line, the §8 Design-locks line, and the removal live-verification note to capture that `removeAll` also turns the export OFF + resets `backfillChoice`→`NOT_ASKED` + `newOnlyStamped` (the reason: an `EXPORT_ALL` re-export would otherwise silently undo the removal within ~15 s; re-enabling re-offers the dialog); fixed the stale `MainActivity.onResume` comment ("No-op unless … with a stored grant" — the guard is `!enabled` only) and the backfill dialog's false "You can change this later" copy. + - **Tests:** 7 new in `HealthConnectPrefsStoreTest` — the NEW_ONLY consent clamp (single key, multi-key, the non-NEW_ONLY null regression, and on-disk persistence) and the upgrade-boundary seed (absent-key seed from the earliest watermark, all-null watermarks not seeded, present-key not overridden). **Verified:** `testDebugUnitTest` (health package: 152 tests, 0 failures — 7 new in `HealthConnectPrefsStoreTest`) + `assembleDebug` green. + +- **2026-08-19 — PR #50 review pass 3 (1 MAJOR, 3 MINOR), all addressed** (branch `feat/health-connect-foundation`). + - **Root cause (reviewer):** a null group watermark is *overloaded* — it means both "never exported" and "export from epoch" (`createdSince(kind, 0)`) — and the NEW_ONLY consent boundary had been enforced at every *write* site (three rounds, three different nulling paths) instead of the single *read* site. + - **MAJOR (3814246783) — `clearWatermarks()` in the revocation dialog bypassed the clamp.** It nulls all six while leaving `backfillChoice=EXPORT_NEW_ONLY`/`newOnlyStamped=true`/`newOnlyConsentAt=T`, so the sentinel won't re-stamp and the watermark reads as epoch → pre-consent leak. Fixed by the read-site clamp below (safe by construction; no call-site patch). + - **Read-site clamp (the fix for findings 1 + 3):** the exporter now selects on `effectiveWatermark(stored, newOnlyConsentAt) = max(stored ?: 0, newOnlyConsentAt ?: 0)` at all six group build() sites instead of `stored ?: 0`. Every watermark-nulling path (resetWatermarks, clearWatermarks from removal *and* the revocation dialog) is now safe by construction, and a future nulling call site can't reopen the class. The STORED watermark still drives the monotonic advance (callers compare against `stored`, not the clamp) — only the SELECT is floored. + - **MINOR (3814246800) — sentinel stamped the six watermarks *before* persisting `newOnlyConsentAt`; a main-thread reset landing in that window read `newOnlyConsentAt == null` and nulled the fresh stamps, then the update committed `newOnlyStamped=true` → all-null watermarks, no re-stamp, epoch export.** The read-site clamp closes this too (a nulled watermark still floors at the consent), so no reordering was needed. + - **MINOR (3814246789) — the derived backfill dialog was not gated on availability.** It read persisted prefs only, so a device whose provider is uninstalled / needs an update (with `enabled` + `isConnected` + `NOT_ASKED` persisted) popped it over the "needs an update" card, and a back-press silently flipped the master off. Added `availability == HealthConnectAvailability.AVAILABLE` to the derived condition. + - **MINOR (3814246812) — the upgrade-boundary `newOnlyStamped` seed was dead + fragile.** `origin/main` has no `health/` code (the feature ships whole in this PR), so no installed blob can be missing the key; the raw-substring probe also silently depended on `encodeDefaults = true` and was not gated on `backfillChoice` (a `NOT_ASKED` archive-restore blob with non-null watermarks would seed `newOnlyStamped=true` and skip the stamp pass). Dropped the seed — the read-site clamp supersedes it (the sentinel records the consent; the floor is enforced at the read site regardless of watermarks). + - **Tests:** dropped the 3 now-obsolete seed cases; added 4 `effectiveWatermark` cases (no-op without consent; null→consent; below-consent floored; at/above-consent kept). **Verified:** `testDebugUnitTest` (health package: 153 tests, 0 failures) + `assembleDebug` green. + +- **2026-08-20 — PR #50 review pass 4 (1 MINOR, 1 NIT), both addressed** (branch `feat/health-connect-foundation`). + - **MINOR (3814608810) — `HealthConnectPrefsStore` mutators were non-atomic read-modify-writes, so concurrent writers silently dropped each other's fields.** `val next = transform(current); _prefs.value = next` has no compare-and-set, and the writers really are concurrent: `HealthConnectExportWorker.doWork` writes `lastSyncAt`/`lastSyncSummary` and advances watermarks on the worker dispatcher, `HealthConnectRemoval.removeAll` writes from `removeScope`, and the settings screen writes toggles / `enabled` / `backfillChoice` / `revocationOfferDismissed` from the main thread. Concrete loss: during a minutes-long first-enable `EXPORT_ALL` backfill — which is exactly where the user sits watching Settings — a data-type Switch flipped off just as the pass finishes is overwritten by the worker's assignment, so the toggle snaps back on and re-exports that type. The same race could swallow the sentinel's `newOnlyStamped`/`newOnlyConsentAt` update (self-healing, but the same class one step removed). **Fixed:** a single `writeLock` serializes read → transform → `_prefs.value`/`_watermarks.value` → persist in all four mutators (`update`, `setWatermark`, `resetWatermarks`, `clearWatermarks`). The lock deliberately spans the persist as well as the in-memory assignment, so the on-disk blob can never be ordered differently from the StateFlow (a lost disk write would resurrect the dropped field on the next process start). `setWatermark`/`resetWatermarks` also read `_watermarks.value` once inside the lock instead of re-reading `currentWatermarks` per field. + - **NIT (3814608816) — an install that ran `bef3048` has `newOnlyStamped = true` with `newOnlyConsentAt = null`, which makes the read clamp a permanent no-op for it.** `newOnlyStamped` shipped in `bef3048` (pass 1), `newOnlyConsentAt` only in `e9b36a4` (pass 2), so such a blob tolerantly decodes to stamped-but-no-consent with all six watermarks already stamped — the sentinel never re-fires, nothing populates the consent instant, and `effectiveWatermark(x, null)` degrades to the old unclamped `stored ?: 0` forever. The first nulling path (the revocation dialog's "Reset export", or a grow-reset) would then export the full pre-consent history. Branch/dogfood installs only — `origin/main` genuinely has no `health/` code — but the recovery is three lines, so it is worth having. **Fixed:** a one-shot `repairMissingConsentInstant()` in the store's `init` back-fills `newOnlyConsentAt` from the *oldest* non-null watermark when `backfillChoice == EXPORT_NEW_ONLY && newOnlyStamped && newOnlyConsentAt == null`. That is the inverse of the stamp (the sentinel wrote one instant to all six groups, so the minimum is that instant, or later if a group has since advanced) and it can never place the boundary *earlier* than the real one — the direction that would leak history. It no-ops when there is nothing to reconstruct from (all watermarks cleared), leaving the next sentinel pass to re-stamp normally. Note this is a genuine seed, unlike the pass-3 one that was deleted: it keys on the *presence* of `newOnlyStamped` with a missing consent instant, the shape the deleted seed explicitly did not cover. + - **Tests:** 5 new `HealthConnectPrefsStoreTest` cases — 2 concurrency (two forced-interleaved `update` writers both land; six threads advancing all six watermarks concurrently all survive) and 3 for the recovery (recovers from the oldest watermark + persists + re-arms the clamp across `clearWatermarks`; leaves `EXPORT_ALL` / not-yet-stamped / already-recorded blobs alone, in particular never *lowering* a recorded consent; no-ops with no watermarks). **Verified:** full `testDebugUnitTest` — 1033 tests, 0 failures (health package 158) — and `compileDebugKotlin` green. + +- **2026-08-20 — PR #50 review pass 5 (1 MAJOR, 1 MEDIUM, 5 MINOR, 2 NIT), all addressed** (branch `feat/health-connect-foundation`). + - **MAJOR — "Remove PulseLoop data" ran in the settings screen's `rememberCoroutineScope()`.** Leaving the screen (back-press, or the composable otherwise leaving composition) cancelled the removal at the next `deleteRecords` suspension: some of the 15 record classes deleted, the rest alive, `clearWatermarks()` + the `enabled=false` / `backfillChoice=NOT_ASKED` reset never run, and no status message anywhere. The watermarks then claim records were exported that no longer exist, and write-only means nothing re-exports them — the one outcome this feature cannot repair. **Fixed:** new `health/HealthConnectRemovalWorker.kt` (unique work `health_connect_removal`, `ExistingWorkPolicy.KEEP` so a double-tap can't start two, no retry) owns the run; `removeAll`'s delete loop + state reset are wrapped in `NonCancellable`; and the outcome is reported through a new persisted `HealthConnectPrefs.removalStatus` (sentinel `REMOVAL_IN_PROGRESS` while running — the button reads "Removing…" and is disabled — then a finished message in a dismissible card), so it survives navigation, rotation and process death. + - **MEDIUM — the removal/export race was not actually closed.** `HealthConnectExportWorker.cancel()` calls `cancelUniqueWork`, which only *records* the cancellation; a RUNNING worker keeps executing to its next suspension point. So a pass mid-`insertRecords` could re-write records the removal had just deleted, and its non-suspending `setWatermark` could land after `clearWatermarks()`. **Fixed:** a process-wide `HealthConnectExportWorker.passMutex` that the pass (`doWork`) and `removeAll` both take — the genuine iOS `isSyncing`-latch analogue. Cancel-then-lock: the queued pass is dropped, an in-flight one is waited out. + - **MINOR — full revocation was a dead end in-app.** `isConnected` going false hid the Data Types card *and* the whole "Remove exported data" card, exactly when a user most wants to clear what was already exported; and there was no entry point to the Health Connect app anywhere, which is also the only way out of the platform's "denied twice → the sheet stops appearing" state (the master switch then silently does nothing). **Fixed:** the last-sync + removal cards are now gated on `isConnected || hasExportState` rather than `isConnected`, the removal card explains in red that deleting needs WRITE and PulseLoop has none, and an always-present "Open Health Connect" button (`HealthConnectClient.getHealthConnectManageDataIntent`, falling back to the store listing) lands the user on PulseLoop's own page in Health Connect — permissions plus the provider's own delete. + - **MINOR — `availability` was a keyless `remember {}`.** After "Install / Update Health Connect" → Play → back, the screen kept rendering the unavailable card and `LaunchedEffect(availability)` never re-fired, until the user left and re-entered. **Fixed:** re-read on every `ON_RESUME` via a `LifecycleEventObserver`, so the whole screen (and the reconcile it keys) recovers by itself. + - **MINOR — an archive restore could silently void an `EXPORT_ALL` backfill.** `DataArchiveService.importFile` stamped all six watermarks to `now` gated only on `enabled`. With `enabled=true` + `NOT_ASKED` (the first-enable dialog still up), a restore followed by "Sync all history" exported nothing — EXPORT_ALL means "from epoch" purely by way of null watermarks, and no path resets them. **Fixed:** the stamp additionally requires `backfillChoice != NOT_ASKED`. + - **MINOR — the rationale screen under-disclosed.** `HealthConnectRationaleActivity` is the permission sheet's privacy-policy target but listed only the Phase 1–4 types; blood pressure, glucose, respiratory rate, VO₂max, resting HR and nutrition are all requested and written. **Fixed:** all 13 exported types are named, with a note on which are user-logged rather than ring-sourced. + - **MINOR — "Last sync:" showed no time.** `lastSyncAt` was persisted but never rendered, so the card read `Last sync: skipped: nutrition (nutrition feature off)`. **Fixed:** `Last sync 4h ago — ` via `DeviceHeroStatus.relativeShort`. + - **NIT — trailing never-exportable rows pinned a group watermark.** vitals / sleep / activity / nutrition dropped rows without contributing a high water, so a demo-sourced, implausible, stage-less or empty row above every exportable one stopped the group watermark below it, and every later pass re-selected and re-upserted the whole tail behind it until some newer good row happened to leapfrog it. Phase 4 solved exactly this for workouts (`invalidHighWater`) and the fix never generalized. **Fixed:** `workoutsWatermarkAdvance` is renamed `watermarkAdvance` and now drives all five groups; each exporter reports a `droppedHighWater` (`BpPairingResult.outOfRangeHighWater` for the paired case), still applied **only on a fully completed pass**. Deliberately NOT counted as dropped: a future-dated activity day and an unpaired blood-pressure row — those are *not yet* exportable rather than never, and advancing past them would lose them. + - **NIT — the stored granted set had two definitions.** The permission-sheet callback filtered its result to `HealthConnectPermissions.all` while `onAppStart`, the settings `LaunchedEffect` and the worker's SecurityException path stored `getGrantedPermissions()` verbatim — identical today (16 declared = 16 in `all`), but they would disagree on every reconcile the moment a health permission outside `all` were granted. **Fixed:** one `HealthConnectPermissionReconcile.storedSetOf()` that every path uses. + - **UI (found while driving the flow on the emulator, both pre-existing rather than introduced by this PR, fixed here anyway):** (a) the two `PulseColors.cardSoft` cards rendered near-invisible — `CardDefaults.cardColors(containerColor = …)` leaves `contentColor` `Unspecified`, so the `Text` fell back to a dark `LocalContentColor` on a dark card; all three call sites in `SettingsSubScreens.kt` (the Strava one included) now pass `contentColor = PulseColors.textPrimary`. (b) A **doubled status-bar inset** left a tall dead band above every pushed screen's title and above "Step 1 of 5": `PulseLoopApp`'s `paddedComposable` already insets the route subtree by the outer Scaffold's system bars, and then `SettingsSubScreen`, `NutritionScreen`, `DebugScreen` (inner `Scaffold` + `TopAppBar` defaults) and `OnboardingScreen` (`windowInsetsPadding(WindowInsets.statusBars)`) applied it again. All four now contribute zero insets of their own. + - **Tests:** 4 new — 2 for `BpPairingResult.outOfRangeHighWater` (out-of-range counted, unpaired never; null when nothing is out of range) and 2 for `storedSetOf` (filters to the requested set + sorts; idempotent, so an unrequested grant is not seen as a grow/shrink). **Verified:** full `testDebugUnitTest` — **1037 tests, 0 failures**; `assembleDebug` and `assembleRelease` (R8) green. + - **Runtime-verified on `emulator-5554` (API 35, windowed):** the full first-run sign-in flow (switch → the platform's 16-row permission sheet with its "Allow all" → grant → backfill dialog → Connected, `16 of 16 permission types granted`, worker `backfill choice not made yet — export gated` before the choice and `pass done: …` after); removal through the new worker (`removal done: Removed PulseLoop data from Health Connect.`, last-sync cleared, result card with Dismiss, and the backfill dialog correctly re-offered on re-enable); the fully-revoked state (`pm revoke` ×16 → the revocation-offer dialog, "Not connected", the removal card still present with its red WRITE warning, and "Open Health Connect" reachable); and both inset fixes (settings title and "Step 1 of 5" no longer sit below a dead band). diff --git a/docs/ios-sync.md b/docs/ios-sync.md index 804acd6..c1479ef 100644 --- a/docs/ios-sync.md +++ b/docs/ios-sync.md @@ -113,7 +113,7 @@ seeded-data mode). **SKIP** — no portable behavior, Android has its own indepe | ☑ | [#98](https://github.com/saksham2001/PulseLoopiOS/pull/98) `ac01555` | ~07-27 | On-device daily calorie estimation (Mifflin-St Jeor BMR + Keytel/MET active energy, HR-gated) for rings that don't report calories | **PORT** | M | `0ca53a1` | | ☑ | [#130](https://github.com/saksham2001/PulseLoopiOS/pull/130) `cf5c0f4` | ~08-04 | RWfit ring family (dual 0x7E/0xAB protocol, full metric set, service-UUID recognition) | **ADAPT** | L–XL | Backed out of PR #45, then **rebuilt from `decompiled-rwfit-official/`** on `feat/rwfit-vendor-rebuild`. Legacy `0x7E` path complete; JieLi `0xAB` framing complete but its history bodies are not decoded yet. **No hardware validation.** See below. | | ☑ | [#131](https://github.com/saksham2001/PulseLoopiOS/pull/131) `88c0f6b` | ~08-08 | Sleep hypnogram label alignment + press-and-hold stage scrubber (+ sync spinner rewrite, iOS-only) | **ADAPT** | S–M | `802789d` | -| ☐ | [#80](https://github.com/saksham2001/PulseLoopiOS/pull/80) `c1275ad` | 07-11 | **Apple Health sync → Health Connect** (per-type toggles, vitals/sleep/activity/workout export, backfill choice, remove-all). Re-triaged 2026-08-09 from SKIP: the *behaviour* ports even though HealthKit doesn't. Write-only; profile import can't port (Health Connect has no DOB/sex type). Design + 7-phase plan in [`health-connect-integration.md`](health-connect-integration.md); reference implementation is `Gadgetbridge/` at the parent repo root, not iOS. Not blocked by the Play Store — the declaration form is a publishing gate, and Gadgetbridge ships this sideload-only. | **ADAPT** | XL | | +| ☑ | [#80](https://github.com/saksham2001/PulseLoopiOS/pull/80) `c1275ad` | 07-11 | **Apple Health sync → Health Connect** (per-type toggles, vitals/sleep/activity/workout export, backfill choice, remove-all). Re-triaged 2026-08-09 from SKIP: the *behaviour* ports even though HealthKit doesn't. Write-only; profile import can't port (Health Connect has no DOB/sex type). Design + 7-phase plan in [`health-connect-integration.md`](health-connect-integration.md); reference implementation is `Gadgetbridge/` at the parent repo root, not iOS. Not blocked by the Play Store — the declaration form is a publishing gate, and Gadgetbridge ships this sideload-only. | **ADAPT** | XL | **Phases 0–6 complete** on `feat/health-connect-foundation` (write-only, 16 `WRITE_*` / 0 `READ_*`; lifecycle, removal, grant/revocation resets, archive-restore stamp, docs). Runtime-verified API 35. See `health-connect-integration.md` §8 | ## Port priority — open items (as of 2026-08-08) @@ -135,7 +135,8 @@ seeded-data mode). **SKIP** — no portable behavior, Android has its own indepe > > **Newly queued, independent of the two above:** **#80 → Health Connect** (re-triaged > 2026-08-09 from SKIP to ADAPT/XL). Design and a 7-phase implementation plan are written up in -> [`health-connect-integration.md`](health-connect-integration.md); no code yet. Start at Phase 0. +> [`health-connect-integration.md`](health-connect-integration.md). **Phases 0–6 are complete** on +> `feat/health-connect-foundation` (see the port-queue row and §8 of that doc). > Unlike every other row in this ledger, the reference implementation is **not** iOS — it's > `Gadgetbridge/` at the parent repo root, which ships this sideload-only and solves the Android > -specific problems (record identity, series bucketing, rate limits, the 1 MB record cap) that @@ -963,10 +964,11 @@ Nine first-parent items: 7 PR merges + 2 direct doc commits. Two are substantial **Skip (iOS-only / docs / CI):** - **#80 Apple Health sync** (`c1275ad`, +1,887) — HealthKit read/write with per-type toggles, - workout export, and profile import. **Intentional iOS-only divergence** (HealthKit-adjacent). - The Android analogue would be **Health Connect** — not queued, but if Android ever wants - wearable→platform sync, this PR is the reference design (per-type prefs store, workout - exporter, profile importer, sync publisher). SKIP for now. + workout export, and profile import. Originally triaged **SKIP** (HealthKit-adjacent, iOS-only), + then re-triaged 2026-08-09 to **ADAPT** → Health Connect (the *behaviour* ports even though + HealthKit doesn't). **Now complete** on `feat/health-connect-foundation` — see the port-queue + row and the Skipped table below; only profile *import* stays iOS-only (Health Connect has no + DOB/sex type). - **#81 contributors automation** (`32dfbe3`) — GitHub Action + `update_contributors.py` + README/docs. Repo governance; the Android repo has its own. SKIP. - **`b3697c0`** (move YCBT spec out of docs site, add Discord to About) and **`0f500fc`** @@ -1270,7 +1272,7 @@ main-thread access from a background worker, and Room calls on the right dispatc | [#47](https://github.com/saksham2001/PulseLoopiOS/pull/47) [#46](https://github.com/saksham2001/PulseLoopiOS/pull/46) [#39](https://github.com/saksham2001/PulseLoopiOS/pull/39) | Release-IPA CI workflow + fixes | iOS CI | | [#45](https://github.com/saksham2001/PulseLoopiOS/pull/45) [#37](https://github.com/saksham2001/PulseLoopiOS/pull/37) [#28](https://github.com/saksham2001/PulseLoopiOS/pull/28) [#23](https://github.com/saksham2001/PulseLoopiOS/pull/23) | Sideloading guide, iOS-vs-Android refresh, MkDocs site, README updates | Docs | | [#7](https://github.com/saksham2001/PulseLoopiOS/pull/7) `c9897c9` | OSS setup (templates, SwiftLint, CI) | Repo governance; Android repo has its own | -| [#80](https://github.com/saksham2001/PulseLoopiOS/pull/80) `c1275ad` | Apple Health sync (per-type toggles, workout export, profile import) | HealthKit itself is iOS-only, but the **behaviour now has an Android home**: re-triaged 2026-08-09 as **ADAPT** → Health Connect. Design + phase plan in [`health-connect-integration.md`](health-connect-integration.md); tracked in the port queue above. This row stays here only for the HealthKit-specific parts (profile import can't fully port — Health Connect has no date-of-birth or biological-sex data type) | +| [#80](https://github.com/saksham2001/PulseLoopiOS/pull/80) `c1275ad` | Apple Health sync (per-type toggles, workout export, profile import) | HealthKit itself is iOS-only, but the **behaviour now has an Android home**: re-triaged 2026-08-09 as **ADAPT** → Health Connect and **complete** on `feat/health-connect-foundation` (Phases 0–6). Design + phase plan in [`health-connect-integration.md`](health-connect-integration.md); tracked in the port queue above. This row stays here only for the HealthKit-specific parts (profile import can't fully port — Health Connect has no date-of-birth or biological-sex data type) | | [#81](https://github.com/saksham2001/PulseLoopiOS/pull/81) `32dfbe3` | Automated contributor recognition (Action + script + README) | Repo governance; Android repo has its own | | [#89](https://github.com/saksham2001/PulseLoopiOS/pull/89) `0a8ab4e` | iOS-26 Liquid Glass rendering correctness + Dynamic Type a11y | Glass is an iOS visual language (standing SKIP); portable reactivity bit folds into #88 | | `25e49fd` `577c5f3` `35d1aa7` `ee42b10` `b3697c0` `0f500fc` | Direct commits: docs/screenshots/tagline/YCBT-spec/Discord/jring-URLs | Docs | @@ -1603,4 +1605,7 @@ upstream — each entry says what changed here and whether iOS should look at it **iOS-only — not expected on Android:** - Live Activities (lock-screen workout UI) - Apple on-device coach (Apple Intelligence) -- HealthKit-adjacent integrations + +> HealthKit-adjacent export is **no longer** an iOS-only divergence: its behaviour is ported to +> Android via Health Connect (the #80 port-queue row; `health-connect-integration.md`). HealthKit +> itself (and profile *import* — Health Connect has no DOB/sex type) remains iOS-only. diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 7a8ca15..115af22 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,4 +1,4 @@ -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists networkTimeout=10000