Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
55def2e
feat(health): Phase 0 foundation for Health Connect export
foureight84 Aug 15, 2026
c8253bc
docs(health): record Phase 0 runtime verification on API 35 AVD
foureight84 Aug 15, 2026
66e93b6
feat(health): Phase 1 — vitals export engine (HR series, SpO2, HRV, t…
foureight84 Aug 15, 2026
72641bb
feat(health): Phase 2 — sleep export
foureight84 Aug 16, 2026
1ec0c1e
feat(health): Phase 3 — daily activity export (steps, active calories…
foureight84 Aug 16, 2026
44d9794
feat(health): Phase 4 — workouts + GPS route export
foureight84 Aug 16, 2026
1366c97
docs(health): record Phase 4 implementation + runtime verification on…
foureight84 Aug 16, 2026
f50f8a3
docs(health): update plan status to Phases 0-4 complete
foureight84 Aug 16, 2026
d2772ad
fix(health): Phase 4 observer stage-B fixes — watermark leapfrog, del…
foureight84 Aug 16, 2026
054b5d2
fix(health): query vitals by kindRaw name, not key
foureight84 Aug 17, 2026
4e7b36b
feat(health): Phase 5 — BP, glucose, resp rate, VO2max, resting HR, n…
foureight84 Aug 17, 2026
c25c925
feat(health): Phase 6 — removal, grant/revocation resets, meal update…
foureight84 Aug 17, 2026
c371512
fix(health): clear revocation-offer flag on out-of-band grow (pre-mer…
foureight84 Aug 17, 2026
0920c59
docs(health): refresh stale §8 section status line (Phases 0-6 complete)
foureight84 Aug 17, 2026
43a3be1
fix(health): guard launcher revocation offer with hadSync (pre-merge …
foureight84 Aug 17, 2026
bef3048
fix(health): address PR #50 code review (1 CRITICAL, 3 MAJOR, 4 MINOR…
foureight84 Aug 19, 2026
e9b36a4
fix(health): address PR #50 review pass 2 (1 MAJOR, 2 MINOR, 1 NIT)
foureight84 Aug 19, 2026
0b2302d
fix(health): address PR #50 review pass 3 (1 MAJOR, 3 MINOR)
foureight84 Aug 19, 2026
e7f66f6
fix(health): address PR #50 review pass 4 (1 MINOR, 1 NIT)
foureight84 Aug 20, 2026
e70dd68
build: bump versionCode to 37
foureight84 Aug 20, 2026
11abb92
fix(health): address PR #50 review pass 5 (1 MAJOR, 1 MEDIUM, 5 MINOR…
foureight84 Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
.gradle/
local.properties
build/
dist/
*.keystore
*.jks
*.keystore
Expand Down
11 changes: 9 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"

Expand Down Expand Up @@ -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")

Expand Down
61 changes: 60 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<!-- Phase 2: BLE permissions -->
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/>
Expand Down Expand Up @@ -32,6 +33,30 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />

<!-- Phase 8: Health Connect export (write-only). Exactly the Phase 1–4 data types from
docs/health-connect-integration.md — Phase 5 types (blood pressure, glucose, respiratory
rate, VO2max, resting HR, nutrition) - the Phase 5 "beyond iOS" types, added in Phase 5 so the
first permission sheet stayed narrow. No READ_* permissions anywhere. -->
<uses-permission android:name="android.permission.health.WRITE_HEART_RATE" />
<uses-permission android:name="android.permission.health.WRITE_OXYGEN_SATURATION" />
<uses-permission android:name="android.permission.health.WRITE_HEART_RATE_VARIABILITY" />
<uses-permission android:name="android.permission.health.WRITE_BODY_TEMPERATURE" />
<uses-permission android:name="android.permission.health.WRITE_SLEEP" />
<uses-permission android:name="android.permission.health.WRITE_STEPS" />
<uses-permission android:name="android.permission.health.WRITE_ACTIVE_CALORIES_BURNED" />
<uses-permission android:name="android.permission.health.WRITE_DISTANCE" />
<uses-permission android:name="android.permission.health.WRITE_EXERCISE" />
<!-- Singular on purpose: the official docs note WRITE_EXERCISE_ROUTE is singular while
READ_EXERCISE_ROUTES is plural. -->
<uses-permission android:name="android.permission.health.WRITE_EXERCISE_ROUTE" />
<!-- Phase 5 (beyond iOS). Verbatim values of HealthPermission.WRITE_* (1.1.0 AAR). -->
<uses-permission android:name="android.permission.health.WRITE_BLOOD_PRESSURE" />
<uses-permission android:name="android.permission.health.WRITE_BLOOD_GLUCOSE" />
<uses-permission android:name="android.permission.health.WRITE_RESPIRATORY_RATE" />
<uses-permission android:name="android.permission.health.WRITE_VO2_MAX" />
<uses-permission android:name="android.permission.health.WRITE_RESTING_HEART_RATE" />
<uses-permission android:name="android.permission.health.WRITE_NUTRITION" />

<application
android:name=".PulseLoopApplication"
android:allowBackup="false"
Expand Down Expand Up @@ -119,5 +144,39 @@
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>

<!-- Phase 8: Health Connect rationale — target of the "privacy policy" link in the
permission sheet, mandatory for apps requesting health data types (official
get-started guide). Pre-34 fires ACTION_SHOW_PERMISSIONS_RATIONALE at the activity;
API 34+ reaches the alias via VIEW_PERMISSION_USAGE + HEALTH_PERMISSIONS, guarded
by START_VIEW_PERMISSION_USAGE. In-app rationale only, no hosted URL (Gadgetbridge
precedent). -->
<activity
android:name=".health.HealthConnectRationaleActivity"
android:exported="true">
<intent-filter>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent-filter>
</activity>
<activity-alias
android:name=".health.ViewPermissionUsageActivity"
android:exported="true"
android:targetActivity=".health.HealthConnectRationaleActivity"
android:permission="android.permission.START_VIEW_PERMISSION_USAGE"
tools:targetApi="34">
<intent-filter>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
</intent-filter>
</activity-alias>
</application>

<!-- Health Connect: provider app (the separate APK on API < 34) plus the rationale action,
so the permission sheet's privacy-policy link resolves to our activity. -->
<queries>
<package android:name="com.google.android.apps.healthdata" />
<intent>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent>
</queries>
</manifest>
7 changes: 7 additions & 0 deletions app/src/main/java/com/pulseloop/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/com/pulseloop/data/DataArchive.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
22 changes: 21 additions & 1 deletion app/src/main/java/com/pulseloop/data/DataArchiveService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ->
Expand Down Expand Up @@ -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) {
Expand All @@ -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
}

Expand Down
20 changes: 19 additions & 1 deletion app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import com.pulseloop.data.entity.*
MealEntryEntity::class,
CachedFoodProductEntity::class,
],
version = 20,
version = 21,
exportSchema = false,
)
abstract class PulseLoopDatabase : RoomDatabase() {
Expand Down Expand Up @@ -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-<id>` 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(
Expand Down Expand Up @@ -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
Expand Down
83 changes: 83 additions & 0 deletions app/src/main/java/com/pulseloop/data/dao/Daos.kt
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,23 @@ interface MeasurementDao {
GROUP BY bucket ORDER BY bucket ASC
""")
suspend fun hourlyAggregates(kind: String, start: Long, end: Long): List<Bucket>

/**
* 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<MeasurementEntity>

/**
* 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<MeasurementEntity>
}

@Dao
Expand All @@ -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<ActivityDailyEntity>
}

@Dao
Expand Down Expand Up @@ -181,6 +213,29 @@ interface ActivitySessionDao {
@Query("SELECT * FROM activity_sessions WHERE statusRaw = 'finished' AND endedAt >= :cutoff")
suspend fun finishedSince(cutoff: Long): List<ActivitySessionEntity>

// 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<ActivitySessionEntity>

// 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-<id> 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<ActivitySessionEntity>

@Upsert
suspend fun upsert(session: ActivitySessionEntity)
}
Expand All @@ -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<ActivityGpsPointEntity>

/** 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<String>): List<ActivityGpsPointEntity>

@Insert
suspend fun insert(point: ActivityGpsPointEntity)
}
Expand Down Expand Up @@ -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<SleepSessionEntity>

/**
* 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<SleepSessionEntity>

/** 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?
Expand Down Expand Up @@ -415,6 +491,13 @@ interface MealEntryDao {
""")
suspend fun dayTotals(day: Long): List<MealTotals>

/** 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<MealEntryEntity>

@Upsert
suspend fun upsert(entry: MealEntryEntity)

Expand Down
Loading