Skip to content
Draft
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
67 changes: 66 additions & 1 deletion app/src/main/java/com/pulseloop/health/HealthConnectExporter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,19 @@ 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.SleepSessionRecord
import androidx.health.connect.client.records.metadata.Device
import androidx.health.connect.client.time.TimeRangeFilter
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.CancellationException
import kotlinx.coroutines.delay
import java.time.Instant

/**
* Chunk + retry progress for one kind's insert pass (see [healthConnectInsertChunked]).
Expand Down Expand Up @@ -165,6 +169,38 @@ internal fun watermarkAdvance(
internal fun effectiveWatermark(storedWatermark: Long?, newOnlyConsentAt: Long?): Long =
maxOf(storedWatermark ?: 0L, newOnlyConsentAt ?: 0L)

internal fun sleepIdentityV2MigrationRequired(
prefs: HealthConnectPrefs,
granted: Set<String>,
): Boolean = prefs.enabled &&
prefs.backfillChoice != HealthConnectPrefs.BackfillChoice.NOT_ASKED &&
prefs.sleep &&
HealthConnectPermissions.sleep.first() in granted &&
!prefs.sleepIdentityV2Done

/**
* Functional seam for the ordered one-time sleep identity migration. A false gate is a successful
* no-op; a failed delete returns false without resetting the watermark or setting the marker.
*/
internal suspend fun migrateSleepIdentityV2(
required: Boolean,
deleteLegacyRecords: suspend () -> Unit,
resetSleepWatermark: () -> Unit,
markDone: () -> Unit,
): Boolean {
if (!required) return true
return try {
deleteLegacyRecords()
resetSleepWatermark()
markDone()
true
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
false
}
}

/**
* 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
Expand Down Expand Up @@ -369,14 +405,43 @@ class HealthConnectExporter(
if (groupHigh > (vitalsWm ?: 0L)) store.setWatermark(HealthConnectWatermarks.Key.VITALS, groupHigh)
}

val sleepMigrationRequired = sleepIdentityV2MigrationRequired(prefs, granted)
val sleepIdentityReady = migrateSleepIdentityV2(
required = sleepMigrationRequired,
deleteLegacyRecords = {
// Health Connect scopes this range deletion to records written by the calling app;
// records from other apps and PulseLoop's Room data are not touched.
client.deleteRecords(
SleepSessionRecord::class,
TimeRangeFilter.after(Instant.EPOCH),
)
},
resetSleepWatermark = {
store.resetWatermarks(setOf(HealthConnectWatermarks.Key.SLEEP))
},
markDone = {
store.update { it.copy(sleepIdentityV2Done = true) }
},
)
if (sleepMigrationRequired && sleepIdentityReady) {
// The reset invalidated the snapshot; the normal sleep exporter rebuilds v2 records
// from the allowed Room window in this same pass.
wm0 = store.currentWatermarks
} else if (!sleepIdentityReady) {
errors += "sleep migration: could not replace legacy Health Connect sleep records; " +
"sleep export was skipped and will retry on the next pass"
}

// ── Sleep group (Phase 2; watermarked on SleepSessionEntity.updatedAt — a re-synced
// night re-upserts the same pl-sleep-<dayEpochMs> record in place) ──
// session re-upserts the same stable v2 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 if (!sleepIdentityReady) {
skipped += "sleep (stable identity migration pending)"
} else {
val sleepPending = SleepExporter(db).build(effectiveWatermark(wm0.sleep, prefs.newOnlyConsentAt), device)
val sleepProgress = insertChunked(sleepPending.records, sleepPending.highWaters) { chunk ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ data class HealthConnectPrefs(
* decode), which is exactly the "still needs the flip" state for upgrading users.
*/
val nettingFlipDone: Boolean = false,
/**
* One-time marker for replacing role-based sleep record identities with stable v2 identities.
* Old blobs decode to false; removal deliberately leaves this true because it deletes the
* calling app's Health Connect records rather than restoring legacy identities.
*/
val sleepIdentityV2Done: 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ 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.nio.charset.StandardCharsets
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
import java.util.UUID

/**
* Pure identity + mapping helpers for the Health Connect export — the Android port of the iOS
Expand Down Expand Up @@ -59,6 +61,17 @@ object HealthConnectTypeMappings {
fun hrRecordId(hourStartEpochMs: Long, segmentIndex: Int? = null): String =
if (segmentIndex == null) "pl-hr-$hourStartEpochMs" else "pl-hr-$hourStartEpochMs-$segmentIndex"

/**
* Bounded v2 sleep identity derived only from the stable Room session id. Corrections can
* change a session's duration, bounds, waking day, or main/nap role without changing this id.
*/
fun sleepSessionRecordIdV2(sessionId: String): String {
val uuid = UUID.nameUUIDFromBytes(
"pulseloop:sleep:v2:$sessionId".toByteArray(StandardCharsets.UTF_8),
)
return "pl-sleep-v2-$uuid"
}

// ── time helpers ──

