Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
246 changes: 240 additions & 6 deletions AGENTS.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions app/src/main/java/com/pulseloop/PulseLoopApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class PulseLoopApplication : Application() {
// main thread and is prefs-gated so it executes once. Safe to race the first sync — the
// ring can't connect before this completes a couple of DELETE statements.
appScope.launch { DataRepairs.runIfNeeded(this@PulseLoopApplication) }
appScope.launch { DataRepairs.repairSleepDurationsIfNeeded(this@PulseLoopApplication) }

// Home-screen widgets (iOS #44): publish the snapshot on every foreground/background
// edge (the iOS scene-phase triggers — catches goal/unit/profile edits that don't run
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.pulseloop.coach.context.CoachContextBuilder
import com.pulseloop.coach.context.CoachContextPacket
import com.pulseloop.data.PulseLoopDatabase
import com.pulseloop.data.entity.*
import com.pulseloop.ring.SleepStage
import com.pulseloop.service.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
Expand Down Expand Up @@ -107,9 +108,12 @@ object CoachSummaryContextBuilder {
val deepPct: Int,
val activitySteps: Int?,
)
val deepMin = blocks.filter { it.stageRaw == "deep" }.sumOf { it.durationMinutes }
val lightMin = blocks.filter { it.stageRaw == "light" }.sumOf { it.durationMinutes }
val awakeMin = blocks.filter { it.stageRaw == "awake" }.sumOf { it.durationMinutes }
// stageRaw is persisted as the SleepStage enum NAME, which is uppercase. These three
// matched lowercase literals and were therefore always zero, so every sleep summary the
// coach has ever been given said the user had no deep, light or awake sleep at all.
val deepMin = blocks.filter { it.stageRaw == SleepStage.DEEP.name }.sumOf { it.durationMinutes }
val lightMin = blocks.filter { it.stageRaw == SleepStage.LIGHT.name }.sumOf { it.durationMinutes }
val awakeMin = blocks.filter { it.stageRaw == SleepStage.AWAKE.name }.sumOf { it.durationMinutes }

val p = SleepDayPacket(
date = session.date.toString(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -635,17 +635,18 @@ object ActionTools {
if (coordinator == null || !coordinator.isConnected) {
ToolResult("""{"status":"unavailable","note":"Ring is not connected — cannot take a live reading."}""")
} else {
kotlinx.coroutines.runBlocking {
// The leg's return value is the measurement's only output (issue #59/#60): the
// settled reading, or null when it failed. The live mirrors are deliberately NOT it —
// they hold the last raw sample the ring streamed, which on a converging sensor is the
// pre-converged plateau, and they are not cleared when a measurement fails. Reading
// them here reported a stale 46 bpm as a completed measurement.
val value = kotlinx.coroutines.runBlocking {
when (kind) {
"hr" -> coordinator.measureHR()
"spo2" -> coordinator.measureSpO2()
else -> null
}
}
val value = when (kind) {
"hr" -> coordinator.latestHRValue
"spo2" -> coordinator.latestSpO2Value
else -> null
}
if (value != null) {
ToolResult("""{"status":"completed","kind":"$kind","value":$value,"unit":"${if (kind == "hr") "bpm" else "%"}"}""")
} else {
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/java/com/pulseloop/data/DataArchive.kt
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ data class PulseArchive(
/** iOS #96 nutrition. Not in iOS's own `DataArchive.swift` — see the note on [MealEntryDTO]. */
val mealEntries: List<MealEntryDTO> = emptyList(),
val foodProducts: List<CachedFoodProductDTO> = emptyList(),
/**
* Issue #60: readings the user deleted. Carried in the archive because a restore wipes every
* table first — without these, a restore would forget the deletions while the ring still holds
* the days behind them, and the next sync would put every deleted reading back.
*/
val measurementDeletions: List<MeasurementDeletionDTO> = emptyList(),
)

@Serializable data class DeviceDTO(
Expand All @@ -54,6 +60,10 @@ data class PulseArchive(
val createdAt: Long,
)

@Serializable data class MeasurementDeletionDTO(
val measurementId: String, val kindRaw: String, val timestamp: Long, val deletedAt: Long,
)

@Serializable data class ActivityDailyDTO(
val id: String, val date: Long, val steps: Int = 0, val calories: Double = 0.0,
val distanceMeters: Double = 0.0, val activeMinutes: Int = 0,
Expand Down
48 changes: 39 additions & 9 deletions app/src/main/java/com/pulseloop/data/DataArchiveService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,12 @@ object DataArchiveService {
lastUsedAt = c.long("lastUsedAt"), useCount = c.int_("useCount"),
)
},
measurementDeletions = collect("measurement_deletions") { c ->
MeasurementDeletionDTO(
measurementId = c.str("measurementId"), kindRaw = c.str("kindRaw"),
timestamp = c.long("timestamp"), deletedAt = c.long("deletedAt"),
)
},
)
}

Expand Down Expand Up @@ -369,6 +375,18 @@ object DataArchiveService {
rawPacketId = m.rawPacketId, createdAt = m.createdAt,
))
}
// Restore the tombstones BEFORE anything can sync against them (issue #60): a restored
// history reading the user had deleted must not survive the round trip.
if (archive.measurementDeletions.isNotEmpty()) {
db.measurementDeletionDao().insertAll(
archive.measurementDeletions.map {
MeasurementDeletionEntity(
measurementId = it.measurementId, kindRaw = it.kindRaw,
timestamp = it.timestamp, deletedAt = it.deletedAt,
)
}
)
}
for (a in archive.activityDaily) {
db.activityDailyDao().upsert(ActivityDailyEntity(
id = a.id, date = a.date, steps = a.steps, calories = a.calories,
Expand Down Expand Up @@ -448,20 +466,32 @@ object DataArchiveService {
sp.errorMessage?.let { put("errorMessage", it) }
})
}
// A backup written before issue #63 stores each night's span as its duration, and the
// one-time repair has already run (and will not again) on the install restoring it —
// so restate every restored night from its own blocks here, exactly as the repair
// does, rather than carry the archived number through verbatim. A session with no
// blocks in the archive keeps what it had; there is nothing to recompute from.
val restoredBlocks = archive.sleepStageBlocks.map { block ->
SleepStageBlockEntity(
id = block.id, sessionId = block.sessionId, startAt = block.startAt,
startMinute = block.startMinute, durationMinutes = block.durationMinutes,
stageRaw = block.stageRaw,
)
}.groupBy { it.sessionId }
for (ss in archive.sleepSessions) {
db.sleepSessionDao().upsert(SleepSessionEntity(
val archived = SleepSessionEntity(
id = ss.id, date = ss.date, startAt = ss.startAt, endAt = ss.endAt,
totalMinutes = ss.totalMinutes, score = ss.score, syncedAt = ss.syncedAt,
sourceRaw = ss.sourceRaw, createdAt = ss.createdAt, updatedAt = ss.updatedAt,
))
}
for (block in archive.sleepStageBlocks) {
db.sleepStageBlockDao().insert(SleepStageBlockEntity(
id = block.id, sessionId = block.sessionId, startAt = block.startAt,
startMinute = block.startMinute, durationMinutes = block.durationMinutes,
stageRaw = block.stageRaw,
))
)
val blocks = restoredBlocks[ss.id].orEmpty()
val restated = if (blocks.isEmpty()) archived else {
val asleep = archived.copy(totalMinutes = com.pulseloop.service.asleepMinutes(blocks))
asleep.copy(score = com.pulseloop.service.SleepScore.calculate(asleep, blocks).score)
}
db.sleepSessionDao().upsert(restated)
}
restoredBlocks.values.flatten().forEach { db.sleepStageBlockDao().insert(it) }
for (conv in archive.coachConversations) {
db.coachConversationDao().upsert(CoachConversationEntity(
id = conv.id, title = conv.title, createdAt = conv.createdAt,
Expand Down
44 changes: 44 additions & 0 deletions app/src/main/java/com/pulseloop/data/DataRepairs.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package com.pulseloop.data

import android.content.Context
import androidx.room.withTransaction
import com.pulseloop.service.SleepScore
import com.pulseloop.service.asleepMinutes
import com.pulseloop.util.TimeUtil

/**
Expand Down Expand Up @@ -45,4 +48,45 @@ object DataRepairs {
}
prefs.edit().putBoolean(key, true).apply()
}

/**
* Restate every stored night's `totalMinutes` as time asleep rather than the span from its
* start to its end (issue #63). Ring history only reaches back about a week, so a re-sync
* would leave every older night reading the old way indefinitely — and the two numbers differ
* by the awake stretches plus, on a night the ring split into two records, the gap between
* them: 8 h 10 against 6 h 48 on the reporter's night.
*
* Recomputed from each session's own stage blocks, which are exact — they are a run-length
* encoding of a per-minute stage list and are de-overlapped on merge. A session with no blocks
* left is skipped rather than zeroed: there is nothing to recompute from, and a zero would
* hide the night entirely (`byDay` and `earliestDay` both filter on `totalMinutes > 0`).
* Demo rows are repaired too, so a seeded night and a real one report the same kind of number.
*
* The stored `score` is recomputed with it, since the score's denominators moved with the
* definition. And the whole pass is one transaction: it runs at app start alongside the first
* sync, and a row-by-row read-modify-write outside one could overwrite a night the reconcile
* had just rewritten with a stale snapshot of it.
*/
suspend fun repairSleepDurationsIfNeeded(
context: Context,
db: PulseLoopDatabase = PulseLoopDatabase.getInstance(context),
) {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val key = "sleepAsleepMinutesRepair.v1"
if (prefs.getBoolean(key, false)) return
val now = System.currentTimeMillis()
db.withTransaction {
for (session in db.sleepSessionDao().all()) {
val blocks = db.sleepStageBlockDao().forSession(session.id)
if (blocks.isEmpty()) continue
val asleep = asleepMinutes(blocks)
if (asleep == session.totalMinutes) continue
val restated = session.copy(totalMinutes = asleep, updatedAt = now)
db.sleepSessionDao().upsert(
restated.copy(score = SleepScore.calculate(restated, blocks).score)
)
}
}
prefs.edit().putBoolean(key, true).apply()
}
}
6 changes: 4 additions & 2 deletions app/src/main/java/com/pulseloop/data/DemoDataSeeder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -281,10 +281,12 @@ object DemoDataSeeder {
block
}

// `totalMinutes` is time asleep, not the span (issue #63) — the pattern above tiles the
// whole night including its two awake stretches, so the headline is the blocks less those.
val session = SleepSessionEntity(
id = sessionId, date = wakeDayStart,
startAt = sleepStart, endAt = wake,
totalMinutes = totalMinutes,
totalMinutes = com.pulseloop.service.asleepMinutes(blocks),
sourceRaw = "demo",
)
db.sleepStageBlockDao().deleteBySession(sessionId)
Expand All @@ -301,7 +303,7 @@ object DemoDataSeeder {
val napSession = SleepSessionEntity(
id = napId, date = wakeDayStart,
startAt = napStart, endAt = napEnd,
totalMinutes = nap.minutes,
totalMinutes = com.pulseloop.service.asleepMinutes(napBlocks),
sourceRaw = "demo",
)
db.sleepStageBlockDao().deleteBySession(napId)
Expand Down
64 changes: 64 additions & 0 deletions app/src/main/java/com/pulseloop/data/MeasurementDeletion.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.pulseloop.data

import androidx.room.withTransaction
import com.pulseloop.data.entity.MeasurementEntity
import com.pulseloop.ring.MeasurementKind

/**
* Deleting individual readings (issue #60).
*
* A measurement can be wrong in ways nothing downstream can detect — a ring worn loosely, a
* measurement started by mistake, a reading taken mid-movement — and until now there was no way to
* remove one, so a bad value stayed in the record forever and dragged every average computed over
* it. Deletion only: a recorded health value may be removed, never edited into a different number.
*
* Two rules make a delete actually stick, and both live here so no caller has to remember them:
*
* * **Tombstone anything the ring can re-send.** History rows are keyed `history:<kind>:<ts>` and
* written with `upsert` so a re-synced day is idempotent; without a tombstone the next sync
* would restore exactly the reading the user removed.
* * **Delete a blood-pressure reading as a pair.** One reading is stored as two rows (systolic and
* diastolic) sharing a timestamp. Removing one would leave a half reading that charts as a
* systolic with no diastolic.
*/
object MeasurementDeletion {

/** The two rows one blood-pressure reading is stored as. */
private val BLOOD_PRESSURE_KINDS = listOf(
MeasurementKind.BLOOD_PRESSURE_SYSTOLIC,
MeasurementKind.BLOOD_PRESSURE_DIASTOLIC,
)

/**
* Delete [measurements] and remember the ones a later sync could rewrite. Atomic: a delete that
* lost its tombstone half would come back on the next sync, which is the bug this exists to
* prevent.
*
* Returns the number of rows removed.
*/
suspend fun delete(db: PulseLoopDatabase, measurements: List<MeasurementEntity>): Int {
if (measurements.isEmpty()) return 0
return db.withTransaction {
db.measurementDeletionDao().record(measurements)
db.measurementDao().deleteByIds(measurements.map { it.id })
measurements.size
}
}

/** [delete], resolving ids back to rows first — what the readings list has to hand. */
suspend fun deleteByIds(db: PulseLoopDatabase, ids: List<String>): Int {
if (ids.isEmpty()) return 0
return delete(db, db.measurementDao().byIds(ids))
}

/**
* Delete the blood-pressure reading taken at [timestamp] — both of its rows, whichever of them
* the caller happened to be looking at.
*/
suspend fun deleteBloodPressureAt(db: PulseLoopDatabase, timestamp: Long): Int {
val rows = BLOOD_PRESSURE_KINDS.flatMap { kind ->
db.measurementDao().range(kind.name, timestamp, timestamp)
}
return delete(db, rows)
}
}
29 changes: 27 additions & 2 deletions app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,16 @@ import com.pulseloop.data.entity.*
CoachNotificationRecordEntity::class,
MealEntryEntity::class,
CachedFoodProductEntity::class,
MeasurementDeletionEntity::class,
],
version = 23,
version = 24,
exportSchema = false,
)
abstract class PulseLoopDatabase : RoomDatabase() {
abstract fun deviceDao(): DeviceDao
abstract fun measurementDao(): MeasurementDao
/** Issue #60: the tombstones that keep a deleted reading deleted across re-syncs. */
abstract fun measurementDeletionDao(): MeasurementDeletionDao
abstract fun activityDailyDao(): ActivityDailyDao
abstract fun activityBucketDao(): ActivityBucketDao
abstract fun deviceMeasurementConfigDao(): DeviceMeasurementConfigDao
Expand Down Expand Up @@ -107,7 +110,7 @@ abstract class PulseLoopDatabase : RoomDatabase() {
"coach_tool_calls", "user_profiles", "user_goals",
"raw_packets", "derived_updates", "coach_summaries",
"wearable_logs", "coach_notification_records",
"meal_entries", "food_products",
"meal_entries", "food_products", "measurement_deletions",
)

@Volatile private var INSTANCE: PulseLoopDatabase? = null
Expand Down Expand Up @@ -442,6 +445,27 @@ abstract class PulseLoopDatabase : RoomDatabase() {
}
}

/**
* v23 → v24: the deleted-reading tombstones (issue #60). Deleting a history-sourced
* measurement only sticks if the deletion outlives the row — see
* [com.pulseloop.data.entity.MeasurementDeletionEntity].
*/
private val MIGRATION_23_24 = object : Migration(23, 24) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `measurement_deletions` (
`measurementId` TEXT NOT NULL,
`kindRaw` TEXT NOT NULL,
`timestamp` INTEGER NOT NULL,
`deletedAt` INTEGER NOT NULL,
PRIMARY KEY(`measurementId`)
)
""".trimIndent()
)
}
}

private fun adoptStableMeasurementIdentities(db: SupportSQLiteDatabase) {
db.execSQL("DROP INDEX IF EXISTS `index_measurements_kindRaw_timestamp_sourceRaw`")
db.execSQL(
Expand Down Expand Up @@ -531,6 +555,7 @@ abstract class PulseLoopDatabase : RoomDatabase() {
MIGRATION_20_21,
MIGRATION_21_22,
MIGRATION_22_23,
MIGRATION_23_24,
)
// Downgrades only (sideloading an older APK). A blanket destructive
// fallback would silently wipe every measurement, sleep session, and
Expand Down
Loading