diff --git a/app/src/main/java/com/pulseloop/health/HealthConnectExporter.kt b/app/src/main/java/com/pulseloop/health/HealthConnectExporter.kt index 8673181..fef4f2d 100644 --- a/app/src/main/java/com/pulseloop/health/HealthConnectExporter.kt +++ b/app/src/main/java/com/pulseloop/health/HealthConnectExporter.kt @@ -6,7 +6,9 @@ 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 @@ -14,7 +16,9 @@ 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]). @@ -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, +): 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 @@ -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- 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 -> diff --git a/app/src/main/java/com/pulseloop/health/HealthConnectPrefsStore.kt b/app/src/main/java/com/pulseloop/health/HealthConnectPrefsStore.kt index 3473d15..9013c22 100644 --- a/app/src/main/java/com/pulseloop/health/HealthConnectPrefsStore.kt +++ b/app/src/main/java/com/pulseloop/health/HealthConnectPrefsStore.kt @@ -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 diff --git a/app/src/main/java/com/pulseloop/health/HealthConnectTypeMappings.kt b/app/src/main/java/com/pulseloop/health/HealthConnectTypeMappings.kt index b6f9c0d..065448e 100644 --- a/app/src/main/java/com/pulseloop/health/HealthConnectTypeMappings.kt +++ b/app/src/main/java/com/pulseloop/health/HealthConnectTypeMappings.kt @@ -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 @@ -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). */ diff --git a/app/src/main/java/com/pulseloop/health/exporters/SleepExporter.kt b/app/src/main/java/com/pulseloop/health/exporters/SleepExporter.kt index 277345f..9e25093 100644 --- a/app/src/main/java/com/pulseloop/health/exporters/SleepExporter.kt +++ b/app/src/main/java/com/pulseloop/health/exporters/SleepExporter.kt @@ -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 @@ -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-` 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 @@ -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>() - val records = mutableListOf() val highWaters = mutableListOf() var skipped = 0 @@ -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, + 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, + ) + }, + ) +} diff --git a/app/src/main/java/com/pulseloop/ring/PulseEventBus.kt b/app/src/main/java/com/pulseloop/ring/PulseEventBus.kt index 254dd9a..bb067e1 100644 --- a/app/src/main/java/com/pulseloop/ring/PulseEventBus.kt +++ b/app/src/main/java/com/pulseloop/ring/PulseEventBus.kt @@ -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, + val sessionStart: java.time.Instant, + val sessionEnd: java.time.Instant, + val segments: List, val completeSession: Boolean = false, - ) : PulseEvent() + ) : PulseEvent() { + constructor( + timestamp: java.time.Instant, + stages: List, + 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 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 diff --git a/app/src/main/java/com/pulseloop/ring/RingDecodedEvent.kt b/app/src/main/java/com/pulseloop/ring/RingDecodedEvent.kt index 497c202..fc44891 100644 --- a/app/src/main/java/com/pulseloop/ring/RingDecodedEvent.kt +++ b/app/src/main/java/com/pulseloop/ring/RingDecodedEvent.kt @@ -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): List { + if (stages.isEmpty()) return emptyList() + val segments = mutableListOf() + 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. */ @@ -164,11 +186,30 @@ sealed class RingDecodedEvent { } data class SleepTimeline( - val _timestamp: Instant, - val stages: List, + val sessionStart: Instant, + val sessionEnd: Instant, + val segments: List, /** True when this event is the ring's complete authoritative session, not one packet. */ val completeSession: Boolean = false, ) : RingDecodedEvent() { + constructor( + _timestamp: Instant, + stages: List, + 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 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 = "{}" diff --git a/app/src/main/java/com/pulseloop/ring/RingEventBridge.kt b/app/src/main/java/com/pulseloop/ring/RingEventBridge.kt index 0ecf489..975a31b 100644 --- a/app/src/main/java/com/pulseloop/ring/RingEventBridge.kt +++ b/app/src/main/java/com/pulseloop/ring/RingEventBridge.kt @@ -71,8 +71,15 @@ object RingEventBridge { listOf(PulseEvent.SyncProgress("done")) is RingDecodedEvent.SleepTimeline -> { - if (!isWithinHistoryWindow(decoded._timestamp, now) || decoded.stages.isEmpty()) emptyList() - else listOf(PulseEvent.SleepTimeline(decoded._timestamp, decoded.stages, decoded.completeSession)) + if (!isWithinHistoryWindow(decoded.sessionStart, now) || + !isWithinHistoryWindow(decoded.sessionEnd, now) || + decoded.sessionEnd <= decoded.sessionStart || decoded.segments.isEmpty()) emptyList() + else listOf(PulseEvent.SleepTimeline( + decoded.sessionStart, + decoded.sessionEnd, + decoded.segments, + decoded.completeSession, + )) } is RingDecodedEvent.Battery -> { diff --git a/app/src/main/java/com/pulseloop/ring/YCBTHealthRecords.kt b/app/src/main/java/com/pulseloop/ring/YCBTHealthRecords.kt index 28943cb..1467fba 100644 --- a/app/src/main/java/com/pulseloop/ring/YCBTHealthRecords.kt +++ b/app/src/main/java/com/pulseloop/ring/YCBTHealthRecords.kt @@ -1,6 +1,9 @@ package com.pulseloop.ring +import com.pulseloop.util.TimeUtil import java.time.Instant +import java.time.Duration +import java.time.ZoneId /** * Ported from YCBTHealthRecords.swift. @@ -10,6 +13,9 @@ import java.time.Instant object YCBTHealthRecords { private const val TEMPERATURE_FILLER: Int = 15 private const val MAX_SLEEP_SESSION_MINUTES = 24 * 60 + // Pixel 7 + R10M FCF4 emitted one proven night as three complete records 1h52m and 42m apart. + private val MAX_OVERNIGHT_FRAGMENT_GAP = Duration.ofHours(3) + private val MAX_STITCHED_SLEEP_SPAN = Duration.ofHours(16) fun decode(buffer: ByteArray, type: YCBTHistoryType): List { return when (type) { @@ -171,38 +177,188 @@ object YCBTHealthRecords { var cursor = 0 while (cursor + headerLength <= buffer.size) { val recordLength = YCBTBytes.u16(buffer, cursor + 2) + val remaining = buffer.size - cursor val segmentsStart = cursor + headerLength val declared = maxOf(0, recordLength - headerLength) / segmentLength val available = (buffer.size - segmentsStart) / segmentLength val segmentCount = minOf(declared, available) - val stages = mutableListOf() - var sessionStart: Instant? = null + val headerStart = YCBTBytes.date(YCBTBytes.u32(buffer, cursor + 4)) + val headerEnd = YCBTBytes.date(YCBTBytes.u32(buffer, cursor + 8)) + val headerDuration = Duration.between(headerStart, headerEnd) + val validHeader = !headerDuration.isNegative && !headerDuration.isZero && + headerDuration <= Duration.ofMinutes(MAX_SLEEP_SESSION_MINUTES.toLong()) + val validDeclaredWidth = recordLength >= headerLength && + recordLength <= remaining && + (recordLength - headerLength) % segmentLength == 0 + val rawSegments = mutableListOf() val seenStarts = mutableSetOf() + var allDeclaredSegmentsValid = true + var nextRecordBoundary: Int? = null for (index in 0 until segmentCount) { val offset = segmentsStart + index * segmentLength - val stage = sleepStage(buffer[offset].toInt() and 0xFF) ?: continue - val segmentStart = YCBTBytes.u32(buffer, offset + 1) - if (!seenStarts.add(segmentStart)) continue + if (buffer[offset] == 0xaf.toByte() && buffer[offset + 1] == 0xfa.toByte()) { + nextRecordBoundary = offset + allDeclaredSegmentsValid = false + break + } + val stage = sleepStage(buffer[offset].toInt() and 0xFF) + val segmentStartRaw = YCBTBytes.u32(buffer, offset + 1) val segmentSeconds = YCBTBytes.u24(buffer, offset + 5) - if (sessionStart == null) sessionStart = YCBTBytes.date(segmentStart) - val remaining = MAX_SLEEP_SESSION_MINUTES - stages.size - if (remaining <= 0) break - val minutes = kotlin.math.round(segmentSeconds / 60.0).toInt().coerceIn(1, remaining) - repeat(minutes) { stages.add(stage) } + val segmentStart = YCBTBytes.date(segmentStartRaw) + val segmentEnd = segmentStart.plusSeconds(segmentSeconds.toLong()) + val validFields = stage != null && segmentSeconds > 0 + val uniqueStart = seenStarts.add(segmentStartRaw) + val intersectsHeader = validHeader && segmentStart < headerEnd && segmentEnd > headerStart + if (!validFields || !intersectsHeader) { + allDeclaredSegmentsValid = false + } + if (validFields && uniqueStart && (!validHeader || intersectsHeader)) { + rawSegments += SleepStageSegment(stage ?: continue, segmentStart, segmentEnd) + } } - if (sessionStart != null && stages.isNotEmpty()) { + + if (segmentCount < declared) allDeclaredSegmentsValid = false + val structurallyComplete = validDeclaredWidth && allDeclaredSegmentsValid && + nextRecordBoundary == null + val normalized = if (validHeader && structurallyComplete) { + when { + declared == 0 -> listOf(SleepStageSegment(SleepStage.UNKNOWN, headerStart, headerEnd)) + rawSegments.isEmpty() -> emptyList() + else -> normalizeSleepSegments(headerStart, headerEnd, rawSegments) + } + } else { + fallbackSleepSegments(rawSegments) + } + if (normalized.isNotEmpty()) { + val useHeaderBounds = validHeader && structurallyComplete + val sessionStart = if (useHeaderBounds) headerStart else normalized.first().start + val sessionEnd = if (useHeaderBounds) headerEnd else normalized.last().end events.add( RingDecodedEvent.SleepTimeline( - _timestamp = sessionStart, - stages = stages, - completeSession = true, + sessionStart = sessionStart, + sessionEnd = sessionEnd, + segments = normalized, + completeSession = useHeaderBounds, ) ) } - cursor = segmentsStart + segmentCount * segmentLength + cursor = when { + nextRecordBoundary != null -> nextRecordBoundary + recordLength in headerLength..remaining -> cursor + recordLength + recordLength > remaining -> maxOf(cursor + 1, segmentsStart + segmentCount * segmentLength) + else -> cursor + 1 + } + } + return stitchCompleteOvernightFragments(events) + } + + private fun stitchCompleteOvernightFragments( + events: List, + ): List { + val timelines = events.filterIsInstance() + .withIndex() + .sortedWith( + compareBy> { it.value.sessionStart } + .thenBy { it.value.sessionEnd } + .thenBy { it.index }, + ) + .map { it.value } + if (timelines.size < 2) return timelines + + val out = mutableListOf() + var cluster = timelines.first() + for (next in timelines.drop(1)) { + if (canStitch(cluster, next)) { + cluster = RingDecodedEvent.SleepTimeline( + sessionStart = cluster.sessionStart, + sessionEnd = next.sessionEnd, + segments = normalizeSleepSegments( + cluster.sessionStart, + next.sessionEnd, + cluster.segments + next.segments, + ), + completeSession = true, + ) + } else { + out += cluster + cluster = next + } + } + out += cluster + return out + } + + private fun canStitch( + current: RingDecodedEvent.SleepTimeline, + next: RingDecodedEvent.SleepTimeline, + zone: ZoneId = ZoneId.systemDefault(), + ): Boolean { + if (!current.completeSession || !next.completeSession) return false + if (!isOvernightStart(current.sessionStart, zone) || !isOvernightStart(next.sessionStart, zone)) return false + if (TimeUtil.wakingDayLocal(current.sessionStart.toEpochMilli(), zone) != + TimeUtil.wakingDayLocal(next.sessionStart.toEpochMilli(), zone)) return false + if (next.sessionStart < current.sessionEnd) return false + if (Duration.between(current.sessionEnd, next.sessionStart) > MAX_OVERNIGHT_FRAGMENT_GAP) return false + return Duration.between(current.sessionStart, next.sessionEnd) <= MAX_STITCHED_SLEEP_SPAN + } + + private fun isOvernightStart(start: Instant, zone: ZoneId): Boolean { + val hour = start.atZone(zone).hour + return hour >= TimeUtil.SLEEP_EVENING_BOUNDARY_HOUR || hour < 12 + } + + private fun normalizeSleepSegments( + sessionStart: Instant, + sessionEnd: Instant, + raw: List, + ): List { + if (raw.isEmpty()) return emptyList() + val clipped = raw.mapNotNull { segment -> + val start = maxOf(segment.start, sessionStart) + val end = minOf(segment.end, sessionEnd) + if (end <= start) null else segment.copy(start = start, end = end) + }.sortedWith(compareBy { it.start }.thenBy { it.end }) + if (clipped.isEmpty()) return emptyList() + + val out = mutableListOf() + var cursor = sessionStart + for (segment in clipped) { + val start = maxOf(segment.start, cursor) + if (start >= segment.end) continue + if (start > cursor) appendSleepSegment(out, SleepStageSegment(SleepStage.UNKNOWN, cursor, start)) + appendSleepSegment(out, segment.copy(start = start)) + cursor = segment.end + } + if (cursor < sessionEnd) { + appendSleepSegment(out, SleepStageSegment(SleepStage.UNKNOWN, cursor, sessionEnd)) + } + return out + } + + private fun fallbackSleepSegments(raw: List): List { + val first = raw.firstOrNull() ?: return emptyList() + val limit = first.start.plusSeconds(MAX_SLEEP_SESSION_MINUTES * 60L) + val out = mutableListOf() + var cursor = first.start + for (segment in raw) { + if (cursor >= limit) break + val seconds = Duration.between(segment.start, segment.end).seconds + if (seconds <= 0) continue + val end = minOf(cursor.plusSeconds(seconds), limit) + appendSleepSegment(out, SleepStageSegment(segment.stage, cursor, end)) + cursor = end + } + return out + } + + private fun appendSleepSegment(out: MutableList, segment: SleepStageSegment) { + val previous = out.lastOrNull() + if (previous != null && previous.stage == segment.stage && previous.end == segment.start) { + out[out.lastIndex] = previous.copy(end = segment.end) + } else { + out += segment } - return events } private fun sleepStage(tag: Int): SleepStage? { diff --git a/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt b/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt index 1ba5e92..90bcb1f 100644 --- a/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt +++ b/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt @@ -276,7 +276,12 @@ class EventPersistenceSubscriber( applyActivityBucket(event.timestamp.toEpochMilli(), event.steps, event.distanceMeters) } is PulseEvent.SleepTimeline -> { - upsertSleepSession(event.timestamp.toEpochMilli(), event.stages, event.completeSession) + upsertSleepSession( + event.sessionStart.toEpochMilli(), + event.sessionEnd.toEpochMilli(), + event.segments, + event.completeSession, + ) } is PulseEvent.SyncProgress -> { if (event.stage == "done") { @@ -434,21 +439,33 @@ class EventPersistenceSubscriber( ) } - private suspend fun upsertSleepSession(ts: Long, stages: List, completeSession: Boolean) { - if (stages.isEmpty() || stages.size > MAX_SLEEP_TIMELINE_MINUTES) return - db.withTransaction { upsertSleepSessionAtomic(ts, stages, completeSession) } + private suspend fun upsertSleepSession( + sessionStart: Long, + sessionEnd: Long, + segments: List, + completeSession: Boolean, + ) { + if (segments.isEmpty() || sessionEnd <= sessionStart || + sessionEnd - sessionStart > MAX_SLEEP_TIMELINE_MILLIS) return + db.withTransaction { + upsertSleepSessionAtomic(sessionStart, sessionEnd, segments, completeSession) + } } - private suspend fun upsertSleepSessionAtomic(ts: Long, stages: List, completeSession: Boolean) { + private suspend fun upsertSleepSessionAtomic( + sessionStart: Long, + sessionEnd: Long, + segments: List, + completeSession: Boolean, + ) { // Group packets by the waking-day boundary (sleep from 7 PM rolls to the next morning) so // a night that starts before midnight lands under the morning of waking instead of being // split into two sessions at midnight. Matches the iOS reference // (PulseEventBus.persistSleepTimeline + Calendar.wakingDay(forSleepStart:)). - val dayStart = com.pulseloop.util.TimeUtil.wakingDayLocal(ts) - val packetEnd = ts + stages.size * 60_000L + val dayStart = com.pulseloop.util.TimeUtil.wakingDayLocal(sessionStart) // Include legacy rows keyed to the wrong day if they overlap this packet. Reconciliation // re-points their surviving blocks to the correct waking day. - val overlapping = db.sleepSessionDao().ringOverlapping(ts, packetEnd) + val overlapping = db.sleepSessionDao().ringOverlapping(sessionStart, sessionEnd) val existing = (db.sleepSessionDao().ringAllByDay(dayStart) + overlapping).distinctBy { it.id } val existingBlocks = if (existing.isEmpty()) emptyList() @@ -457,20 +474,27 @@ class EventPersistenceSubscriber( // YCBT complete records are authoritative for their interval, including shortened // revisions. Packet-based families replace only the packet interval. In both cases the // unaffected blocks remain available for SleepSegmentation to preserve separate naps. - val replacements = buildStageBlocks("", ts, stages) + val replacements = buildTimestampedStageBlocks("", sessionStart, sessionEnd, segments) + if (replacements.isEmpty()) return + val replacedSessionIds = if (completeSession) { + overlapping.mapTo(mutableSetOf()) { it.id } + } else { + emptySet() + } val dayBlocks = replaceOverlappingSleepBlocks( - existing = if (completeSession) { - val replacedSessionIds = overlapping.mapTo(mutableSetOf()) { it.id } - existingBlocks.filterNot { it.sessionId in replacedSessionIds } - } else { - existingBlocks - }, + existing = existingBlocks, replacements = replacements, - replacementStart = ts, - replacementEnd = packetEnd, + replacementStart = sessionStart, + replacementEnd = sessionEnd, + removeSessionIds = replacedSessionIds, ) - reconcileWakingDay(dayStart, existing, dayBlocks) + reconcileWakingDay( + dayStart, + existing, + dayBlocks, + authoritativeBounds = if (completeSession) sessionStart to sessionEnd else null, + ) } /** @@ -500,8 +524,9 @@ class EventPersistenceSubscriber( dayStart: Long, existing: List, dayBlocks: List, + authoritativeBounds: Pair? = null, ) = db.withTransaction { - val groups = SleepSegmentation.segment(dayBlocks) + val groups = buildSleepReconcileGroups(dayBlocks, authoritativeBounds) // No blocks left on this day — drop the empty rows entirely (their blocks cascade). if (groups.isEmpty()) { @@ -509,38 +534,16 @@ class EventPersistenceSubscriber( return@withTransaction } - data class Segment(val blocks: List, val start: Long, val end: Long) - val segments = groups.map { g -> - val sorted = g.sortedBy { it.startAt } - val start = sorted.first().startAt - val end = sorted.maxOf { it.startAt + it.durationMinutes * 60_000L } - Segment(sorted, start, end) - } - - fun overlap(a0: Long, a1: Long, b0: Long, b1: Long): Long = - maxOf(0L, minOf(a1, b1) - maxOf(a0, b0)) - - // Greedily match each segment to the best-overlapping unused row; a row also matches when it - // contains the segment's start (covers a freshly-created zero-length container row). The - // rows' pre-mutation bounds are the match key — `existing` is read before any write below. - val available = existing.toMutableList() - val matched: List> = segments.map { seg -> - val best = available.maxByOrNull { overlap(seg.start, seg.end, it.startAt, it.endAt) } - if (best != null && (overlap(seg.start, seg.end, best.startAt, best.endAt) > 0L || - best.startAt in seg.start..seg.end)) { - available.remove(best) - seg to best - } else { - seg to null - } - } + val plan = buildSleepReconcilePlan(existing, groups) // Clear every existing row's blocks up front so re-pointing a block between sessions can't // leave a transient duplicate keyed to two sessions at once. existing.forEach { db.sleepStageBlockDao().deleteBySession(it.id) } val now = System.currentTimeMillis() - for ((seg, row) in matched) { + for (match in plan.matches) { + val seg = match.group + val row = match.session val id = row?.id ?: "sleep-$dayStart-${seg.start}" val totalMin = ((seg.end - seg.start) / 60_000L).toInt().coerceAtLeast(0) val deepMin = seg.blocks @@ -560,7 +563,7 @@ class EventPersistenceSubscriber( totalMinutes = totalMin, score = score, syncedAt = now, - updatedAt = now, + updatedAt = row?.let { nextSleepUpdatedAt(now, it.updatedAt) } ?: now, ) ) seg.blocks.forEach { @@ -575,48 +578,7 @@ class EventPersistenceSubscriber( } // Rows not matched to any segment had all their blocks re-pointed away — delete them. - available.forEach { db.sleepSessionDao().deleteById(it.id) } - } - - /** - * Build SleepStageBlockEntity entries with run-length encoding. - * Consecutive minutes of the same stage are merged into one block. - */ - private fun buildStageBlocks(sessionId: String, startTs: Long, stages: List): List { - if (stages.isEmpty()) return emptyList() - val blocks = mutableListOf() - var currentStage = stages[0] - var blockStart = startTs - var blockMinute = 0 - var duration = 1 - - for (i in 1 until stages.size) { - val stage = stages[i] - if (stage == currentStage) { - duration++ - } else { - blocks.add(SleepStageBlockEntity( - sessionId = sessionId, - startAt = blockStart, - startMinute = blockMinute, - durationMinutes = duration, - stageRaw = currentStage.name, - )) - currentStage = stage - blockStart = startTs + i * 60_000L - blockMinute = i - duration = 1 - } - } - // Final block - blocks.add(SleepStageBlockEntity( - sessionId = sessionId, - startAt = blockStart, - startMinute = blockMinute, - durationMinutes = duration, - stageRaw = currentStage.name, - )) - return blocks + plan.deleteSessionIds.forEach { db.sleepSessionDao().deleteById(it) } } /** @@ -652,13 +614,175 @@ class EventPersistenceSubscriber( } private companion object { - const val MAX_SLEEP_TIMELINE_MINUTES = 24 * 60 + const val MAX_SLEEP_TIMELINE_MILLIS = 24 * 60 * 60_000L } } internal fun historyMeasurementId(kind: MeasurementKind, timestamp: Long): String = "history:${kind.key}:$timestamp" +internal fun buildTimestampedStageBlocks( + sessionId: String, + sessionStart: Long, + sessionEnd: Long, + segments: List, +): List { + if (sessionEnd <= sessionStart) return emptyList() + + data class Span(val stage: SleepStage, val durationMillis: Long) + + val normalized = mutableListOf() + var cursor = sessionStart + for (segment in segments.sortedWith(compareBy { it.start }.thenBy { it.end })) { + val clippedStart = segment.start.toEpochMilli().coerceIn(sessionStart, sessionEnd) + val end = segment.end.toEpochMilli().coerceIn(sessionStart, sessionEnd) + val start = maxOf(clippedStart, cursor) + if (end <= start) continue + if (start > cursor) normalized += Span(SleepStage.UNKNOWN, start - cursor) + normalized += Span(segment.stage, end - start) + cursor = end + } + if (cursor < sessionEnd) normalized += Span(SleepStage.UNKNOWN, sessionEnd - cursor) + if (normalized.isEmpty()) return emptyList() + + val totalMinutes = (sessionEnd - sessionStart) / 60_000L + if (totalMinutes <= 0L) return emptyList() + + // Diffuse each fractional-minute remainder into the following segment. Unlike flooring every + // absolute boundary, this preserves the session's cumulative floor exactly (for example three + // 90-second spans become 1, 2, 1 minutes rather than losing the middle transition). + val minutes = LongArray(normalized.size) + var remainder = 0L + normalized.forEachIndexed { index, span -> + val withRemainder = span.durationMillis + remainder + minutes[index] = withRemainder / 60_000L + remainder = withRemainder % 60_000L + } + + // A short explicit transition is fidelity, not noise. When there is at least one Room minute + // available per positive span, reserve one for every span that error diffusion rounded to zero + // and take it from the currently most over-represented multi-minute span. + if (totalMinutes >= normalized.size) { + for (index in minutes.indices) { + if (minutes[index] != 0L) continue + val donor = minutes.indices + .filter { minutes[it] > 1L } + .maxWithOrNull( + compareBy { minutes[it] * 60_000L - normalized[it].durationMillis } + .thenByDescending { it }, + ) ?: continue + minutes[index] = 1L + minutes[donor]-- + } + } + + val blocks = mutableListOf() + var startMinute = 0L + normalized.forEachIndexed { index, span -> + val durationMinutes = minutes[index] + if (durationMinutes <= 0L) return@forEachIndexed + blocks += SleepStageBlockEntity( + sessionId = sessionId, + startAt = sessionStart + startMinute * 60_000L, + startMinute = startMinute.toInt(), + durationMinutes = durationMinutes.toInt(), + stageRaw = span.stage.name, + ) + startMinute += durationMinutes + } + return blocks +} + +internal fun nextSleepUpdatedAt(wallClockNow: Long, existingUpdatedAt: Long): Long { + val successor = if (existingUpdatedAt == Long.MAX_VALUE) Long.MAX_VALUE else existingUpdatedAt + 1L + return maxOf(wallClockNow, successor) +} + +internal data class SleepReconcileGroup( + val blocks: List, + val start: Long, + val end: Long, +) + +internal data class SleepReconcileMatch( + val group: SleepReconcileGroup, + val session: SleepSessionEntity?, +) + +internal data class SleepReconcilePlan( + val matches: List, + val deleteSessionIds: Set, +) + +internal fun buildSleepReconcilePlan( + existing: List, + groups: List, +): SleepReconcilePlan { + fun overlap(a0: Long, a1: Long, b0: Long, b1: Long): Long = + maxOf(0L, minOf(a1, b1) - maxOf(a0, b0)) + + // Match against pre-mutation bounds so correcting a malformed parent cannot change later + // matches in the same reconciliation pass. + val available = existing.toMutableList() + val matches = groups.map { group -> + val best = available.maxByOrNull { overlap(group.start, group.end, it.startAt, it.endAt) } + if (best != null && (overlap(group.start, group.end, best.startAt, best.endAt) > 0L || + best.startAt in group.start..group.end)) { + available.remove(best) + SleepReconcileMatch(group, best) + } else { + SleepReconcileMatch(group, null) + } + } + return SleepReconcilePlan(matches, available.mapTo(linkedSetOf()) { it.id }) +} + +internal fun buildSleepReconcileGroups( + blocks: List, + authoritativeBounds: Pair? = null, +): List { + fun naturalGroups(input: List) = SleepSegmentation.segment(input).map { group -> + val sorted = group.sortedBy { it.startAt } + SleepReconcileGroup( + blocks = sorted, + start = sorted.first().startAt, + end = sorted.maxOf { it.startAt + it.durationMinutes * 60_000L }, + ) + } + + val bounds = authoritativeBounds ?: return naturalGroups(blocks) + val (explicitStart, explicitEnd) = bounds + val before = mutableListOf() + val authoritative = mutableListOf() + val after = mutableListOf() + for (block in blocks) { + val blockEnd = block.startAt + block.durationMinutes * 60_000L + when { + blockEnd <= explicitStart -> before += block + block.startAt >= explicitEnd -> after += block + else -> { + val boundedStart = maxOf(block.startAt, explicitStart) + val boundedEnd = minOf(blockEnd, explicitEnd) + val boundedMinutes = ((boundedEnd - boundedStart) / 60_000L).toInt() + if (boundedMinutes > 0) { + authoritative += block.copy( + startAt = boundedStart, + durationMinutes = boundedMinutes, + ) + } + } + } + } + + return buildList { + addAll(naturalGroups(before)) + if (authoritative.isNotEmpty()) { + add(SleepReconcileGroup(authoritative.sortedBy { it.startAt }, explicitStart, explicitEnd)) + } + addAll(naturalGroups(after)) + } +} + /** * True when a `DeviceStateChanged(CONNECTED, …)` is a real connection transition rather than a * mid-session re-assertion. @@ -730,9 +854,11 @@ internal fun replaceOverlappingSleepBlocks( replacements: List, replacementStart: Long, replacementEnd: Long, + removeSessionIds: Set = emptySet(), ): List { val byStart = LinkedHashMap() for (block in existing) { + if (block.sessionId in removeSessionIds) continue val blockEnd = block.startAt + block.durationMinutes * 60_000L if (blockEnd <= replacementStart || block.startAt >= replacementEnd) { byStart[block.startAt] = block diff --git a/app/src/test/java/com/pulseloop/health/HealthConnectExporterTest.kt b/app/src/test/java/com/pulseloop/health/HealthConnectExporterTest.kt index 3989a34..ae0ba87 100644 --- a/app/src/test/java/com/pulseloop/health/HealthConnectExporterTest.kt +++ b/app/src/test/java/com/pulseloop/health/HealthConnectExporterTest.kt @@ -6,6 +6,7 @@ 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.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -173,6 +174,82 @@ class HealthConnectExporterTest { assertEquals(499L, p.lastCompletedHighWater) } + @Test + fun sleepIdentityMigrationDeletesThenResetsThenMarks() = runTest { + val calls = mutableListOf() + val migrated = migrateSleepIdentityV2( + required = true, + deleteLegacyRecords = { calls += "delete" }, + resetSleepWatermark = { calls += "reset" }, + markDone = { calls += "mark" }, + ) + + assertTrue(migrated) + assertEquals(listOf("delete", "reset", "mark"), calls) + } + + @Test + fun sleepIdentityMigrationDeleteFailureDoesNotResetOrMark() = runTest { + val calls = mutableListOf() + val migrated = migrateSleepIdentityV2( + required = true, + deleteLegacyRecords = { + calls += "delete" + throw IllegalStateException("provider unavailable") + }, + resetSleepWatermark = { calls += "reset" }, + markDone = { calls += "mark" }, + ) + + assertFalse(migrated) + assertEquals(listOf("delete"), calls) + } + + @Test + fun sleepIdentityMigrationDoesNotSwallowCancellation() = runTest { + try { + migrateSleepIdentityV2( + required = true, + deleteLegacyRecords = { throw CancellationException("cancelled") }, + resetSleepWatermark = { fail("must not reset after cancellation") }, + markDone = { fail("must not mark after cancellation") }, + ) + fail("expected cancellation") + } catch (_: CancellationException) { + // Structured cancellation must propagate to WorkManager/coroutine ownership. + } + } + + @Test + fun sleepIdentityMigrationGatesSkipWithoutSideEffects() = runTest { + val permission = HealthConnectPermissions.sleep.first() + val ready = HealthConnectPrefs( + enabled = true, + sleep = true, + backfillChoice = HealthConnectPrefs.BackfillChoice.EXPORT_ALL, + ) + val gated = listOf( + ready.copy(enabled = false) to setOf(permission), + ready.copy(backfillChoice = HealthConnectPrefs.BackfillChoice.NOT_ASKED) to setOf(permission), + ready.copy(sleep = false) to setOf(permission), + ready to emptySet(), + ready.copy(sleepIdentityV2Done = true) to setOf(permission), + ) + + assertTrue(sleepIdentityV2MigrationRequired(ready, setOf(permission))) + gated.forEach { (prefs, granted) -> + var called = false + val migrated = migrateSleepIdentityV2( + required = sleepIdentityV2MigrationRequired(prefs, granted), + deleteLegacyRecords = { called = true }, + resetSleepWatermark = { called = true }, + markDone = { called = true }, + ) + assertTrue(migrated) + assertFalse(called) + } + } + // ── Phase 3: one source row can emit several records sharing one high water ── @Test diff --git a/app/src/test/java/com/pulseloop/health/HealthConnectPrefsStoreTest.kt b/app/src/test/java/com/pulseloop/health/HealthConnectPrefsStoreTest.kt index 9125f05..e3bd11b 100644 --- a/app/src/test/java/com/pulseloop/health/HealthConnectPrefsStoreTest.kt +++ b/app/src/test/java/com/pulseloop/health/HealthConnectPrefsStoreTest.kt @@ -116,10 +116,23 @@ class HealthConnectPrefsStoreTest { val store = storeWith("{\"enabled\":true}") assertTrue(store.current.enabled) assertTrue(store.current.workouts) // field did not exist when the blob was written + assertFalse(store.current.sleepIdentityV2Done) assertNull(store.current.lastSyncAt) assertFalse(store.current.isConnected) } + @Test + fun sleepIdentityV2MarkerDefaultsFalseAndRoundTripsTrue() { + assertFalse(storeWith(null).current.sleepIdentityV2Done) + assertFalse(storeWith("{\"enabled\":true}").current.sleepIdentityV2Done) + + val fake = FakeSharedPreferences() + val store = HealthConnectPrefsStore(fake) + store.update { it.copy(sleepIdentityV2Done = true) } + + assertTrue(HealthConnectPrefsStore(fake).current.sleepIdentityV2Done) + } + @Test fun corruptBlobFallsBackToDefaults() { val store = storeWith("not-json{") diff --git a/app/src/test/java/com/pulseloop/health/SleepExporterMappingTest.kt b/app/src/test/java/com/pulseloop/health/SleepExporterMappingTest.kt new file mode 100644 index 0000000..ca210fe --- /dev/null +++ b/app/src/test/java/com/pulseloop/health/SleepExporterMappingTest.kt @@ -0,0 +1,138 @@ +package com.pulseloop.health + +import androidx.health.connect.client.records.SleepSessionRecord +import androidx.health.connect.client.records.metadata.Device +import com.pulseloop.data.entity.SleepSessionEntity +import com.pulseloop.health.exporters.buildSleepSessionRecord +import com.pulseloop.ring.SleepStage +import com.pulseloop.ring.SleepStageSegment +import com.pulseloop.service.buildTimestampedStageBlocks +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.Instant +import java.time.ZoneId + +class SleepExporterMappingTest { + private val device = Device(type = Device.TYPE_RING, manufacturer = "test", model = "YCBT") + + @Test + fun `corrected Room bounds and unknown gaps build a valid Health Connect sleep record`() { + val start = Instant.parse("2026-07-06T22:30:00Z") + val end = start.plusSeconds(6 * 60 * 60L) + val day = Instant.parse("2026-07-07T00:00:00Z").toEpochMilli() + val updatedAt = 2_000L + val session = SleepSessionEntity( + id = "room-session", + date = day, + startAt = start.toEpochMilli(), + endAt = end.toEpochMilli(), + totalMinutes = 360, + updatedAt = updatedAt, + ) + val blocks = buildTimestampedStageBlocks( + session.id, + session.startAt, + session.endAt, + listOf( + SleepStageSegment(SleepStage.UNKNOWN, start, start.plusSeconds(30 * 60L)), + SleepStageSegment(SleepStage.LIGHT, start.plusSeconds(30 * 60L), start.plusSeconds(90 * 60L)), + SleepStageSegment(SleepStage.UNKNOWN, start.plusSeconds(90 * 60L), start.plusSeconds(4 * 60 * 60L)), + SleepStageSegment(SleepStage.DEEP, start.plusSeconds(4 * 60 * 60L), start.plusSeconds(4 * 60 * 60L + 56 * 60L)), + SleepStageSegment(SleepStage.UNKNOWN, start.plusSeconds(4 * 60 * 60L + 56 * 60L), end), + ), + ) + val recordId = HealthConnectTypeMappings.sleepSessionRecordIdV2(session.id) + + val record = buildSleepSessionRecord(session, blocks, recordId, device, ZoneId.of("UTC"))!! + + assertEquals(start, record.startTime) + assertEquals(end, record.endTime) + assertEquals(recordId, record.metadata.clientRecordId) + assertEquals(updatedAt, record.metadata.clientRecordVersion) + assertEquals( + listOf( + SleepSessionRecord.STAGE_TYPE_UNKNOWN, + SleepSessionRecord.STAGE_TYPE_LIGHT, + SleepSessionRecord.STAGE_TYPE_UNKNOWN, + SleepSessionRecord.STAGE_TYPE_DEEP, + SleepSessionRecord.STAGE_TYPE_UNKNOWN, + ), + record.stages.map { it.stage }, + ) + assertEquals(start, record.stages.first().startTime) + assertEquals(end, record.stages.last().endTime) + assertTrue(record.stages.zipWithNext().all { (a, b) -> a.endTime <= b.startTime }) + } + + @Test + fun `sleep correction keeps client identity and advances updatedAt version`() { + val day = Instant.parse("2026-07-07T00:00:00Z").toEpochMilli() + val start = Instant.parse("2026-07-06T22:30:00Z") + fun record(updatedAt: Long, minutes: Int): SleepSessionRecord { + val end = start.plusSeconds(minutes * 60L) + val session = SleepSessionEntity( + id = "room-session", + date = day, + startAt = start.toEpochMilli(), + endAt = end.toEpochMilli(), + totalMinutes = minutes, + updatedAt = updatedAt, + ) + val blocks = buildTimestampedStageBlocks( + session.id, + session.startAt, + session.endAt, + listOf(SleepStageSegment(SleepStage.UNKNOWN, start, end)), + ) + return buildSleepSessionRecord( + session, + blocks, + HealthConnectTypeMappings.sleepSessionRecordIdV2(session.id), + device, + ZoneId.of("UTC"), + )!! + } + + val collapsed = record(updatedAt = 1_000L, minutes = 116) + val corrected = record(updatedAt = 2_000L, minutes = 360) + + assertEquals(collapsed.metadata.clientRecordId, corrected.metadata.clientRecordId) + assertEquals(1_000L, collapsed.metadata.clientRecordVersion) + assertEquals(2_000L, corrected.metadata.clientRecordVersion) + assertTrue(corrected.endTime > collapsed.endTime) + } + + @Test + fun `role change keeps every stable v2 identity unchanged`() { + val day = Instant.parse("2026-07-07T00:00:00Z").toEpochMilli() + val nightStart = Instant.parse("2026-07-06T22:30:00Z") + val napStart = Instant.parse("2026-07-07T14:00:00Z") + + fun session(id: String, start: Instant, minutes: Int, updatedAt: Long) = SleepSessionEntity( + id = id, + date = day, + startAt = start.toEpochMilli(), + endAt = start.plusSeconds(minutes * 60L).toEpochMilli(), + totalMinutes = minutes, + updatedAt = updatedAt, + ) + + val collapsedNight = session("night-session", nightStart, 116, 1_000L) + val nap = session("nap-session", napStart, 180, 1_000L) + val correctedNight = session("night-session", nightStart, 360, 2_000L) + + val idsBefore = listOf(collapsedNight, nap).associate { session -> + session.id to HealthConnectTypeMappings.sleepSessionRecordIdV2(session.id) + } + val idsAfter = listOf(correctedNight, nap).associate { session -> + session.id to HealthConnectTypeMappings.sleepSessionRecordIdV2(session.id) + } + + assertTrue(collapsedNight.totalMinutes < nap.totalMinutes) + assertTrue(correctedNight.totalMinutes > nap.totalMinutes) + assertEquals(idsBefore, idsAfter) + assertEquals(2, idsAfter.values.toSet().size) + assertTrue(idsAfter.values.all { it.length <= 100 }) + } +} diff --git a/app/src/test/java/com/pulseloop/ring/RingEventBridgeTest.kt b/app/src/test/java/com/pulseloop/ring/RingEventBridgeTest.kt index 3f50996..a43fb6d 100644 --- a/app/src/test/java/com/pulseloop/ring/RingEventBridgeTest.kt +++ b/app/src/test/java/com/pulseloop/ring/RingEventBridgeTest.kt @@ -206,6 +206,26 @@ class RingEventBridgeTest { assertEquals(1, RingEventBridge.eventsFor(sleep, now).size) } + @Test + fun `normalized sleep timeline preserves explicit bounds and timestamped segments`() { + val start = now.minus(6, ChronoUnit.HOURS) + val end = now + val segments = listOf( + SleepStageSegment(SleepStage.UNKNOWN, start, start.plus(30, ChronoUnit.MINUTES)), + SleepStageSegment(SleepStage.AWAKE, start.plus(30, ChronoUnit.MINUTES), end), + ) + + val event = RingEventBridge.eventsFor( + RingDecodedEvent.SleepTimeline(start, end, segments, completeSession = true), + now, + ).single() as PulseEvent.SleepTimeline + + assertEquals(start, event.sessionStart) + assertEquals(end, event.sessionEnd) + assertEquals(segments, event.segments) + assertTrue(event.completeSession) + } + @Test fun `sleep timeline with empty stages is dropped`() { val sleep = RingDecodedEvent.SleepTimeline( @@ -233,6 +253,19 @@ class RingEventBridgeTest { assertTrue(RingEventBridge.eventsFor(sleep, now).isEmpty()) } + @Test + fun `sleep timeline whose end is over one hour in the future is dropped`() { + val start = now.minus(30, ChronoUnit.MINUTES) + val end = now.plus(2, ChronoUnit.HOURS) + val sleep = RingDecodedEvent.SleepTimeline( + start, + end, + listOf(SleepStageSegment(SleepStage.LIGHT, start, end)), + ) + + assertTrue(RingEventBridge.eventsFor(sleep, now).isEmpty()) + } + // ── Battery Gating ────────────────────────────────────────────────── @Test diff --git a/app/src/test/java/com/pulseloop/ring/YCBTHealthRecordsTest.kt b/app/src/test/java/com/pulseloop/ring/YCBTHealthRecordsTest.kt index 00417ef..ae94b2a 100644 --- a/app/src/test/java/com/pulseloop/ring/YCBTHealthRecordsTest.kt +++ b/app/src/test/java/com/pulseloop/ring/YCBTHealthRecordsTest.kt @@ -2,6 +2,9 @@ package com.pulseloop.ring import org.junit.Assert.* import org.junit.Test +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId class YCBTHealthRecordsTest { @@ -103,9 +106,45 @@ class YCBTHealthRecordsTest { assertEquals(1, events.size) } + @Test + fun `valid header preserves a six hour session and fills unclassified time as unknown`() { + val start = Instant.parse("2026-07-06T22:30:00Z") + val end = start.plusSeconds(6 * 60 * 60L) + val session = sleepSession( + sessionStart = start, + sessionEnd = end, + segments = listOf( + Triple(0xf2, start.plusSeconds(30 * 60L), 60 * 60), + Triple(0xf1, start.plusSeconds(4 * 60 * 60L), 56 * 60), + ), + ) + + val event = YCBTHealthRecords.sleep(session).single() as RingDecodedEvent.SleepTimeline + + assertEquals(start, event.sessionStart) + assertEquals(end, event.sessionEnd) + assertEquals(6 * 60L, event.segments.sumOf { java.time.Duration.between(it.start, it.end).toMinutes() }) + assertEquals( + listOf( + SleepStage.UNKNOWN, + SleepStage.LIGHT, + SleepStage.UNKNOWN, + SleepStage.DEEP, + SleepStage.UNKNOWN, + ), + event.segments.map { it.stage }, + ) + assertEquals(start.plusSeconds(30 * 60L), event.segments[1].start) + assertEquals(start.plusSeconds(4 * 60 * 60L), event.segments[3].start) + assertEquals(116L, event.segments.filter { it.stage != SleepStage.UNKNOWN } + .sumOf { java.time.Duration.between(it.start, it.end).toMinutes() }) + } + @Test fun `sleep decodes a full night matching the app`() { val event = YCBTHealthRecords.sleep(capturedNight).first() as RingDecodedEvent.SleepTimeline + assertEquals(YCBTBytes.date(YCBTBytes.u32(capturedNight, 4)), event.sessionStart) + assertEquals(YCBTBytes.date(YCBTBytes.u32(capturedNight, 8)), event.sessionEnd) val deep = event.stages.count { it == SleepStage.DEEP } val light = event.stages.count { it == SleepStage.LIGHT } val rem = event.stages.count { it == SleepStage.REM } @@ -116,12 +155,243 @@ class YCBTHealthRecordsTest { assertTrue(event.completeSession) } + @Test + fun `explicit awake segment is preserved inside header bounds`() { + val start = Instant.parse("2026-07-06T22:30:00Z") + val end = start.plusSeconds(90 * 60L) + val event = YCBTHealthRecords.sleep(sleepSession( + sessionStart = start, + sessionEnd = end, + segments = listOf( + Triple(0xf2, start, 30 * 60), + Triple(0xf4, start.plusSeconds(30 * 60L), 15 * 60), + Triple(0xf1, start.plusSeconds(45 * 60L), 45 * 60), + ), + )).single() as RingDecodedEvent.SleepTimeline + + assertEquals(15, event.stages.count { it == SleepStage.AWAKE }) + assertEquals(listOf(SleepStage.LIGHT, SleepStage.AWAKE, SleepStage.DEEP), event.segments.map { it.stage }) + } + + @Test + fun `header normalization sorts clips deduplicates and resolves overlaps`() { + val start = Instant.parse("2026-07-06T22:30:00Z") + val end = start.plusSeconds(3 * 60 * 60L) + val event = YCBTHealthRecords.sleep(sleepSession( + sessionStart = start, + sessionEnd = end, + segments = listOf( + Triple(0xf2, start.plusSeconds(60 * 60L), 2 * 60 * 60), + Triple(0xf4, start.plusSeconds(30 * 60L), 60 * 60), + Triple(0xf1, start.minusSeconds(30 * 60L), 60 * 60), + Triple(0xf4, start.plusSeconds(30 * 60L), 30 * 60), + ), + )).single() as RingDecodedEvent.SleepTimeline + + assertEquals(listOf(SleepStage.DEEP, SleepStage.AWAKE, SleepStage.LIGHT), event.segments.map { it.stage }) + assertEquals(listOf(start, start.plusSeconds(30 * 60L), start.plusSeconds(90 * 60L)), event.segments.map { it.start }) + assertEquals(listOf(start.plusSeconds(30 * 60L), start.plusSeconds(90 * 60L), end), event.segments.map { it.end }) + } + + @Test + fun `malformed header falls back to compact segment durations`() { + val first = Instant.parse("2026-07-06T22:30:00Z") + val event = YCBTHealthRecords.sleep(sleepSession( + sessionStart = first.plusSeconds(60 * 60L), + sessionEnd = first, + segments = listOf( + Triple(0xf2, first, 30 * 60), + Triple(0xf1, first.plusSeconds(3 * 60 * 60L), 45 * 60), + ), + )).single() as RingDecodedEvent.SleepTimeline + + assertEquals(first, event.sessionStart) + assertEquals(first.plusSeconds(75 * 60L), event.sessionEnd) + assertEquals(75, event.stages.size) + assertEquals(listOf(SleepStage.LIGHT, SleepStage.DEEP), event.segments.map { it.stage }) + } + + @Test + fun `header longer than one day falls back instead of expanding the session`() { + val start = Instant.parse("2026-07-06T22:30:00Z") + val event = YCBTHealthRecords.sleep(sleepSession( + sessionStart = start, + sessionEnd = start.plusSeconds(24 * 60 * 60L + 59), + segments = listOf(Triple(0xf2, start, 30 * 60)), + )).single() as RingDecodedEvent.SleepTimeline + + assertEquals(start.plusSeconds(30 * 60L), event.sessionEnd) + } + + @Test + fun `valid header with zero declared segments becomes unknown sleep`() { + val start = Instant.parse("2026-07-06T22:30:00Z") + val end = start.plusSeconds(60 * 60L) + + val event = YCBTHealthRecords.sleep(sleepSession(start, end, emptyList())) + .single() as RingDecodedEvent.SleepTimeline + + assertEquals(listOf(SleepStage.UNKNOWN), event.segments.map { it.stage }) + assertEquals(start, event.segments.single().start) + assertEquals(end, event.segments.single().end) + assertTrue(event.completeSession) + } + + @Test + fun `valid header whose declared segments produce no intersection is rejected`() { + val start = Instant.parse("2026-07-06T22:30:00Z") + val session = sleepSession( + sessionStart = start, + sessionEnd = start.plusSeconds(60 * 60L), + segments = listOf(Triple(0xf0, start, 60 * 60)), + ) + + assertTrue(YCBTHealthRecords.sleep(session).isEmpty()) + } + + @Test + fun `valid header whose decodable segments are all outside it is rejected`() { + val start = Instant.parse("2026-07-06T22:30:00Z") + val session = sleepSession( + sessionStart = start, + sessionEnd = start.plusSeconds(60 * 60L), + segments = listOf(Triple(0xf2, start.plusSeconds(2 * 60 * 60L), 30 * 60)), + ) + + assertTrue(YCBTHealthRecords.sleep(session).isEmpty()) + } + @Test fun `multiple sessions in one buffer`() { val timelines = YCBTHealthRecords.sleep(capturedNight + capturedNight).filterIsInstance() assertEquals(2, timelines.size) } + @Test + fun `complete overnight fragments stitch across unknown gaps`() { + val start = localInstant("2026-08-24T02:07:46") + val end = localInstant("2026-08-24T08:30:17") + + val timeline = YCBTHealthRecords.sleep(provenOvernightFragments()) + .single() as RingDecodedEvent.SleepTimeline + + assertEquals(start, timeline.sessionStart) + assertEquals(end, timeline.sessionEnd) + assertTrue(timeline.completeSession) + assertEquals(382, java.time.Duration.between(timeline.sessionStart, timeline.sessionEnd).toMinutes()) + assertEquals(13_677L, timeline.segments.filter { it.stage != SleepStage.UNKNOWN } + .sumOf { java.time.Duration.between(it.start, it.end).seconds }) + assertEquals(600L, timeline.segments.filter { it.stage == SleepStage.AWAKE } + .sumOf { java.time.Duration.between(it.start, it.end).seconds }) + assertTrue(timeline.segments.zipWithNext().all { (left, right) -> left.end == right.start }) + assertEquals(start, timeline.segments.first().start) + assertEquals(end, timeline.segments.last().end) + assertUnknownCoverage( + timeline, + localInstant("2026-08-24T03:08:32"), + localInstant("2026-08-24T05:01:08"), + ) + assertUnknownCoverage( + timeline, + localInstant("2026-08-24T05:51:46"), + localInstant("2026-08-24T06:33:32"), + ) + } + + @Test + fun `daytime nap remains separate from stitched overnight fragments`() { + val napStart = localInstant("2026-08-24T14:00:00") + val nap = completeSleepRecord(napStart, napStart.plusSeconds(30 * 60L), 0xf1) + + val timelines = YCBTHealthRecords.sleep(nap + provenOvernightFragments()) + .filterIsInstance() + + assertEquals(2, timelines.size) + assertEquals( + listOf(localInstant("2026-08-24T02:07:46"), napStart), + timelines.map { it.sessionStart }, + ) + } + + @Test + fun `overnight fragments more than three hours apart remain separate`() { + val firstStart = localInstant("2026-08-24T01:00:00") + val firstEnd = localInstant("2026-08-24T02:00:00") + val secondStart = firstEnd.plusSeconds(3 * 60 * 60L + 1) + val secondEnd = secondStart.plusSeconds(60 * 60L) + + val timelines = YCBTHealthRecords.sleep( + completeSleepRecord(firstStart, firstEnd) + completeSleepRecord(secondStart, secondEnd), + ).filterIsInstance() + + assertEquals(listOf(firstStart, secondStart), timelines.map { it.sessionStart }) + } + + @Test + fun `overnight fragments on different waking days remain separate`() { + val firstStart = localInstant("2026-08-24T01:00:00") + val firstEnd = localInstant("2026-08-24T02:00:00") + val secondStart = localInstant("2026-08-24T22:00:00") + val secondEnd = localInstant("2026-08-24T23:00:00") + + val timelines = YCBTHealthRecords.sleep( + completeSleepRecord(firstStart, firstEnd) + completeSleepRecord(secondStart, secondEnd), + ).filterIsInstance() + + assertEquals(listOf(firstStart, secondStart), timelines.map { it.sessionStart }) + } + + @Test + fun `partial overnight record never stitches to a complete record`() { + val firstStart = localInstant("2026-08-24T01:00:00") + val firstEnd = localInstant("2026-08-24T02:00:00") + val secondStart = localInstant("2026-08-24T03:00:00") + val secondEnd = localInstant("2026-08-24T04:00:00") + val partial = completeSleepRecord(firstStart, firstEnd).also { putU16(it, 2, it.size + 8) } + + val timelines = YCBTHealthRecords.sleep(partial + completeSleepRecord(secondStart, secondEnd)) + .filterIsInstance() + + assertEquals(2, timelines.size) + assertFalse(timelines.first().completeSession) + assertTrue(timelines.last().completeSession) + } + + @Test + fun `duplicate and overlapping complete records remain separate deterministically`() { + val start = localInstant("2026-08-24T02:00:00") + val duplicate = completeSleepRecord(start, start.plusSeconds(60 * 60L)) + val overlapStart = start.plusSeconds(30 * 60L) + val overlap = completeSleepRecord(overlapStart, overlapStart.plusSeconds(60 * 60L), 0xf1) + val buffer = overlap + duplicate + duplicate + + val first = YCBTHealthRecords.sleep(buffer).filterIsInstance() + val replay = YCBTHealthRecords.sleep(buffer).filterIsInstance() + + assertEquals(3, first.size) + assertEquals(listOf(start, start, overlapStart), first.map { it.sessionStart }) + assertEquals(first, replay) + } + + @Test + fun `overnight cluster cannot exceed sixteen hours`() { + val starts = listOf( + "2026-08-23T19:00:00", "2026-08-23T22:00:00", "2026-08-24T01:00:00", + "2026-08-24T04:00:00", "2026-08-24T07:00:00", "2026-08-24T10:00:00", + ).map(::localInstant) + val records = starts.mapIndexed { index, start -> + val duration = if (index == starts.lastIndex) 61 * 60L else 60 * 60L + completeSleepRecord(start, start.plusSeconds(duration)) + }.reduce(ByteArray::plus) + + val timelines = YCBTHealthRecords.sleep(records).filterIsInstance() + + assertEquals(2, timelines.size) + assertTrue(timelines.all { + java.time.Duration.between(it.sessionStart, it.sessionEnd) <= java.time.Duration.ofHours(16) + }) + } + @Test fun `nap segment does not truncate the night`() { val session = sleepSession(listOf( @@ -169,6 +439,97 @@ class YCBTHealthRecordsTest { session = session.copyOfRange(0, session.size - 8) val event = YCBTHealthRecords.sleep(session).first() as RingDecodedEvent.SleepTimeline assertEquals(10, event.stages.size) + assertFalse(event.completeSession) + } + + @Test + fun `valid full night header on a truncated record does not invent or replace its missing tail`() { + val start = Instant.parse("2026-07-06T22:30:00Z") + val fullEnd = start.plusSeconds(8 * 60 * 60L) + val complete = sleepSession( + sessionStart = start, + sessionEnd = fullEnd, + segments = listOf( + Triple(0xf2, start.plusSeconds(30 * 60L), 60 * 60), + Triple(0xf1, start.plusSeconds(90 * 60L), 6 * 60 * 60), + ), + ) + + val event = YCBTHealthRecords.sleep(complete.copyOf(complete.size - 8)) + .single() as RingDecodedEvent.SleepTimeline + + assertFalse(event.completeSession) + assertEquals(start.plusSeconds(30 * 60L), event.sessionStart) + assertEquals(start.plusSeconds(90 * 60L), event.sessionEnd) + assertEquals(listOf(SleepStage.LIGHT), event.segments.map { it.stage }) + assertFalse(event.segments.any { it.stage == SleepStage.UNKNOWN }) + assertTrue(event.sessionEnd < fullEnd) + } + + @Test + fun `record with a partial segment width is not authoritative`() { + val start = Instant.parse("2026-07-06T22:30:00Z") + var session = sleepSession( + sessionStart = start, + sessionEnd = start.plusSeconds(60 * 60L), + segments = listOf(Triple(0xf2, start, 60 * 60)), + ) + byteArrayOf(0) + putU16(session, 2, session.size) + + val event = YCBTHealthRecords.sleep(session).single() as RingDecodedEvent.SleepTimeline + + assertFalse(event.completeSession) + assertEquals(start.plusSeconds(60 * 60L), event.sessionEnd) + } + + @Test + fun `partial-width record advances by its declared length before the next record`() { + val firstStart = Instant.parse("2026-07-06T20:00:00Z") + var malformed = sleepSession( + sessionStart = firstStart, + sessionEnd = firstStart.plusSeconds(60 * 60L), + segments = listOf(Triple(0xf2, firstStart, 60 * 60)), + ) + byteArrayOf(0) + putU16(malformed, 2, malformed.size) + val secondStart = Instant.parse("2026-07-06T22:30:00Z") + val valid = sleepSession( + sessionStart = secondStart, + sessionEnd = secondStart.plusSeconds(2 * 60 * 60L), + segments = listOf(Triple(0xf1, secondStart, 2 * 60 * 60)), + ) + + val events = YCBTHealthRecords.sleep(malformed + valid) + .filterIsInstance() + + assertEquals(2, events.size) + assertFalse(events[0].completeSession) + assertEquals(secondStart, events[1].sessionStart) + assertTrue(events[1].completeSession) + } + + @Test + fun `truncated record stops at the following record preamble instead of borrowing it`() { + val firstStart = Instant.parse("2026-07-06T20:00:00Z") + val truncated = sleepSession( + sessionStart = firstStart, + sessionEnd = firstStart.plusSeconds(2 * 60 * 60L), + segments = listOf(Triple(0xf2, firstStart, 60 * 60)), + ).also { putU16(it, 2, it.size + 8) } + val secondStart = Instant.parse("2026-07-06T23:00:00Z") + val valid = sleepSession( + sessionStart = secondStart, + sessionEnd = secondStart.plusSeconds(90 * 60L), + segments = listOf(Triple(0xf1, secondStart, 90 * 60)), + ) + + val events = YCBTHealthRecords.sleep(truncated + valid) + .filterIsInstance() + + assertEquals(2, events.size) + assertFalse(events[0].completeSession) + assertEquals(firstStart.plusSeconds(60 * 60L), events[0].sessionEnd) + assertEquals(secondStart, events[1].sessionStart) + assertTrue(events[1].completeSession) } @Test @@ -290,4 +651,90 @@ class YCBTHealthRecordsTest { } return out.toByteArray() } + + private fun sleepSession( + sessionStart: Instant, + sessionEnd: Instant, + segments: List>, + ): ByteArray { + val recordLength = 20 + segments.size * 8 + val out = ByteArray(recordLength) + out[0] = 0xaf.toByte() + out[1] = 0xfa.toByte() + putU16(out, 2, recordLength) + putU32(out, 4, YCBTBytes.ringSeconds(sessionStart)) + putU32(out, 8, YCBTBytes.ringSeconds(sessionEnd)) + segments.forEachIndexed { index, (tag, start, durationSeconds) -> + val offset = 20 + index * 8 + out[offset] = tag.toByte() + putU32(out, offset + 1, YCBTBytes.ringSeconds(start)) + out[offset + 5] = (durationSeconds and 0xff).toByte() + out[offset + 6] = ((durationSeconds ushr 8) and 0xff).toByte() + out[offset + 7] = ((durationSeconds ushr 16) and 0xff).toByte() + } + return out + } + + private fun provenOvernightFragments(): ByteArray { + val a = localInstant("2026-08-24T02:07:46") + val b = localInstant("2026-08-24T05:01:08") + val c = localInstant("2026-08-24T06:33:32") + return sleepSession( + a, + localInstant("2026-08-24T03:08:32"), + timestampedSegments(a, listOf(0xf2 to 900, 0xf4 to 600, 0xf1 to 1_200, 0xf3 to 942)), + ) + sleepSession( + b, + localInstant("2026-08-24T05:51:46"), + timestampedSegments(b, listOf(0xf2 to 600, 0xf1 to 900, 0xf3 to 600, 0xf2 to 936)), + ) + sleepSession( + c, + localInstant("2026-08-24T08:30:17"), + timestampedSegments( + c, + listOf( + 0xf2 to 780, 0xf1 to 780, 0xf3 to 780, + 0xf2 to 780, 0xf1 to 780, 0xf3 to 780, + 0xf2 to 780, 0xf1 to 780, 0xf3 to 759, + ), + ), + ) + } + + private fun completeSleepRecord(start: Instant, end: Instant, tag: Int = 0xf2): ByteArray = + sleepSession(start, end, listOf(Triple(tag, start, java.time.Duration.between(start, end).seconds.toInt()))) + + private fun timestampedSegments( + start: Instant, + stages: List>, + ): List> { + var cursor = start + return stages.map { (tag, duration) -> + Triple(tag, cursor, duration).also { cursor = cursor.plusSeconds(duration.toLong()) } + } + } + + private fun localInstant(value: String): Instant = + LocalDateTime.parse(value).atZone(ZoneId.systemDefault()).toInstant() + + private fun assertUnknownCoverage( + timeline: RingDecodedEvent.SleepTimeline, + start: Instant, + end: Instant, + ) { + val covering = timeline.segments.filter { it.start < end && it.end > start } + assertTrue(covering.all { it.stage == SleepStage.UNKNOWN }) + assertTrue(covering.first().start <= start) + assertTrue(covering.last().end >= end) + assertTrue(covering.zipWithNext().all { (left, right) -> left.end == right.start }) + } + + private fun putU16(out: ByteArray, offset: Int, value: Int) { + out[offset] = (value and 0xff).toByte() + out[offset + 1] = ((value ushr 8) and 0xff).toByte() + } + + private fun putU32(out: ByteArray, offset: Int, value: Int) { + repeat(4) { out[offset + it] = ((value ushr (it * 8)) and 0xff).toByte() } + } } diff --git a/app/src/test/java/com/pulseloop/service/EventPersistenceIdentityTest.kt b/app/src/test/java/com/pulseloop/service/EventPersistenceIdentityTest.kt index e9f3c57..ddbb5df 100644 --- a/app/src/test/java/com/pulseloop/service/EventPersistenceIdentityTest.kt +++ b/app/src/test/java/com/pulseloop/service/EventPersistenceIdentityTest.kt @@ -1,13 +1,17 @@ package com.pulseloop.service import com.pulseloop.data.entity.SleepStageBlockEntity +import com.pulseloop.data.entity.SleepSessionEntity import com.pulseloop.ring.MeasurementKind import com.pulseloop.ring.RingDeviceType +import com.pulseloop.ring.SleepStage +import com.pulseloop.ring.SleepStageSegment import org.junit.Assert.assertFalse import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals import org.junit.Assert.assertTrue import org.junit.Test +import java.time.Instant class EventPersistenceIdentityTest { @Test @@ -114,6 +118,218 @@ class EventPersistenceIdentityTest { assertEquals(listOf("LIGHT", "DEEP", "LIGHT"), merged.map { it.stageRaw }) } + @Test + fun `timestamped correction replaces collapsed sleep across explicit bounds idempotently`() { + val start = Instant.parse("2026-07-06T22:30:00Z") + val end = start.plusSeconds(6 * 60 * 60L) + val startMs = start.toEpochMilli() + val endMs = end.toEpochMilli() + val oldCollapsed = listOf(block("collapsed", startMs, 116, SleepStage.LIGHT.name)) + val segments = listOf( + SleepStageSegment(SleepStage.UNKNOWN, start, start.plusSeconds(30 * 60L)), + SleepStageSegment(SleepStage.LIGHT, start.plusSeconds(30 * 60L), start.plusSeconds(90 * 60L)), + SleepStageSegment(SleepStage.UNKNOWN, start.plusSeconds(90 * 60L), start.plusSeconds(4 * 60 * 60L)), + SleepStageSegment(SleepStage.DEEP, start.plusSeconds(4 * 60 * 60L), start.plusSeconds(4 * 60 * 60L + 56 * 60L)), + SleepStageSegment(SleepStage.UNKNOWN, start.plusSeconds(4 * 60 * 60L + 56 * 60L), end), + ) + val corrected = buildTimestampedStageBlocks("sleep-session", startMs, endMs, segments) + + val first = replaceOverlappingSleepBlocks(oldCollapsed, corrected, startMs, endMs) + val replay = replaceOverlappingSleepBlocks(first, corrected, startMs, endMs) + + assertEquals(360, first.sumOf { it.durationMinutes }) + assertEquals(listOf("UNKNOWN", "LIGHT", "UNKNOWN", "DEEP", "UNKNOWN"), first.map { it.stageRaw }) + assertEquals(first.map { it.startAt to it.durationMinutes }, replay.map { it.startAt to it.durationMinutes }) + assertTrue(first.none { it.id == "collapsed" }) + assertTrue(first.zipWithNext().all { (a, b) -> + a.startAt + a.durationMinutes * 60_000L <= b.startAt + }) + assertTrue(first.all { it.startAt + it.durationMinutes * 60_000L <= endMs }) + } + + @Test + fun `stitched YCBT night replaces malformed parents with one bounded idempotent group`() { + val start = Instant.parse("2026-08-24T02:07:46Z") + val aClassifiedEnd = start.plusSeconds(3_642) + val bStart = Instant.parse("2026-08-24T05:01:08Z") + val bClassifiedEnd = bStart.plusSeconds(3_036) + val cStart = Instant.parse("2026-08-24T06:33:32Z") + val cClassifiedEnd = cStart.plusSeconds(6_999) + val end = Instant.parse("2026-08-24T08:30:17Z") + val startMs = start.toEpochMilli() + val endMs = end.toEpochMilli() + val parentA = sleepParent("parent-a", start, Instant.parse("2026-08-24T03:08:32Z"), 60) + val parentC = sleepParent("parent-c", cStart, end, 116) + val malformed = listOf( + block("a-60", startMs, 60, SleepStage.LIGHT.name, parentA.id), + block("b-under-c-50", bStart.toEpochMilli(), 50, SleepStage.DEEP.name, parentC.id), + block("c-116", cStart.toEpochMilli(), 116, SleepStage.REM.name, parentC.id), + ) + assertEquals(166, malformed.filter { it.sessionId == parentC.id }.sumOf { it.durationMinutes }) + assertTrue(malformed.any { it.sessionId == parentC.id && it.startAt < parentC.startAt }) + + val replacements = buildTimestampedStageBlocks( + "stitched", + startMs, + endMs, + listOf( + SleepStageSegment(SleepStage.LIGHT, start, aClassifiedEnd), + SleepStageSegment(SleepStage.DEEP, bStart, bClassifiedEnd), + SleepStageSegment(SleepStage.REM, cStart, cClassifiedEnd), + ), + ) + val replacedParentIds = setOf(parentA.id, parentC.id) + val firstBlocks = replaceOverlappingSleepBlocks( + malformed, replacements, startMs, endMs, removeSessionIds = replacedParentIds, + ) + val replayBlocks = replaceOverlappingSleepBlocks( + firstBlocks, replacements, startMs, endMs, removeSessionIds = replacedParentIds, + ) + val firstGroups = buildSleepReconcileGroups(firstBlocks, startMs to endMs) + val replayGroups = buildSleepReconcileGroups(replayBlocks, startMs to endMs) + val plan = buildSleepReconcilePlan(listOf(parentA, parentC), firstGroups) + + assertEquals(382, (endMs - startMs) / 60_000L) + assertEquals(382, firstBlocks.sumOf { it.durationMinutes }) + assertEquals(firstBlocks.map { it.startAt to it.durationMinutes }, replayBlocks.map { it.startAt to it.durationMinutes }) + assertEquals(firstGroups, replayGroups) + assertEquals(1, firstGroups.size) + assertEquals(startMs to endMs, firstGroups.single().start to firstGroups.single().end) + assertTrue(firstBlocks.all { + it.startAt >= startMs && it.startAt + it.durationMinutes * 60_000L <= endMs + }) + assertEquals(1, plan.matches.size) + assertEquals(setOf(parentA.id), plan.deleteSessionIds) + } + + @Test + fun `timestamped blocks floor partial minutes without crossing explicit end`() { + val start = Instant.parse("2026-07-06T22:30:00Z") + val end = start.plusSeconds(119) + val blocks = buildTimestampedStageBlocks( + "sleep-session", + start.toEpochMilli(), + end.toEpochMilli(), + listOf(SleepStageSegment(SleepStage.LIGHT, start, end.plusSeconds(30))), + ) + + assertEquals(1, blocks.single().durationMinutes) + assertTrue(blocks.single().startAt + blocks.single().durationMinutes * 60_000L <= end.toEpochMilli()) + } + + @Test + fun `timestamped blocks quantize cumulative fractional boundaries without losing coverage`() { + val start = Instant.parse("2026-07-06T22:30:00Z") + val firstBoundary = start.plusSeconds(90) + val secondBoundary = start.plusSeconds(180) + val end = start.plusSeconds(270) + + val blocks = buildTimestampedStageBlocks( + "sleep-session", + start.toEpochMilli(), + end.toEpochMilli(), + listOf( + SleepStageSegment(SleepStage.LIGHT, start, firstBoundary), + SleepStageSegment(SleepStage.DEEP, firstBoundary, secondBoundary), + SleepStageSegment(SleepStage.REM, secondBoundary, end), + ), + ) + + assertEquals(4, blocks.sumOf { it.durationMinutes }) + assertEquals(listOf("LIGHT", "DEEP", "REM"), blocks.map { it.stageRaw }) + assertEquals(listOf(1, 2, 1), blocks.map { it.durationMinutes }) + assertEquals(listOf(0, 1, 3), blocks.map { it.startMinute }) + assertTrue(blocks.zipWithNext().all { (a, b) -> + a.startAt + a.durationMinutes * 60_000L <= b.startAt + }) + assertTrue(blocks.all { it.startAt + it.durationMinutes * 60_000L <= end.toEpochMilli() }) + } + + @Test + fun `thirty second awake transition survives minute quantization`() { + val start = Instant.parse("2026-07-06T22:30:00Z") + val awakeStart = start.plusSeconds(30 * 60L) + val awakeEnd = awakeStart.plusSeconds(30) + val end = start.plusSeconds(60 * 60L) + + val blocks = buildTimestampedStageBlocks( + "sleep-session", + start.toEpochMilli(), + end.toEpochMilli(), + listOf( + SleepStageSegment(SleepStage.LIGHT, start, awakeStart), + SleepStageSegment(SleepStage.AWAKE, awakeStart, awakeEnd), + SleepStageSegment(SleepStage.DEEP, awakeEnd, end), + ), + ) + + assertEquals(60, blocks.sumOf { it.durationMinutes }) + assertEquals(listOf("LIGHT", "AWAKE", "DEEP"), blocks.map { it.stageRaw }) + assertEquals(1, blocks.single { it.stageRaw == "AWAKE" }.durationMinutes) + assertTrue(blocks.zipWithNext().all { (a, b) -> + a.startAt + a.durationMinutes * 60_000L <= b.startAt + }) + assertTrue(blocks.all { it.startAt + it.durationMinutes * 60_000L <= end.toEpochMilli() }) + } + + @Test + fun `corrected sleep updatedAt advances within the same millisecond`() { + assertEquals(1_001L, nextSleepUpdatedAt(wallClockNow = 1_000L, existingUpdatedAt = 1_000L)) + } + + @Test + fun `corrected sleep updatedAt advances across clock rollback and saturates safely`() { + assertEquals(2_001L, nextSleepUpdatedAt(wallClockNow = 1_000L, existingUpdatedAt = 2_000L)) + assertEquals(Long.MAX_VALUE, nextSleepUpdatedAt(0L, Long.MAX_VALUE)) + } + + @Test + fun `authoritative night stays separate from a retained nap thirty minutes later`() { + val nightStart = Instant.parse("2026-07-06T22:30:00Z").toEpochMilli() + val nightEnd = nightStart + 6 * 60 * 60_000L + val blocks = listOf( + block("night", nightStart, 360, SleepStage.LIGHT.name), + block("nap", nightEnd + 30 * 60_000L, 30, SleepStage.DEEP.name), + ) + + val first = buildSleepReconcileGroups(blocks, nightStart to nightEnd) + val replay = buildSleepReconcileGroups(first.flatMap { it.blocks }, nightStart to nightEnd) + + assertEquals(2, first.size) + assertEquals(first, replay) + assertEquals(nightStart to nightEnd, first[0].start to first[0].end) + assertTrue(first.all { group -> + group.blocks.all { block -> + block.startAt >= group.start && + block.startAt + block.durationMinutes * 60_000L <= group.end + } + }) + } + + @Test + fun `retained block ending at authoritative night start remains separate`() { + val nightStart = Instant.parse("2026-07-06T22:30:00Z").toEpochMilli() + val nightEnd = nightStart + 6 * 60 * 60_000L + val groups = buildSleepReconcileGroups( + listOf( + block("before", nightStart - 30 * 60_000L, 30, SleepStage.REM.name), + block("night", nightStart, 360, SleepStage.LIGHT.name), + ), + nightStart to nightEnd, + ) + + assertEquals(2, groups.size) + assertEquals(listOf("before"), groups[0].blocks.map { it.id }) + assertEquals(listOf("night"), groups[1].blocks.map { it.id }) + assertEquals(nightStart to nightEnd, groups[1].start to groups[1].end) + assertTrue(groups.all { group -> + group.blocks.all { block -> + block.startAt >= group.start && + block.startAt + block.durationMinutes * 60_000L <= group.end + } + }) + } + @Test fun `short nap cannot replace a longer night on the same waking day`() { val nightStart = 1_721_234_000_000L @@ -147,13 +363,28 @@ class EventPersistenceIdentityTest { ) } - private fun block(id: String, start: Long, duration: Int, stage: String) = + private fun block( + id: String, + start: Long, + duration: Int, + stage: String, + sessionId: String = "sleep-session", + ) = SleepStageBlockEntity( id = id, - sessionId = "sleep-session", + sessionId = sessionId, startAt = start, startMinute = 0, durationMinutes = duration, stageRaw = stage, ) + + private fun sleepParent(id: String, start: Instant, end: Instant, minutes: Int) = + SleepSessionEntity( + id = id, + date = start.toEpochMilli(), + startAt = start.toEpochMilli(), + endAt = end.toEpochMilli(), + totalMinutes = minutes, + ) }