/** Local-midnight start of the hour containing [epochMs] (epoch millis). */
Expand Down
101 changes: 51 additions & 50 deletions app/src/main/java/com/pulseloop/health/exporters/SleepExporter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ 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.data.entity.SleepStageBlockEntity
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
Expand All @@ -21,9 +21,8 @@ import java.time.ZoneId
* ([HealthConnectTypeMappings.normalizeSleepStages]) — with the client's stage-type constants
* (never raw ints).
*
* Identity (plan §3, identity trap #1): `clientRecordId = pl-sleep-<dayEpochMs>` 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
* Identity: the bounded v2 `clientRecordId` is derived only from the stable
* [SleepSessionEntity.id], never from waking-day main/nap rank. `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
Expand Down Expand Up @@ -76,11 +75,6 @@ class SleepExporter(private val db: PulseLoopDatabase) {
.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<Long, List<SleepSessionEntity>>()

val records = mutableListOf<Record>()
val highWaters = mutableListOf<Long>()
var skipped = 0
Expand All @@ -91,56 +85,63 @@ class SleepExporter(private val db: PulseLoopDatabase) {
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 recordId = HealthConnectTypeMappings.sleepSessionRecordIdV2(session.id)

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()) {
val record = buildSleepSessionRecord(
session,
blocksBySession[session.id].orEmpty(),
recordId,
device,
zone,
)
if (record == null) {
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,
)
},
)
records += record
highWaters += session.updatedAt
}
return PendingSleep(records, highWaters, skipped, droppedHigh)
}
}

internal fun buildSleepSessionRecord(
session: SleepSessionEntity,
blocks: List<SleepStageBlockEntity>,
recordId: String,
device: Device,
zone: ZoneId,
): SleepSessionRecord? {
if (session.endAt <= session.startAt) return null
val spans = blocks.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()) return null

val start = Instant.ofEpochMilli(session.startAt)
val end = Instant.ofEpochMilli(session.endAt)
return 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,
)
},
)
}
26 changes: 23 additions & 3 deletions app/src/main/java/com/pulseloop/ring/PulseEventBus.kt
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,30 @@ sealed class PulseEvent {
data class HrvSample(val value: Int, val timestamp: java.time.Instant) : PulseEvent()
data class TemperatureSample(val celsius: Double, val timestamp: java.time.Instant, val isHistory: Boolean = false) : PulseEvent()
data class SleepTimeline(
val timestamp: java.time.Instant,
val stages: List<SleepStage>,
val sessionStart: java.time.Instant,
val sessionEnd: java.time.Instant,
val segments: List<SleepStageSegment>,
val completeSession: Boolean = false,
) : PulseEvent()
) : PulseEvent() {
constructor(
timestamp: java.time.Instant,
stages: List<SleepStage>,
completeSession: Boolean = false,
) : this(
sessionStart = timestamp,
sessionEnd = timestamp.plusSeconds(stages.size * 60L),
segments = contiguousSleepSegments(timestamp, stages),
completeSession = completeSession,
)

val timestamp: java.time.Instant get() = sessionStart
val stages: List<SleepStage> get() = segments.flatMap { segment ->
val minutes = kotlin.math.round(
java.time.Duration.between(segment.start, segment.end).seconds / 60.0
).toInt().coerceAtLeast(0)
List(minutes) { segment.stage }
}
}
data class SyncProgress(val stage: String) : PulseEvent()
data class FirmwareVersion(val version: Int?) : PulseEvent()
/** The ring's firmware version as a display string (CRP `3/3` → `MOY-R1K3-2.1.6`). Distinct
Expand Down
45 changes: 43 additions & 2 deletions app/src/main/java/com/pulseloop/ring/RingDecodedEvent.kt
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,28 @@ enum class SleepStage {
}
}

data class SleepStageSegment(
val stage: SleepStage,
val start: Instant,
val end: Instant,
)

internal fun contiguousSleepSegments(start: Instant, stages: List<SleepStage>): List<SleepStageSegment> {
if (stages.isEmpty()) return emptyList()
val segments = mutableListOf<SleepStageSegment>()
var stage = stages.first()
var segmentStart = start
for (index in 1 until stages.size) {
if (stages[index] == stage) continue
val segmentEnd = start.plusSeconds(index * 60L)
segments += SleepStageSegment(stage, segmentStart, segmentEnd)
stage = stages[index]
segmentStart = segmentEnd
}
segments += SleepStageSegment(stage, segmentStart, start.plusSeconds(stages.size * 60L))
return segments
}

/**
* Ported from [DecodeConfidence] in PulseModels.swift.
*/
Expand Down Expand Up @@ -164,11 +186,30 @@ sealed class RingDecodedEvent {
}

data class SleepTimeline(
val _timestamp: Instant,
val stages: List<SleepStage>,
val sessionStart: Instant,
val sessionEnd: Instant,
val segments: List<SleepStageSegment>,
/** True when this event is the ring's complete authoritative session, not one packet. */
val completeSession: Boolean = false,
) : RingDecodedEvent() {
constructor(
_timestamp: Instant,
stages: List<SleepStage>,
completeSession: Boolean = false,
) : this(
sessionStart = _timestamp,
sessionEnd = _timestamp.plusSeconds(stages.size * 60L),
segments = contiguousSleepSegments(_timestamp, stages),
completeSession = completeSession,
)

val _timestamp: Instant get() = sessionStart
val stages: List<SleepStage> get() = segments.flatMap { segment ->
val minutes = kotlin.math.round(
java.time.Duration.between(segment.start, segment.end).seconds / 60.0
).toInt().coerceAtLeast(0)
List(minutes) { segment.stage }
}
override val kind = "sleep_timeline"
override val confidence = DecodeConfidence.KNOWN
override val debugJSON = "{}"
Expand Down
Loading