From 52f1ad476a46c139749c0b5fa20c3584c49e7ac1 Mon Sep 17 00:00:00 2001 From: llmassisted Date: Tue, 25 Aug 2026 16:18:52 -0400 Subject: [PATCH 1/6] feat(oura): integrate Ring 4 sleep and HRV support --- .../com/noop/analytics/AnalyticsEngine.kt | 68 ++- .../com/noop/analytics/AnalyticsModels.kt | 15 + .../com/noop/analytics/IntelligenceEngine.kt | 443 +++++++++------ .../com/noop/analytics/OuraRestingWindows.kt | 84 +++ .../com/noop/analytics/SleepStageHealer.kt | 29 +- .../main/java/com/noop/ble/GattWriteQueue.kt | 47 ++ .../java/com/noop/ble/OuraInstallKeyStore.kt | 58 +- .../java/com/noop/ble/OuraKeyCandidates.kt | 28 + .../main/java/com/noop/ble/OuraLiveSource.kt | 533 ++++++++++++++++-- .../java/com/noop/ble/SourceCoordinator.kt | 83 ++- .../src/main/java/com/noop/data/WhoopDao.kt | 15 + .../java/com/noop/data/WhoopRepository.kt | 18 +- .../src/main/java/com/noop/oura/Commands.kt | 22 + .../main/java/com/noop/oura/OuraIdentity.kt | 32 ++ .../src/main/java/com/noop/oura/RingGen.kt | 5 + .../main/java/com/noop/ui/AddDeviceWizard.kt | 23 +- .../src/main/java/com/noop/ui/AppViewModel.kt | 131 ++++- .../src/main/java/com/noop/ui/HealthScreen.kt | 403 ++++++++++++- .../src/main/java/com/noop/ui/MainActivity.kt | 16 + .../main/java/com/noop/ui/OuraNativeHrv.kt | 92 +++ .../src/main/java/com/noop/ui/SleepScreen.kt | 11 +- .../noop/analytics/OuraRestingWindowsTest.kt | 57 ++ .../com/noop/analytics/SleepWindowHintTest.kt | 40 ++ .../java/com/noop/ble/GattWriteQueueTest.kt | 53 ++ .../com/noop/ble/OuraKeyCandidatesTest.kt | 47 ++ .../com/noop/oura/OuraFeatureStatusTest.kt | 22 +- .../com/noop/oura/RingGenProductInfoTest.kt | 18 + .../java/com/noop/ui/OuraNativeHrvTest.kt | 139 +++++ 28 files changed, 2250 insertions(+), 282 deletions(-) create mode 100644 android/app/src/main/java/com/noop/analytics/OuraRestingWindows.kt create mode 100644 android/app/src/main/java/com/noop/ble/GattWriteQueue.kt create mode 100644 android/app/src/main/java/com/noop/ble/OuraKeyCandidates.kt create mode 100644 android/app/src/main/java/com/noop/oura/OuraIdentity.kt create mode 100644 android/app/src/main/java/com/noop/ui/OuraNativeHrv.kt create mode 100644 android/app/src/test/java/com/noop/analytics/OuraRestingWindowsTest.kt create mode 100644 android/app/src/test/java/com/noop/analytics/SleepWindowHintTest.kt create mode 100644 android/app/src/test/java/com/noop/ble/GattWriteQueueTest.kt create mode 100644 android/app/src/test/java/com/noop/ble/OuraKeyCandidatesTest.kt create mode 100644 android/app/src/test/java/com/noop/ui/OuraNativeHrvTest.kt diff --git a/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt b/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt index 32d854a2d1..e97c9f021e 100644 --- a/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt @@ -405,6 +405,11 @@ object AnalyticsEngine { // Threaded rather than read from a global so this stays a pure function, and defaulted so every // existing caller and test is byte-identical. effortMethod: StrainScorer.Method = StrainScorer.Method.EDWARDS, + // Trusted in-bed bounds from a source that cannot feed the motion detector (notably Oura Ring 4), + // or from an explicit user bedtime/wake pair. A source-provided hypnogram is used verbatim; + // otherwise the selected V1/V2 recipe stages the bounded cardio streams. Default empty keeps every + // existing caller and motion-capable device byte-identical. + sleepWindowHints: List = emptyList(), ): DayResult { // Precompute the day's UTC bounds ONCE (#996). isoDay is a FIXED-UTC formatter, so @@ -437,24 +442,57 @@ object AnalyticsEngine { // #804 Fix A: fold in the caller's device-provided hypnogram (see [providedSleep]). Empty = the // byte-identical motion-only path. Otherwise enrich each provided session's nightly restingHR/avgHRV // from THIS day's hr/rr over its window (the stored ring row carries neither), using the SAME helpers - // detectSleep populates a session with, then keep only the detected sessions that DON'T overlap a - // provided one (provided is authoritative where they collide; a separate nap survives). - val allSessions: List = if (providedSleep.isEmpty()) { - refinedSessions - } else { - val rrSorted = rr.sortedBy { it.ts } - val enrichedProvided = providedSleep.map { s -> - if (s.restingHR != null && s.avgHRV != null) s - else s.copy( - restingHR = s.restingHR ?: SleepStager.sessionRestingHR(s.start, s.end, hr), - avgHRV = s.avgHRV ?: SleepStager.sessionAvgHRV(s.start, s.end, rrSorted), - ) + // detectSleep populates a session with. Explicit/user hints win over a provided session; otherwise a + // provided hypnogram is authoritative over motion detection. Disjoint detected naps survive. + val rrSorted = rr.sortedBy { it.ts } + val enrichedProvided = providedSleep.map { s -> + if (s.restingHR != null && s.avgHRV != null) s + else s.copy( + restingHR = s.restingHR ?: SleepStager.sessionRestingHR(s.start, s.end, hr), + avgHRV = s.avgHRV ?: SleepStager.sessionAvgHRV(s.start, s.end, rrSorted), + ) + } + + // A hint supplies only the missing DETECTION boundary; the normal stager and physiology functions + // still compute NOOP's stages/RHR/HRV from the raw streams. This is what lets an Oura 4 night flow + // into Sleep, Rest, vitals and Charge without pretending its movement-gated 0x47 events are a + // continuous accelerometer. When Oura did expose SleepNet stages, preserve those ring-provided + // stages and only derive the physiology around them. + val hintedSessions = sleepWindowHints.mapNotNull { hint -> + if (hint.start < 0L || hint.end <= hint.start || hint.end - hint.start > 16L * 3_600L) { + return@mapNotNull null } - val keptDetected = refinedSessions.filter { d -> - enrichedProvided.none { it.start < d.end && d.start < it.end } + val supplied = hint.stages + ?.mapNotNull { s -> + val start = maxOf(hint.start, s.start) + val end = minOf(hint.end, s.end) + if (end <= start) null else StageSegment(start, end, s.stage) + } + ?.takeIf { it.isNotEmpty() } + val stages = supplied ?: if (useSleepStagerV2) { + SleepStagerV2.stageSession(hint.start, hint.end, gravity, hr, rrSorted, resp) + } else { + SleepStager.stageSession(hint.start, hint.end, gravity, hr, rrSorted, resp) } - keptDetected + enrichedProvided + DetectedSleep( + start = hint.start, + end = hint.end, + efficiency = SleepStager.efficiency(hint.start, hint.end, stages), + stages = stages, + restingHR = SleepStager.sessionRestingHR(hint.start, hint.end, hr), + avgHRV = SleepStager.sessionAvgHRV(hint.start, hint.end, rrSorted), + ) + } + // Trusted hints win over every overlapping source. A provided hypnogram then wins over motion + // detection, and disjoint detector sessions (for example, a separate nap) remain visible. + val keptProvided = enrichedProvided.filter { provided -> + hintedSessions.none { hinted -> provided.start < hinted.end && hinted.start < provided.end } + } + val keptDetected = refinedSessions.filter { detected -> + hintedSessions.none { hinted -> detected.start < hinted.end && hinted.start < detected.end } && + keptProvided.none { provided -> detected.start < provided.end && provided.start < detected.end } } + val allSessions = (keptDetected + keptProvided + hintedSessions).sortedBy { it.start } // Sessions attributed to `day` = those whose end falls on `day` (LOCAL day, #277). `day` is // the caller's local-day key; attribute by the same offset so the bucket and the key agree. val matched = allSessions.filter { tsInDay(it.end) } diff --git a/android/app/src/main/java/com/noop/analytics/AnalyticsModels.kt b/android/app/src/main/java/com/noop/analytics/AnalyticsModels.kt index 04c4587414..e520f0c0b6 100644 --- a/android/app/src/main/java/com/noop/analytics/AnalyticsModels.kt +++ b/android/app/src/main/java/com/noop/analytics/AnalyticsModels.kt @@ -89,6 +89,21 @@ data class DetectedSleep( val avgHRV: Double?, ) +/** + * A trusted in-bed boundary supplied outside the motion detector. + * + * Oura Ring 4 does not expose a continuous gravity stream, so its ring-provided SleepNet session (when + * available), a sufficiently long automatic-resting window, or a user-marked bedtime/wake pair must be + * able to provide the bounds while the normal NOOP stager derives physiology inside them. [stages] is + * non-null only when the source already supplied a hypnogram; otherwise V1/V2 stages the bounded raw + * cardio streams. The detector remains unchanged for every source that has motion. + */ +data class SleepWindowHint( + val start: Long, + val end: Long, + val stages: List? = null, +) + /** * AASM-style metrics from a session's stage segments. * Mirrors Swift `SleepStager.HypnogramMetrics`. diff --git a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt index 22c031b80e..00df0841c4 100644 --- a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt @@ -665,6 +665,21 @@ object IntelligenceEngine { // `<= 0` / `< 18`, so the two platforms land on the identical floor. val sleepNeedHours = RestScorer.personalizedNeedHours(nightlyHours, profile.age.toInt()) + // User-corrected/marked rows are needed in pass 1 as detector-independent bounds as well as in + // pass 2's edit override. This is the same self-heal call that historically ran between the two + // passes, moved earlier so it can serve both without adding a suspend point to the size-critical + // per-day loop. Raw streams live under the strap id; edited rows live under the computed id. + val windowStart = nowSeconds - maxDays.toLong() * SECONDS_PER_DAY - 30 * 3_600L + val editedRows = SleepStageHealer.selfHealEditedStages( + repo = repo, + computedDeviceId = computedId, + strapDeviceId = importedDeviceId, + windowStart = windowStart, + windowEnd = nowSeconds, + useExperimentalSleepV2 = useExperimentalSleepV2, + useMotionAwareWake = useMotionAwareWake, + ) + // #970 read efficiency, skin-temp leg: [RegistryDayOwnerSource.skinTempFamily] resolves the family // via registry.all() — a Room query — and the loop below wants it once per DAY, so a 21-day scan // re-read the paired-devices table ~21× for what is almost always ONE owner. Swift never paid this: @@ -931,7 +946,10 @@ object IntelligenceEngine { // only when its off-wrist coverage reaches maxOffWristSleepFraction, so a real night with a // short off-wrist tail survives. Pairing needs WRIST_ON too (to bound each interval); a span // still open at the window end closes at `to`. Empty when the strap emitted no wrist events. - val wristOff = AnalyticsEngine.offWristIntervals(repo.events(owner, from, to, STREAM_LIMIT), to) + // Keep the rows: Oura's native HRV events also supply a conservative resting-window fallback, + // so one Room read serves both consumers. + val nightEvents = repo.events(owner, from, to, STREAM_LIMIT) + val wristOff = AnalyticsEngine.offWristIntervals(nightEvents, to) // Calendar-day window for the ADDITIVE daily totals (steps + calories). The night window // above is anchored to the current time-of-day and ends at dayStart+12h, so for a PAST @@ -981,13 +999,21 @@ object IntelligenceEngine { // scores. Gated on absent gravity (`grav.size < 2` — a ring streams zero; a WHOOP always streams a // gravity vector) plus a non-canonical-WHOOP-import owner, so WHOOP straps and the "my-whoop" // import namespace are untouched; analyzeDay still lets a DETECTED session win where they overlap. - val providedSleep: List = + val providedSleepRows: List = if (owner != importedDeviceId && grav.size < 2) { repo.sleepSessions(owner, from, to, 4000) - .mapNotNull { AnalyticsEngine.sleepSessionFromProvided(it) } } else { emptyList() } + val sleepEvidence = detectorIndependentSleepForDay( + day = day, + tzOffsetSeconds = tzOffsetSeconds, + ownerIsOura = owner.startsWith("oura-", ignoreCase = true), + editedRows = editedRows, + providedRows = providedSleepRows, + nightEvents = nightEvents, + diag = diag, + ) val tScore0 = System.nanoTime() dayPrepNanos += tScore0 - tPrep0 @@ -1027,7 +1053,7 @@ object IntelligenceEngine { // #364 follow-up: same threading for the motion-aware wake refinement post-pass. useMotionAwareWake = useMotionAwareWake, // #804 Fix A: the owner's own device-provided hypnogram (empty for WHOOP/non-ring days). - providedSleep = providedSleep, + providedSleep = sleepEvidence.provided, // Sleep & Rest test mode (Test Centre E5): thread the trace sink straight through. null (the // default) keeps analyzeDay's byte-identical untraced path; when the caller passed a non-null // sink (mode on), detectSleep's gate trace + the Rest sub-score line route to the .sleep-tagged @@ -1039,6 +1065,7 @@ object IntelligenceEngine { hrvWindowDetail = dayStart == nowLocalMidnight, deepHrvWindow = deepHrvWindow, effortMethod = effortMethod, + sleepWindowHints = sleepEvidence.hints, ) dayScoreNanos += System.nanoTime() - tScore0 @@ -1049,143 +1076,19 @@ object IntelligenceEngine { // `nInput` is set before the min-beats gate, so a sparse night still shows its count with // rmssd=nil). A SEPARATE analyzeRaw pass over the in-sleep R-R — does NOT touch the shipped // windowed avgHrv. Emitted here where `rr` is in scope; byte-identical to the Swift line. - val sleepRrRows = rr.filter { r -> res.sleepSessions.any { r.ts >= it.start && r.ts < it.end } } - val sleepRr = sleepRrRows.map { it.rrMs.toDouble() } - if (sleepRr.isNotEmpty()) { - val h = HrvAnalyzer.analyzeRaw(sleepRr) - val ms = { v: Double? -> v?.let { String.format(java.util.Locale.US, "%.0f", it) } ?: "nil" } - val rej = if (h.nInput > 0) String.format(java.util.Locale.US, "%.0f", 100.0 * (1.0 - h.nClean.toDouble() / h.nInput)) else "0" - // #257: coverage (sum of NN ÷ wall-clock span; > 1.0 is impossible without double-counted - // R-R) + exact-duplicate beat count, so a "reads ~2x too high" report is self-diagnosing - // from the always-on log instead of hand-computing beat density. - val ts = sleepRrRows.map { it.ts } - // Computed ONCE and reused for both the formatted field and the verdict below: - // collapsedCoverage sorts and de-dups the whole night's R-R (tens of thousands of rows on a - // dense capture), and this runs per day across a full re-score. - val covVal = HrvAnalyzer.rrCoverage(ts, sleepRr) - val cov = String.format(java.util.Locale.US, "%.2f", covVal) - // #550: collapsedCov previews a same-second R-R de-dup — well below `coverage` ⇒ the - // over-count is same-second (a dedup fix would work); still high ⇒ cross-second overlap. - val colCovVal = HrvAnalyzer.collapsedCoverage(ts, sleepRr) - val colCov = String.format(java.util.Locale.US, "%.2f", colCovVal) - val dup = HrvAnalyzer.duplicateBeatCount(ts, sleepRr) - // #550: state the CONCLUSION, not just the evidence. Reading coverage against collapsedCov - // is what distinguishes a same-second over-count (a de-dup would fix it) from a cross-second - // one (it would not) — a rule that lived only in the comments above, so triaging an - // "HRV reads ~2x high" report required knowing it. Now the line says which. - val verdict = HrvAnalyzer.classifyCoverage(covVal, colCovVal) - // #550 follow-up: having stated the conclusion, ACT on it. SDNN is a spread over every - // interval, so an over-counted night inflates it directly — a ring whose banked R-R covers - // 1.25x its wall-clock reads ~197 ms across a sleeping night, against a 40-100 ms - // physiological range. Printing that number beside the verdict that says it cannot be - // trusted invites it to be read as a measurement, so it is withheld instead; the - // `rrIntegrity=` field on the same line says why. RMSSD/meanNN are NOT withheld — mean rate - // survives an over-count, and RMSSD's dominant error was the emission order fixed at the - // write path (#1072). Twin of the Swift line. - // P7' follow-up: the over-count verdict is necessary but NOT sufficient. The 2026-08-06 - // Oura night measured coverage 1.03 / PLAUSIBLE — no duplication at all, its records - // tiling the timeline at a fill ratio of 0.990 — and still printed SDNN 174 ms. A BANKED - // stream stamps a whole record of intervals on one timestamp, so its stored values are a - // decomposition of a record period, not beat-to-beat measurements: the per-record SUM is - // right to ~1% (meanNN and RHR stay correct and WHOOP-validated) while the individual - // intervals are not. Gate on that too. Twin of the Swift line. - val accVal = HrvAnalyzer.beatAccurateFraction(ts, sleepRr) - val acc = String.format(java.util.Locale.US, "%.2f", accVal) - val sdnnField = - if (HrvAnalyzer.beatSpreadIsTrustworthy(verdict) && - HrvAnalyzer.beatValuesAreTrustworthy(accVal)) "${ms(h.sdnn)}ms" else "withheld" - dayDiag("hrv diag day=${res.daily.day} rmssd=${ms(h.rmssd)}ms sdnn=$sdnnField meanNN=${ms(h.meanNN)}ms " + - "rr=${h.nInput}/${h.nClean} rejected=$rej% coverage=$cov collapsedCov=$colCov dupBeats=$dup " + - "beatAccurate=$acc " + - "rrIntegrity=${verdict.raw}") - // #1008: on an OVER-COUNT night only, dump a raw-row sample around the densest second so the - // over-count's MECHANISM is readable from the always-on log (near-equal copies vs distinct - // trains vs a tagged channel) — clean nights stay quiet. srcChannel rides from the read model. - // #1118: flag this night's HRV as over-counted (same verdict the diag logs) so the HRV - // card can mark the reading unverified until the two-channel de-dup lands. - hrvOverCountByDay[res.daily.day] = - (verdict == HrvAnalyzer.RrCoverageVerdict.CROSS_SECOND_OVER_COUNT || - verdict == HrvAnalyzer.RrCoverageVerdict.SAME_SECOND_OVER_COUNT) - if (verdict == HrvAnalyzer.RrCoverageVerdict.CROSS_SECOND_OVER_COUNT || - verdict == HrvAnalyzer.RrCoverageVerdict.SAME_SECOND_OVER_COUNT) { - // Built ONCE for all three diagnostics below. Each call materialised its own copy of - // the night's ords — ~70k elements, on a path that runs ~21 times per analyzeRecent, - // every 15 minutes — so two of the three were pure waste. (#1510) Twin of the Swift hoist. - val rrOrds = sleepRrRows.map { it.ord } - val sample = HrvAnalyzer.densestSecondWindowSample( - ts, sleepRr, sleepRrRows.map { it.srcChannel }, - rrOrds, - ) - if (sample.isNotEmpty()) dayDiag("hrv rrsample day=${res.daily.day} $sample") - // #1331/#1008: the sample above shows ords for a handful of seconds; this counts them - // across the WHOLE night, which is what decides whether the fix belongs at ingest. - val deliveries = HrvAnalyzer.deliveryHistogram(ts, sleepRr, rrOrds) - if (deliveries.isNotEmpty()) dayDiag("$deliveries day=${res.daily.day}") - // #1505: the histogram above counts deliveries per second but never compares what they - // wrote. If the live and historical copies are the same beat in two units, a duplicated - // second holds two values 1024/1000 apart; if they are different beats, the ratios - // scatter. One pair cannot tell those apart — a night's worth can. Measurement only, - // takes no view on the answer. Swift twin. - val dupPairs = HrvAnalyzer.duplicatePairRatios(ts, sleepRr, rrOrds) - if (dupPairs.isNotEmpty()) dayDiag("$dupPairs day=${res.daily.day}") - // #1331/#1008/#1118 SHADOW: log the DEDUPED stream's HRV + coverage + beat-accuracy - // beside the raw so the candidate de-dup can be validated vs WHOOP + @artemc's Polar - // before it becomes the read path. Instrumentation only — shipped HRV/resp unchanged. - // If de-dup works: coverage→~1.0, beatAccurate high (would pass #1127's RSA gate → - // resp returns = the #1331 fix), rmssd/sdnn physiological. Twin of the Swift line. - // Two candidates so validation isn't confounded: EXACT-dup collapse (rrTolMs 0 — same - // ts AND value, no real-beat loss) is the safe floor; the ~40 ms collapse is the - // aggressive UPPER BOUND (catches the two-channel twins but can over-merge two real - // neighbours within 40 ms). Log both so we can see where the real de-dup sits. Twin. - // #1331 RETIRED (two of them). `xsec` — the 40 ms collapse widened to a 1-second - // window — was a strict UPPER BOUND that over-merges real beats, kept only to size how - // far a cross-second collapse COULD get. It has now produced that number in the field: - // covXsec 0.80 with beatAccXsec 0.26, i.e. it eats real beats exactly as its own - // comment predicted. And the same-second TOLERANCE SWEEP (20/34/60) was sizing a fix - // already ruled out — every affected night reads CROSS_SECOND_OVER_COUNT, so no - // same-second tolerance can reach duplicates that straddle the boundary. - // - // Both cost real work on the phones least able to spare it: this block runs for EVERY - // night of an affected strap, analyzeRecent re-scores ~21 days every 15 minutes, and - // each collapseOverCount SORTS the night's ~50-70k intervals. Six sorts per night - // became two. What survives is the honest floor (`ex`, exact duplicates only — - // provably no real-beat loss) and the incumbent candidate (`dd`, 40 ms same-second). - // The delivery histogram above supersedes what both retired measurements reached for, - // and costs one pass instead of nine. Twin of the Swift block. - val ex = HrvAnalyzer.collapseOverCount(ts, sleepRr, 0.0) - val dd = HrvAnalyzer.collapseOverCount(ts, sleepRr) - val hDd = HrvAnalyzer.analyzeRaw(dd.second) - val covEx = HrvAnalyzer.rrCoverage(ex.first, ex.second) - val covDd = HrvAnalyzer.rrCoverage(dd.first, dd.second) - val accDd = HrvAnalyzer.beatAccurateFraction(dd.first, dd.second) - dayDiag("hrv dedup day=${res.daily.day} exactN=${ex.second.size}/${sleepRr.size} " + - "covExact=${String.format(java.util.Locale.US, "%.2f", covEx)} | ch40N=${dd.second.size} " + - "cov40=${String.format(java.util.Locale.US, "%.2f", covDd)} " + - "beatAcc40=${String.format(java.util.Locale.US, "%.2f", accDd)} " + - "rmssd40=${ms(hDd.rmssd)}ms sdnn40=${ms(hDd.sdnn)}ms meanNN40=${ms(hDd.meanNN)}ms " + - "| collapse candidates only; the DELIVERY histogram above sizes the fix") - // #1118 sweep: the same-second collapse at a range of tolerances, so a capture shows - // WHICH tolerance the over-count actually responds to instead of only the one 40 ms - // point. 34 ms is the two-optical-channel twin spacing; 0 is exact-duplicates-only. - // The 0 and 40 points are NOT recomputed: `ex` and `dd` above ARE those collapses - // (collapseOverCount's default tolerance is 40), and each collapse sorts the night's - // intervals — ~50k on an over-count night. Reusing them keeps the sweep to three extra - // passes instead of five on a block that runs for EVERY night of an affected strap. - } - } else if (res.sleepSessions.isEmpty()) { - // #1244: no in-sleep R-R AND no detected session (past the >=200-HR gate) = the "HR tracked, - // no sleep" case. Emit a counts-only reason line naming the inputs the stager had, so the - // report says WHY nothing staged. `window` is the read span in whole hours (30 h back → next - // local midnight, or +18 h for today). Byte-identical to the Swift line. - val windowHours = ((to - from) / 3_600L).toInt() - dayDiag( - sleepDetectNoNightLogLine( - day = day, hrCount = hr.size, rrCount = rr.size, respCount = resp.size, - gravCount = grav.size, stepCount = steps.size, providedCount = providedSleep.size, - windowHours = windowHours, - ), - ) - } + emitHrvAndSleepDiagnostics( + rr = rr, + res = res, + dayDiag = ::dayDiag, + day = day, + from = from, + to = to, + hrCount = hr.size, + respCount = resp.size, + gravCount = grav.size, + stepCount = steps.size, + providedCount = sleepEvidence.provided.size, + )?.let { hrvOverCountByDay[res.daily.day] = it } // Steps test mode: emit the 5/MG raw-counter trace for this day (cumulative @57 series + // wrap-aware deltas + dropped deltas), tagged .steps. Only when the mode is on (the sink is @@ -1389,7 +1292,6 @@ object IntelligenceEngine { // the cross-source duplicate (#107): the strap source carries imported WHOOP rows AND manual / // re-labelled rows (both under [importedDeviceId]); apple-health / health-connect carry Health // imports , a detected bout overlapping ANY of them is skipped below. - val windowStart = nowSeconds - maxDays.toLong() * SECONDS_PER_DAY - 30 * 3_600L val realWorkouts = repo.workouts(importedDeviceId, windowStart, nowSeconds) + repo.workouts("apple-health", windowStart, nowSeconds) + repo.workouts("health-connect", windowStart, nowSeconds) @@ -1417,22 +1319,8 @@ object IntelligenceEngine { // scope as iOS. Keyed by the IMMUTABLE detected `startTs` (never `effectiveStartTs`), so an // edited block lands exactly on its detected twin. // - // Self-heal any night edited before its raw streams synced (port of iOS PR #449, see - // [SleepStageHealer.selfHealEditedStages]): re-derive stages from the now-available raw over the - // night's LOCKED bounds, rewrite the stage breakdown ONLY (userEdited=1 rows, bounds untouched), - // and return the refreshed edited rows so `editsByStart` below carries the REAL staging into the - // daily aggregate this same pass. A no-op for nights already staged from raw (idempotent) and for - // imported nights (raw never dense). MUST run before `editsByStart` so healed stages flow into - // Rest/recovery this run. Raw streams are read under the STRAP id; edited rows under COMPUTED. - val editedRows = SleepStageHealer.selfHealEditedStages( - repo = repo, - computedDeviceId = computedId, - strapDeviceId = importedDeviceId, - windowStart = windowStart, - windowEnd = nowSeconds, - useExperimentalSleepV2 = useExperimentalSleepV2, - useMotionAwareWake = useMotionAwareWake, - ) + // [editedRows] was self-healed before pass 1 so the same corrected/manual bounds can also seed + // detector-independent staging. It remains the single list consumed by the per-day override below. // #299: [editsByStart] / [editOnsetByStart] are now built PER DAY inside the scoring loop (scoped to // the day each edit belongs to), NOT window-wide here. sleepEditedDaily folds any edited row that // isn't a twin of THIS day's detected sessions in as a "manual" block, so a window-wide edit set let @@ -2393,6 +2281,245 @@ object IntelligenceEngine { tzOffsetSeconds: Long, ): List = editedRows.filter { AnalyticsEngine.dayString(it.endTs, tzOffsetSeconds) == day } + /** Convert a stored ring/user session into the detector-independent hint shape. Malformed stage JSON + * falls back to bounds-only so V1/V2 can re-stage it from raw data instead of losing the session. */ + internal fun sleepWindowHint(row: SleepSession): SleepWindowHint? { + val start = row.effectiveStartTs + val end = row.endTs + if (start < 0L || end <= start || end - start > 16L * 3_600L) return null + val stages = row.stagesJSON?.let { json -> + runCatching { + val arr = org.json.JSONArray(json) + buildList { + for (i in 0 until arr.length()) { + val item = arr.optJSONObject(i) ?: continue + val s = maxOf(start, item.optLong("start", -1L)) + val e = minOf(end, item.optLong("end", -1L)) + val stage = item.optString("stage", "") + if (e > s && stage in setOf("wake", "awake", "light", "deep", "rem")) { + add(StageSegment(s, e, if (stage == "awake") "wake" else stage)) + } + } + }.takeIf { it.isNotEmpty() } + }.getOrNull() + } + return SleepWindowHint(start, end, stages) + } + + /** + * Select detector-independent sleep boundaries from rows the pass already loaded. Keeping this helper + * non-suspending is load-bearing: one extra suspension label in [analyzeRecentOnCpu] saves/restores its + * many live locals and exceeds the JVM/JaCoCo method-size budget. + */ + private data class DetectorIndependentSleep( + val provided: List, + val hints: List, + ) + + private fun detectorIndependentSleepForDay( + day: String, + tzOffsetSeconds: Long, + ownerIsOura: Boolean, + editedRows: List, + providedRows: List, + nightEvents: List, + diag: (String) -> Unit, + ): DetectorIndependentSleep { + fun belongsToThisWakeDay(endTs: Long): Boolean = + AnalyticsEngine.dayString(endTs, tzOffsetSeconds) == day + + val manual = editedRowsForDay(editedRows, day, tzOffsetSeconds) + .mapNotNull(::sleepWindowHint) + val provided = providedRows.mapNotNull(AnalyticsEngine::sleepSessionFromProvided) + if (!ownerIsOura) return DetectorIndependentSleep(provided, manual) + + // Explicit user bounds win over SleepNet. SleepNet remains `provided`; its bounds are repeated here + // only to suppress a native-resting fallback for the same night. + val source = providedRows.mapNotNull(::sleepWindowHint) + .filter { belongsToThisWakeDay(it.end) } + val resting = OuraRestingWindows.windows(nightEvents) + .filter { belongsToThisWakeDay(it.endTs) } + val trusted = ArrayList() + fun addIfDisjoint(hint: SleepWindowHint) { + if (trusted.none { it.start < hint.end && hint.start < it.end }) trusted.add(hint) + } + manual.forEach(::addIfDisjoint) + resting.asSequence() + .filter(OuraRestingWindows::isSleepCandidate) + .map { SleepWindowHint(it.startTs, it.endTs) } + .filter { fallback -> source.none { it.start < fallback.end && fallback.start < it.end } } + .forEach(::addIfDisjoint) + val shortCount = resting.count { + it.durationSeconds < OuraRestingWindows.MIN_AUTOMATIC_SLEEP_SECONDS + } + diag( + "oura rest day=$day windows=${resting.size} shortKept=$shortCount " + + "sleepHints=${trusted.size}", + ) + return DetectorIndependentSleep(provided, trusted) + } + + /** + * Emit the per-night HRV integrity trace and the no-sleep diagnostic without changing any score. + * Extracted to keep analyzeRecentOnCpu below the JVM/JaCoCo method-size ceiling. + * + * @return whether the night's R-R stream was over-counted, or null when no in-sleep R-R existed. + */ + private fun emitHrvAndSleepDiagnostics( + rr: List, + res: DayResult, + dayDiag: (String) -> Unit, + day: String, + from: Long, + to: Long, + hrCount: Int, + respCount: Int, + gravCount: Int, + stepCount: Int, + providedCount: Int, + ): Boolean? { + var overCount: Boolean? = null + val sleepRrRows = rr.filter { r -> res.sleepSessions.any { r.ts >= it.start && r.ts < it.end } } + val sleepRr = sleepRrRows.map { it.rrMs.toDouble() } + if (sleepRr.isNotEmpty()) { + val h = HrvAnalyzer.analyzeRaw(sleepRr) + val ms = { v: Double? -> v?.let { String.format(java.util.Locale.US, "%.0f", it) } ?: "nil" } + val rej = if (h.nInput > 0) String.format(java.util.Locale.US, "%.0f", 100.0 * (1.0 - h.nClean.toDouble() / h.nInput)) else "0" + // #257: coverage (sum of NN ÷ wall-clock span; > 1.0 is impossible without double-counted + // R-R) + exact-duplicate beat count, so a "reads ~2x too high" report is self-diagnosing + // from the always-on log instead of hand-computing beat density. + val ts = sleepRrRows.map { it.ts } + // Computed ONCE and reused for both the formatted field and the verdict below: + // collapsedCoverage sorts and de-dups the whole night's R-R (tens of thousands of rows on a + // dense capture), and this runs per day across a full re-score. + val covVal = HrvAnalyzer.rrCoverage(ts, sleepRr) + val cov = String.format(java.util.Locale.US, "%.2f", covVal) + // #550: collapsedCov previews a same-second R-R de-dup — well below `coverage` ⇒ the + // over-count is same-second (a dedup fix would work); still high ⇒ cross-second overlap. + val colCovVal = HrvAnalyzer.collapsedCoverage(ts, sleepRr) + val colCov = String.format(java.util.Locale.US, "%.2f", colCovVal) + val dup = HrvAnalyzer.duplicateBeatCount(ts, sleepRr) + // #550: state the CONCLUSION, not just the evidence. Reading coverage against collapsedCov + // is what distinguishes a same-second over-count (a de-dup would fix it) from a cross-second + // one (it would not) — a rule that lived only in the comments above, so triaging an + // "HRV reads ~2x high" report required knowing it. Now the line says which. + val verdict = HrvAnalyzer.classifyCoverage(covVal, colCovVal) + // #550 follow-up: having stated the conclusion, ACT on it. SDNN is a spread over every + // interval, so an over-counted night inflates it directly — a ring whose banked R-R covers + // 1.25x its wall-clock reads ~197 ms across a sleeping night, against a 40-100 ms + // physiological range. Printing that number beside the verdict that says it cannot be + // trusted invites it to be read as a measurement, so it is withheld instead; the + // `rrIntegrity=` field on the same line says why. RMSSD/meanNN are NOT withheld — mean rate + // survives an over-count, and RMSSD's dominant error was the emission order fixed at the + // write path (#1072). Twin of the Swift line. + // P7' follow-up: the over-count verdict is necessary but NOT sufficient. The 2026-08-06 + // Oura night measured coverage 1.03 / PLAUSIBLE — no duplication at all, its records + // tiling the timeline at a fill ratio of 0.990 — and still printed SDNN 174 ms. A BANKED + // stream stamps a whole record of intervals on one timestamp, so its stored values are a + // decomposition of a record period, not beat-to-beat measurements: the per-record SUM is + // right to ~1% (meanNN and RHR stay correct and WHOOP-validated) while the individual + // intervals are not. Gate on that too. Twin of the Swift line. + val accVal = HrvAnalyzer.beatAccurateFraction(ts, sleepRr) + val acc = String.format(java.util.Locale.US, "%.2f", accVal) + val sdnnField = + if (HrvAnalyzer.beatSpreadIsTrustworthy(verdict) && + HrvAnalyzer.beatValuesAreTrustworthy(accVal)) "${ms(h.sdnn)}ms" else "withheld" + dayDiag("hrv diag day=${res.daily.day} rmssd=${ms(h.rmssd)}ms sdnn=$sdnnField meanNN=${ms(h.meanNN)}ms " + + "rr=${h.nInput}/${h.nClean} rejected=$rej% coverage=$cov collapsedCov=$colCov dupBeats=$dup " + + "beatAccurate=$acc " + + "rrIntegrity=${verdict.raw}") + // #1008: on an OVER-COUNT night only, dump a raw-row sample around the densest second so the + // over-count's MECHANISM is readable from the always-on log (near-equal copies vs distinct + // trains vs a tagged channel) — clean nights stay quiet. srcChannel rides from the read model. + // #1118: flag this night's HRV as over-counted (same verdict the diag logs) so the HRV + // card can mark the reading unverified until the two-channel de-dup lands. + overCount = + (verdict == HrvAnalyzer.RrCoverageVerdict.CROSS_SECOND_OVER_COUNT || + verdict == HrvAnalyzer.RrCoverageVerdict.SAME_SECOND_OVER_COUNT) + if (verdict == HrvAnalyzer.RrCoverageVerdict.CROSS_SECOND_OVER_COUNT || + verdict == HrvAnalyzer.RrCoverageVerdict.SAME_SECOND_OVER_COUNT) { + // Built ONCE for all three diagnostics below. Each call materialised its own copy of + // the night's ords — ~70k elements, on a path that runs ~21 times per analyzeRecent, + // every 15 minutes — so two of the three were pure waste. (#1510) Twin of the Swift hoist. + val rrOrds = sleepRrRows.map { it.ord } + val sample = HrvAnalyzer.densestSecondWindowSample( + ts, sleepRr, sleepRrRows.map { it.srcChannel }, + rrOrds, + ) + if (sample.isNotEmpty()) dayDiag("hrv rrsample day=${res.daily.day} $sample") + // #1331/#1008: the sample above shows ords for a handful of seconds; this counts them + // across the WHOLE night, which is what decides whether the fix belongs at ingest. + val deliveries = HrvAnalyzer.deliveryHistogram(ts, sleepRr, rrOrds) + if (deliveries.isNotEmpty()) dayDiag("$deliveries day=${res.daily.day}") + // #1505: the histogram above counts deliveries per second but never compares what they + // wrote. If the live and historical copies are the same beat in two units, a duplicated + // second holds two values 1024/1000 apart; if they are different beats, the ratios + // scatter. One pair cannot tell those apart — a night's worth can. Measurement only, + // takes no view on the answer. Swift twin. + val dupPairs = HrvAnalyzer.duplicatePairRatios(ts, sleepRr, rrOrds) + if (dupPairs.isNotEmpty()) dayDiag("$dupPairs day=${res.daily.day}") + // #1331/#1008/#1118 SHADOW: log the DEDUPED stream's HRV + coverage + beat-accuracy + // beside the raw so the candidate de-dup can be validated vs WHOOP + @artemc's Polar + // before it becomes the read path. Instrumentation only — shipped HRV/resp unchanged. + // If de-dup works: coverage→~1.0, beatAccurate high (would pass #1127's RSA gate → + // resp returns = the #1331 fix), rmssd/sdnn physiological. Twin of the Swift line. + // Two candidates so validation isn't confounded: EXACT-dup collapse (rrTolMs 0 — same + // ts AND value, no real-beat loss) is the safe floor; the ~40 ms collapse is the + // aggressive UPPER BOUND (catches the two-channel twins but can over-merge two real + // neighbours within 40 ms). Log both so we can see where the real de-dup sits. Twin. + // #1331 RETIRED (two of them). `xsec` — the 40 ms collapse widened to a 1-second + // window — was a strict UPPER BOUND that over-merges real beats, kept only to size how + // far a cross-second collapse COULD get. It has now produced that number in the field: + // covXsec 0.80 with beatAccXsec 0.26, i.e. it eats real beats exactly as its own + // comment predicted. And the same-second TOLERANCE SWEEP (20/34/60) was sizing a fix + // already ruled out — every affected night reads CROSS_SECOND_OVER_COUNT, so no + // same-second tolerance can reach duplicates that straddle the boundary. + // + // Both cost real work on the phones least able to spare it: this block runs for EVERY + // night of an affected strap, analyzeRecent re-scores ~21 days every 15 minutes, and + // each collapseOverCount SORTS the night's ~50-70k intervals. Six sorts per night + // became two. What survives is the honest floor (`ex`, exact duplicates only — + // provably no real-beat loss) and the incumbent candidate (`dd`, 40 ms same-second). + // The delivery histogram above supersedes what both retired measurements reached for, + // and costs one pass instead of nine. Twin of the Swift block. + val ex = HrvAnalyzer.collapseOverCount(ts, sleepRr, 0.0) + val dd = HrvAnalyzer.collapseOverCount(ts, sleepRr) + val hDd = HrvAnalyzer.analyzeRaw(dd.second) + val covEx = HrvAnalyzer.rrCoverage(ex.first, ex.second) + val covDd = HrvAnalyzer.rrCoverage(dd.first, dd.second) + val accDd = HrvAnalyzer.beatAccurateFraction(dd.first, dd.second) + dayDiag("hrv dedup day=${res.daily.day} exactN=${ex.second.size}/${sleepRr.size} " + + "covExact=${String.format(java.util.Locale.US, "%.2f", covEx)} | ch40N=${dd.second.size} " + + "cov40=${String.format(java.util.Locale.US, "%.2f", covDd)} " + + "beatAcc40=${String.format(java.util.Locale.US, "%.2f", accDd)} " + + "rmssd40=${ms(hDd.rmssd)}ms sdnn40=${ms(hDd.sdnn)}ms meanNN40=${ms(hDd.meanNN)}ms " + + "| collapse candidates only; the DELIVERY histogram above sizes the fix") + // #1118 sweep: the same-second collapse at a range of tolerances, so a capture shows + // WHICH tolerance the over-count actually responds to instead of only the one 40 ms + // point. 34 ms is the two-optical-channel twin spacing; 0 is exact-duplicates-only. + // The 0 and 40 points are NOT recomputed: `ex` and `dd` above ARE those collapses + // (collapseOverCount's default tolerance is 40), and each collapse sorts the night's + // intervals — ~50k on an over-count night. Reusing them keeps the sweep to three extra + // passes instead of five on a block that runs for EVERY night of an affected strap. + } + } else if (res.sleepSessions.isEmpty()) { + // #1244: no in-sleep R-R AND no detected session (past the >=200-HR gate) = the "HR tracked, + // no sleep" case. Emit a counts-only reason line naming the inputs the stager had, so the + // report says WHY nothing staged. `window` is the read span in whole hours (30 h back → next + // local midnight, or +18 h for today). Byte-identical to the Swift line. + val windowHours = ((to - from) / 3_600L).toInt() + dayDiag( + sleepDetectNoNightLogLine( + day = day, hrCount = hrCount, rrCount = rr.size, respCount = respCount, + gravCount = gravCount, stepCount = stepCount, providedCount = providedCount, + windowHours = windowHours, + ), + ) + } + return overCount + } + + private fun sleepEditedDaily( daily: DailyMetric, detected: List, diff --git a/android/app/src/main/java/com/noop/analytics/OuraRestingWindows.kt b/android/app/src/main/java/com/noop/analytics/OuraRestingWindows.kt new file mode 100644 index 0000000000..bd8842138a --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/OuraRestingWindows.kt @@ -0,0 +1,84 @@ +package com.noop.analytics + +import com.noop.data.EventRow +import com.noop.data.OuraStreamMapping +import org.json.JSONObject + +/** One contiguous run of Oura's own automatic-resting five-minute HR/HRV buckets. */ +data class OuraRestingWindow( + val startTs: Long, + val endTs: Long, + val bucketCount: Int, + val avgHrBpm: Double?, + val avgHrvMs: Double, +) { + val durationSeconds: Long get() = endTs - startTs +} + +/** + * Converts durable Oura 0x5D events into resting windows without claiming that every rest is sleep. + * + * Every valid bucket is retained in [windows], including short awake rest and naps. [sleepCandidates] + * is deliberately narrower: in the absence of the ring's SleepNet phases, only a long continuous block + * is strong enough to supply an automatic in-bed boundary. Short naps are recorded through NOOP's + * explicit bedtime/wake or add-nap paths; a 40-minute awake resting block must not become fake sleep. + */ +object OuraRestingWindows { + const val BUCKET_SECONDS: Long = 5L * 60L + const val MAX_GAP_SECONDS: Long = 15L * 60L + const val MIN_AUTOMATIC_SLEEP_SECONDS: Long = 90L * 60L + + fun windows(events: List): List { + data class Bucket(val ts: Long, val hr: Int?, val hrv: Double) + + val buckets = events.asSequence() + .filter { it.kind == OuraStreamMapping.EVENT_HRV } + .mapNotNull { event -> + runCatching { + val json = JSONObject(event.payloadJSON) + val hrv = json.optDouble("rmssd_ms", Double.NaN) + .takeIf { it.isFinite() && it > 0.0 && it <= 255.0 } ?: return@runCatching null + val hr = json.optInt("hr_bpm", -1).takeIf { it in 20..240 } + Bucket(event.ts, hr, hrv) + }.getOrNull() + } + .sortedBy { it.ts } + // A replayed packet may contain the same five-minute bucket. Its event PK normally removes it, + // but keeping the pure adapter idempotent makes tests/imported stores safe too. + .distinctBy { it.ts } + .toList() + if (buckets.isEmpty()) return emptyList() + + val runs = ArrayList>() + var current = ArrayList() + for (bucket in buckets) { + val previous = current.lastOrNull() + if (previous != null && bucket.ts - previous.ts > MAX_GAP_SECONDS) { + runs.add(current) + current = ArrayList() + } + current.add(bucket) + } + if (current.isNotEmpty()) runs.add(current) + + return runs.map { run -> + val hrs = run.mapNotNull { it.hr } + OuraRestingWindow( + startTs = run.first().ts, + endTs = run.last().ts + BUCKET_SECONDS, + bucketCount = run.size, + avgHrBpm = hrs.takeIf { it.isNotEmpty() }?.average(), + avgHrvMs = run.map { it.hrv }.average(), + ) + } + } + + fun isSleepCandidate(window: OuraRestingWindow): Boolean = + window.durationSeconds >= MIN_AUTOMATIC_SLEEP_SECONDS && + // Tolerate an occasional missing native bucket, but do not join a sparse scatter of resting + // values into one claimed sleep block merely because every individual gap stayed under 15 min. + window.bucketCount * BUCKET_SECONDS * 10L >= window.durationSeconds * 7L + + fun sleepCandidates(events: List): List = + windows(events).filter(::isSleepCandidate) +} diff --git a/android/app/src/main/java/com/noop/analytics/SleepStageHealer.kt b/android/app/src/main/java/com/noop/analytics/SleepStageHealer.kt index b7a5698517..f3b42af8b8 100644 --- a/android/app/src/main/java/com/noop/analytics/SleepStageHealer.kt +++ b/android/app/src/main/java/com/noop/analytics/SleepStageHealer.kt @@ -66,12 +66,16 @@ object SleepStageHealer { // observed gravity + step density, so turning it on is a no-op for any night too sparse to trust // (e.g. a WHOOP 4.0, which never emits a step sample at all). useMotionAwareWake: Boolean = false, + // Oura Ring 4 has no continuous gravity stream. For an EXPLICIT user-marked window, permit V2 to + // stage from the ring's HR/R-R evidence instead of failing the WHOOP motion-density gate. This is + // opt-in at the Oura manual-session call site only; self-heal and every WHOOP path stay unchanged. + allowCardioOnly: Boolean = false, ): String? { val lo = start - 3_600L val hi = end + 3_600L val grav = repo.gravitySamples(deviceId, lo, hi, IntelligenceEngine.STREAM_LIMIT) // Cheap density gate FIRST (count only) so a sparse imported night skips the three further reads. - if (!isDense(grav, start, end)) return null + if (!isDense(grav, start, end) && !allowCardioOnly) return null val hr = repo.hrSamples(deviceId, lo, hi, IntelligenceEngine.STREAM_LIMIT) val rr = repo.rrIntervals(deviceId, lo, hi, IntelligenceEngine.STREAM_LIMIT) // Same provenance refusal as the nightly scan: an Oura ring's respiration rows are its own @@ -82,7 +86,13 @@ object SleepStageHealer { ) // Only read when the refinement might actually use it — no point paying for it on the (default) off path. val steps = if (useMotionAwareWake) repo.stepSamples(deviceId, lo, hi, IntelligenceEngine.STREAM_LIMIT) else emptyList() - return restageFromSamples(start, end, grav, hr, rr, resp, useExperimentalSleepV2, steps, useMotionAwareWake) + return restageFromSamples( + start, end, grav, hr, rr, resp, + useExperimentalSleepV2 = useExperimentalSleepV2 || allowCardioOnly, + steps = steps, + useMotionAwareWake = useMotionAwareWake, + allowCardioOnly = allowCardioOnly, + ) } /** @@ -123,8 +133,17 @@ object SleepStageHealer { // just ran, reclassifying a hot-but-still wake segment to light. Default false, self-gated on // observed density either way — see [WakeMotionRefinement]. useMotionAwareWake: Boolean = false, + allowCardioOnly: Boolean = false, ): String? { - if (!isDense(grav, start, end)) return null + if (!isDense(grav, start, end)) { + if (!allowCardioOnly) return null + // A boundary mark is evidence of sleep, not evidence that sensor data exists. Require enough + // ring cardio rows to make the V2 staging meaningful; otherwise the repository stores an + // honestly approximate light block and a later sync can self-heal it. + val inWindowHr = hr.count { it.ts in start..end } + val inWindowRr = rr.count { it.ts in start..end } + if (inWindowHr < 2 && inWindowRr < 10) return null + } val segs = if (useExperimentalSleepV2) { SleepStagerV2.stageSession(start = start, end = end, grav = grav, hr = hr, rr = rr, resp = resp) } else { @@ -171,7 +190,9 @@ object SleepStageHealer { // STRAP id (where the sensor streams live), not the computed namespace. Skip when the raw // isn't dense yet, or when the result already matches what's stored (steady state — no write). val newJSON = restageFromRaw(repo, strapDeviceId, row.effectiveStartTs, row.endTs, - useExperimentalSleepV2, useMotionAwareWake) ?: continue + useExperimentalSleepV2, useMotionAwareWake, + allowCardioOnly = strapDeviceId.startsWith("oura-", ignoreCase = true), + ) ?: continue if (newJSON == row.stagesJSON) continue // Keyed by the IMMUTABLE detected startTs (never effectiveStartTs) so it lands on the right // primary-key row; the DAO scopes the write to userEdited = 1. diff --git a/android/app/src/main/java/com/noop/ble/GattWriteQueue.kt b/android/app/src/main/java/com/noop/ble/GattWriteQueue.kt new file mode 100644 index 0000000000..72bf7eddc6 --- /dev/null +++ b/android/app/src/main/java/com/noop/ble/GattWriteQueue.kt @@ -0,0 +1,47 @@ +package com.noop.ble + +/** + * Tiny transport-independent FIFO used by BLE Write Without Response paths. + * + * Android permits only one GATT operation at a time. A successful API return means the stack accepted + * the operation, not that the peripheral replied, so callers pace [accepted] items before peeking again. + */ +internal class GattWriteQueue(private val maxRejects: Int = 8) { + enum class Rejection { Retry, Dropped } + + private val items = ArrayDeque() + private var rejectsForHead = 0 + + val size: Int get() = items.size + + fun add(item: T) { + items.addLast(item) + } + + fun peek(): T? = items.firstOrNull() + + fun accepted(): T? { + rejectsForHead = 0 + return if (items.isEmpty()) null else items.removeFirst() + } + + fun rejected(): Rejection { + if (items.isEmpty()) return Rejection.Dropped + rejectsForHead += 1 + if (rejectsForHead <= maxRejects) return Rejection.Retry + items.removeFirst() + rejectsForHead = 0 + return Rejection.Dropped + } + + fun clear() { + items.clear() + rejectsForHead = 0 + } +} + +/** Pure monotonic pacing calculation, separated so the empty-FIFO edge stays regression-tested. */ +internal object GattWritePacing { + fun delayMs(nowMs: Long, nextAllowedAtMs: Long, requestedDelayMs: Long): Long = + maxOf(requestedDelayMs, (nextAllowedAtMs - nowMs).coerceAtLeast(0L)) +} diff --git a/android/app/src/main/java/com/noop/ble/OuraInstallKeyStore.kt b/android/app/src/main/java/com/noop/ble/OuraInstallKeyStore.kt index 3ebe77b18f..277ba5c451 100644 --- a/android/app/src/main/java/com/noop/ble/OuraInstallKeyStore.kt +++ b/android/app/src/main/java/com/noop/ble/OuraInstallKeyStore.kt @@ -25,6 +25,7 @@ object OuraInstallKeyStore { private const val FILE_NAME = "noop_oura_secure_prefs" private const val KEY_PREFIX = "install_key_" + private const val SAVED_AT_PREFIX = "install_key_saved_at_" /** Prefix for the one-shot adopt-intent marker (see [setPendingAdopt]). Kept in the SAME encrypted * file as the key so the two move together when a ring is forgotten. */ @@ -37,6 +38,8 @@ object OuraInstallKeyStore { /** Per-ring preference key. */ private fun prefKey(deviceId: String) = "$KEY_PREFIX$deviceId" + private fun savedAtKey(deviceId: String) = "$SAVED_AT_PREFIX$deviceId" + /** Per-ring adopt-intent marker key. */ private fun adoptKey(deviceId: String) = "$ADOPT_PREFIX$deviceId" @@ -60,7 +63,10 @@ object OuraInstallKeyStore { if (key.any { it !in 0..255 }) return false val bytes = ByteArray(KEY_LENGTH) { key[it].toByte() } val encoded = Base64.encodeToString(bytes, Base64.NO_WRAP) - prefs(ctx).edit().putString(prefKey(deviceId), encoded).apply() + prefs(ctx).edit() + .putString(prefKey(deviceId), encoded) + .putLong(savedAtKey(deviceId), System.currentTimeMillis()) + .apply() return true } @@ -71,19 +77,65 @@ object OuraInstallKeyStore { * `OuraLiveSource.authKey` and `OuraDriver`'s key parameter exactly. */ fun load(ctx: Context, deviceId: String): IntArray? { - val encoded = prefs(ctx).getString(prefKey(deviceId), null) ?: return null + val encoded = runCatching { prefs(ctx).getString(prefKey(deviceId), null) }.getOrNull() ?: return null + return decode(encoded) + } + + private fun decode(encoded: String): IntArray? { val bytes = runCatching { Base64.decode(encoded, Base64.NO_WRAP) }.getOrNull() ?: return null if (bytes.size != KEY_LENGTH) return null return IntArray(KEY_LENGTH) { bytes[it].toInt() and 0xFF } } + /** + * Return distinct stored keys, trying [preferredDeviceIds] first. The remaining entries are recovery + * candidates from older address-keyed rows: Android rotates an Oura ring's BLE address, while releases + * before the stable-serial migration stored the credential under `oura-`. + * + * This never exposes key bytes to logs and never installs anything on the ring. It only lets the normal + * challenge handshake test locally-stored candidates until one authenticates. + */ + fun loadCandidates(ctx: Context, preferredDeviceIds: List): List { + val p = runCatching { prefs(ctx) }.getOrNull() ?: return emptyList() + val ids = LinkedHashSet() + ids.addAll(preferredDeviceIds.filter { it.isNotBlank() }) + val remaining = runCatching { p.all.keys } + .getOrDefault(emptySet()) + .filter { it.startsWith(KEY_PREFIX) && !it.startsWith(SAVED_AT_PREFIX) } + .sortedByDescending { storedKey -> + val storedDeviceId = storedKey.removePrefix(KEY_PREFIX) + runCatching { p.getLong(savedAtKey(storedDeviceId), 0L) }.getOrDefault(0L) + } + ids.addAll(remaining) + + val out = ArrayList() + for (idOrKey in ids) { + val keyName = if (idOrKey.startsWith(KEY_PREFIX)) idOrKey else prefKey(idOrKey) + val encoded = runCatching { p.getString(keyName, null) }.getOrNull() ?: continue + val candidate = decode(encoded) ?: continue + if (out.none { it.contentEquals(candidate) }) out.add(candidate) + } + return out + } + + /** Copy a valid key to a stable id. Used when a reset-mode advertisement reveals the serial. */ + fun copy(ctx: Context, fromDeviceId: String, toDeviceId: String, overwrite: Boolean = true): Boolean { + val key = load(ctx, fromDeviceId) ?: return false + if (!overwrite && hasKey(ctx, toDeviceId)) return true + return save(ctx, toDeviceId, key) + } + /** True when a valid-length install key is stored for [deviceId]. */ fun hasKey(ctx: Context, deviceId: String): Boolean = load(ctx, deviceId) != null /** Remove the stored install key AND any adopt-intent marker for [deviceId] (e.g. on forget-device / * re-pair), so a forgotten ring never carries a stale key or a stale "install my key" intent. */ fun clear(ctx: Context, deviceId: String) { - prefs(ctx).edit().remove(prefKey(deviceId)).remove(adoptKey(deviceId)).apply() + prefs(ctx).edit() + .remove(prefKey(deviceId)) + .remove(savedAtKey(deviceId)) + .remove(adoptKey(deviceId)) + .apply() } // MARK: - Adopt-intent (one-shot, gates the DANGEROUS post-factory-reset key install) diff --git a/android/app/src/main/java/com/noop/ble/OuraKeyCandidates.kt b/android/app/src/main/java/com/noop/ble/OuraKeyCandidates.kt new file mode 100644 index 0000000000..94f010245f --- /dev/null +++ b/android/app/src/main/java/com/noop/ble/OuraKeyCandidates.kt @@ -0,0 +1,28 @@ +package com.noop.ble + +import com.noop.oura.OuraAuth + +/** Pure validation/order gate for legacy address-keyed Oura credential recovery. */ +internal object OuraKeyCandidates { + fun preferredDeviceIds( + addressDeviceId: String, + serialDeviceId: String?, + currentDeviceId: String, + legacyIdsNewestFirst: List, + ): List = LinkedHashSet().apply { + add(addressDeviceId) + serialDeviceId?.let(::add) + add(currentDeviceId) + addAll(legacyIdsNewestFirst) + }.toList() + + fun normalize(candidates: List): List = buildList { + for (candidate in candidates) { + if (candidate.size == OuraAuth.keyLength && candidate.all { it in 0..255 } && + none { it.contentEquals(candidate) } + ) { + add(candidate.copyOf()) + } + } + } +} diff --git a/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt b/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt index a0da2a59ea..d3cc79a7cd 100644 --- a/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt +++ b/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt @@ -20,6 +20,7 @@ import android.os.Build import android.os.Handler import android.os.Looper import android.os.ParcelUuid +import android.os.SystemClock import com.noop.data.OuraStreamMapping import com.noop.data.StreamBatch import com.noop.data.StreamPersistence @@ -28,12 +29,14 @@ import com.noop.oura.OuraCommand import com.noop.oura.OuraDriver import com.noop.oura.OuraDriverPhase import com.noop.oura.OuraEvent +import com.noop.oura.OuraEventTag import com.noop.oura.OuraFraming import com.noop.oura.OuraIbiHr import com.noop.oura.OuraGatt import com.noop.oura.OuraCommands import com.noop.oura.OuraDecoders import com.noop.oura.OuraHistoryDrain +import com.noop.oura.OuraIdentity import com.noop.oura.OuraHypnogramAssembler import com.noop.oura.OuraHypnogramBurst import com.noop.oura.OuraOuterFrame @@ -98,6 +101,15 @@ class OuraLiveSource( * has been provisioned. INJECTED, never hardcoded (the key lives in [OuraInstallKeyStore], backed by * the Android Keystore). null drives the honest [needsPairing] path - no faked data. */ private val authKey: () -> IntArray?, + /** + * Ordered recovery candidates for the currently-advertised address/serial. Older NOOP releases keyed + * credentials by Android's rotating BLE address, so a post-upgrade session may need to test more than + * [authKey]. Candidates are used only for the read-only challenge handshake; they never authorize an + * install. The default preserves the original single-key behaviour. + */ + private val authKeyCandidates: (address: String, advertisedSerial: String?) -> List = { _, _ -> + emptyList() + }, /** Persist a batch under [deviceId] - wired to `repository.insert`. Mirrors the other sources. */ private val persist: (StreamBatch, String) -> Unit = { _, _ -> }, /** Upsert the ring-PROVIDED reconstructed hypnogram as a night under [deviceId] (the imported/measured @@ -158,6 +170,8 @@ class OuraLiveSource( val name: String, val rssi: Int, val detectedGen: OuraRingGen? = null, + /** Stable serial exposed by reset-mode advertisements (`Oura `), when present. */ + val advertisedSerial: String? = null, ) private val _discovered = MutableStateFlow>(emptyList()) @@ -184,6 +198,11 @@ class OuraLiveSource( * to [AdoptPhase.Idle] on every connect/stop/disconnect so a stale outcome never drives a transition. */ val adoptPhase: StateFlow = _adoptPhase.asStateFlow() + private val _historySyncing = MutableStateFlow(false) + /** True from the moment a GetEvents drain starts until its cursor is committed and the driver returns + * to Streaming. The Health screen observes this instead of guessing from WHOOP's backfill flag. */ + val historySyncing: StateFlow = _historySyncing.asStateFlow() + // MARK: - Live wear/charge indicator (#628 twin) — On wrist / Off wrist / charging // // The ring emits no "worn" event, so wear is inferred: a LIVE-HR push (0x2F) means a finger; a silent @@ -260,11 +279,21 @@ class OuraLiveSource( if (deviceId.isNotEmpty()) OuraRealStepsDump(appContext, deviceId, log) else null private val scanner: BluetoothLeScanner? get() = adapter?.bluetoothLeScanner - private var gatt: BluetoothGatt? = null + @Volatile private var gatt: BluetoothGatt? = null /** Peripherals seen in the current scan, retained by address so a chosen one survives to connect. */ private val seen = ConcurrentHashMap() + /** Stable serials learned from reset-mode advertisements, keyed by the current rotating address. */ + private val seenSerials = ConcurrentHashMap() /** A device asked to connect before a scan result for it landed (connect-by-address path). */ private var pendingConnectAddress: String? = null + /** A coordinator reconnect scan should connect to the matching current advertisement automatically. */ + private var autoConnectScan = false + + /** Address/serial + credential candidates bound to the current GATT session. */ + private var currentAdvertisedSerial: String? = null + private var sessionAuthKeys: List = emptyList() + private var sessionAuthKeyIndex = -1 + private var currentAuthKey: IntArray? = null /** The device of the in-flight connection, remembered so a status-133 disconnect can retry it. */ private var lastDevice: BluetoothDevice? = null @@ -301,6 +330,13 @@ class OuraLiveSource( * Swift's `loggedProductInfo`. Cleared on reset. */ private val loggedProductInfo = mutableSetOf() + /** Per-drain raw TLV tag census. Unlike the decoded-event path, this also counts unknown tags and + * known tags whose payload failed validation, so a normal strap log can distinguish "ring emitted no + * overnight PPG" from "NOOP did not recognise the Ring-4 record" without a separate raw capture. */ + private val historyTagCounts = mutableMapOf() + private var historyRecordCount = 0L + private var historyDecodedRecordCount = 0L + // MARK: - Auto-reconnect (#912) /** @@ -344,7 +380,7 @@ class OuraLiveSource( */ private val reconnectRunnable = Runnable { val address = reconnectAddress - if (!intentionalDisconnect && address != null) connect(address) + if (!intentionalDisconnect && address != null) scanForPairedRing() } /** @@ -377,6 +413,51 @@ class OuraLiveSource( /** All BLE work hops onto the main looper, matching the other sources + CBCentralManager(queue:.main). */ private val handler = Handler(Looper.getMainLooper()) + private val scanTimeoutRunnable: Runnable = Runnable { + if (!_scanning.value) return@Runnable + val wasReconnect = autoConnectScan + autoConnectScan = false + stopScan() + log("Oura: scan timed out without finding the paired ring") + if (wasReconnect && adoptIntent) { + announceNeedsPairing(SCAN_TIMEOUT_MESSAGE) + } else if (wasReconnect && !intentionalDisconnect) { + failedReconnectAttempts += 1 + val delay = ReconnectBackoff.nextDelayMs(failedReconnectAttempts) + log("Oura: scanning again in ${delay / 1000}s (attempt $failedReconnectAttempts)") + handler.removeCallbacks(scanReconnectRunnable) + handler.postDelayed(scanReconnectRunnable, delay) + } + } + private val scanReconnectRunnable: Runnable = Runnable { + if (!intentionalDisconnect) scanForPairedRing() + } + + // Android allows only one GATT operation at a time. OuraDriver intentionally returns small command + // batches (notably notify_all + get_nonce), so every Write Without Response must pass through one FIFO + // and be paced. The old direct loop submitted both in the same millisecond and ignored Android 13+'s + // return code; Samsung could reject/drop get_nonce while the log incorrectly claimed it was sent. + private val writeQueue = GattWriteQueue() + private var writePumpScheduled = false + /** Earliest monotonic time another write may enter Android, even if the FIFO was briefly empty. */ + private var nextWriteAllowedAtMs = 0L + private val writePumpRunnable: Runnable = Runnable { + writePumpScheduled = false + drainWriteQueue() + } + + /** One bounded wait for the protocol response implied by the most recently accepted auth command. */ + @Volatile private var protocolTimeoutExpected: String? = null + private val protocolTimeoutRunnable: Runnable = Runnable { + val expected = protocolTimeoutExpected ?: return@Runnable + protocolTimeoutExpected = null + writeQueue.clear() + writePumpScheduled = false + handler.removeCallbacks(writePumpRunnable) + log("Oura: timed out waiting for $expected - stopping this attempt") + announceNeedsPairing(PROTOCOL_TIMEOUT_MESSAGE) + } + // MARK: - Protocol state (the pure driver + reassembler own all protocol logic) /** @@ -394,14 +475,22 @@ class OuraLiveSource( private var writeChar: BluetoothGattCharacteristic? = null private var notifyChar: BluetoothGattCharacteristic? = null - /** Periodic live-HR re-engage: daytime HR auto-reverts after ~20 s, so while streaming we re-send the - * enable+subscribe every ~15 s (OURA_PROTOCOL.md s5.7). The token lets stop() cancel it. */ + /** Whether a visible screen currently requests daytime live HR. False in the background so the ring's + * automatic resting/sleep measurement is not continuously overridden overnight. */ + private var liveHRRequested = false + /** Whether NOOP most recently commanded the daytime-live feature on. The auth handshake necessarily + * enables it once to reach Streaming; [applyLiveHRDemand] immediately restores automatic mode when no + * UI wants live HR. Reset on disconnect. */ + private var liveHRActive = false + + /** Periodic live-HR re-engage: daytime HR auto-reverts after ~20 s, so while a LIVE-HR SCREEN is visible + * we re-send enable+subscribe every ~15 s (OURA_PROTOCOL.md s5.7). Never runs in the background. */ private var reengageScheduled = false private val reengageIntervalMs = 15_000L private val reengageRunnable = object : Runnable { override fun run() { val d = driver ?: return - if (d.phase == OuraDriverPhase.Streaming) { + if (liveHRRequested && liveHRActive && d.phase == OuraDriverPhase.Streaming) { for (cmd in d.reengageLiveHRCommands()) write(cmd) } // Removal watchdog (#628): if the live-HR stream has gone silent past the grace window while we @@ -414,7 +503,40 @@ class OuraLiveSource( } } // Reschedule only while a session is live; stop() clears reengageScheduled + removes callbacks. - if (reengageScheduled) handler.postDelayed(this, reengageIntervalMs) + if (reengageScheduled && liveHRRequested) handler.postDelayed(this, reengageIntervalMs) + } + } + + /** + * Toggle daytime live HR from UI demand. Authentication/history stay connected regardless; turning + * this off only returns feature 0x02 to automatic mode so feature 0x08 can finalize overnight resting + * HR/HRV normally. Safe before connect: the desired value is retained and applied on Streaming. + */ + fun setLiveHRRequested(requested: Boolean) { + handler.post { + if (liveHRRequested == requested) return@post + liveHRRequested = requested + applyLiveHRDemand() + } + } + + private fun applyLiveHRDemand() { + val d = driver ?: return + if (d.phase != OuraDriverPhase.Streaming) return + if (liveHRRequested) { + if (!liveHRActive) { + for (cmd in d.reengageLiveHRCommands()) write(cmd) + liveHRActive = true + log("Oura: daytime live HR enabled - requested by visible screen") + } + scheduleReengage() + } else { + cancelReengage() + if (liveHRActive) { + write(OuraCommands.liveHRDisable()) + liveHRActive = false + log("Oura: daytime live HR paused - automatic overnight recording remains enabled") + } } } @@ -539,11 +661,15 @@ class OuraLiveSource( private fun fetchHistoryIfIdle(): Unit = guardedCallback("history-fetch") { val d = driver ?: return@guardedCallback if (d.phase != OuraDriverPhase.Streaming) return@guardedCallback + _historySyncing.value = true // Arm the per-drain state: where we sought from (reboot detection), the seen/stored high-water // marks, and the stall/deadline guards. resumeCursorAtFetchStart = historyCursor drainStartedAtMs = System.currentTimeMillis() drain.reset() + historyTagCounts.clear() + historyRecordCount = 0 + historyDecodedRecordCount = 0 lastRequestCursor = historyCursor pendingContinuation = false handler.removeCallbacks(batchQuietRunnable) @@ -551,6 +677,22 @@ class OuraLiveSource( advance(OuraTransition.StartHistoryFetch(cursor = historyCursor)) } + /** User-facing manual history pull. The same phase guard as the periodic fetch makes a tap during an + * existing drain a safe no-op; it never overlaps two GetEvents sessions. */ + fun syncNow() { + handler.post { + guardedCallback("manual-history-fetch") { + val d = driver + if (d?.phase == OuraDriverPhase.Streaming) { + log("Oura: manual history sync requested") + fetchHistoryIfIdle() + } else { + log("Oura: manual history sync ignored - ring is not ready or a sync is already running") + } + } + } + } + private fun scheduleHistoryFetch() { if (historyFetchScheduled) return historyFetchScheduled = true @@ -628,9 +770,11 @@ class OuraLiveSource( pendingContinuation = false handler.removeCallbacks(batchQuietRunnable) hypnogramAssembler.flush()?.let { persistHypnogramBurst(it) } + logHistoryTagSummary() val rebootFullPullPending = drain.sawPreResumeData commitResumeCursor(completed) advance(OuraTransition.HistoryCursorAdvanced(cursor = historyCursor, moreData = false)) + _historySyncing.value = false if (rebootFullPullPending || resumeBacklog) { if (chainedDrainPasses >= MAX_CHAINED_DRAIN_PASSES) { log("Oura: drain pass cap ($MAX_CHAINED_DRAIN_PASSES) reached with work remaining - " + @@ -650,6 +794,34 @@ class OuraLiveSource( } } + /** Compact raw-tag proof for the exported strap log. The biometric roll-up answers the HRV question + * directly; the top-tag list preserves enough detail to spot a new/unknown Ring-4 record family. */ + private fun logHistoryTagSummary() { + if (historyRecordCount == 0L) { + log("Oura: history tag summary - no TLV records received in this drain") + return + } + fun count(vararg tags: Int): Long = tags.sumOf { historyTagCounts[it] ?: 0L } + val ibi = count(0x44, 0x60, 0x6E, 0x71, 0x80) + val nativeHrv = count(0x5D) + val sleepPhase = count(0x4B, 0x4E, 0x5A) + val temp = count(0x46, 0x69, 0x75) + val unknown = historyTagCounts.entries.sumOf { (tag, n) -> + if (OuraEventTag.fromRaw(tag) == null) n else 0L + } + log("Oura: history biometric tags - IBI=$ibi native_HRV=$nativeHrv sleep_phase=$sleepPhase " + + "temperature=$temp unknown=$unknown") + val top = historyTagCounts.entries + .sortedWith(compareByDescending> { it.value }.thenBy { it.key }) + .take(HISTORY_TAG_LOG_LIMIT) + .joinToString(", ") { (tag, n) -> + val name = OuraEventTag.fromRaw(tag)?.tagName ?: "UNKNOWN" + "0x%02x(%s)=%d".format(tag, name, n) + } + log("Oura: history tag summary records=$historyRecordCount decoded=$historyDecodedRecordCount " + + "types=${historyTagCounts.size} top=[$top]") + } + /** * Commit the durable resume cursor at drain end. Only a cursor that (a) moved forward, (b) is * below the plausibility ceiling, and (c) resolves to a real time under the CURRENT anchor is @@ -854,12 +1026,27 @@ class OuraLiveSource( // MARK: - Scanning /** Begin scanning for Oura rings advertising the ring's base service. */ - override fun scan() { + override fun scan() = beginScan(connectWhenFound = false) + + /** + * Reconnect a registered Oura ring through its current advertisement instead of a stale Android BLE + * address. Oura privacy addresses rotate; direct `getRemoteDevice(oldAddress)` is not an identity. + */ + fun scanForPairedRing() = beginScan(connectWhenFound = true) + + private fun beginScan(connectWhenFound: Boolean) { + handler.removeCallbacks(scanReconnectRunnable) + autoConnectScan = connectWhenFound seen.clear() + seenSerials.clear() _discovered.value = emptyList() _scanning.value = true _needsPairing.value = null - log("Oura: scanning for an Oura ring (${ringGen.displayName})…") + log(if (connectWhenFound) { + "Oura: scanning for the paired ring's current BLE address (${ringGen.displayName})…" + } else { + "Oura: scanning for an Oura ring (${ringGen.displayName})…" + }) val sc = scanner ?: run { _scanning.value = false log("Oura: no BLE scanner available - Bluetooth may be off or unsupported") @@ -879,11 +1066,14 @@ class OuraLiveSource( .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .build() sc.startScan(listOf(filter), settings, scanCallback) + handler.removeCallbacks(scanTimeoutRunnable) + handler.postDelayed(scanTimeoutRunnable, SCAN_TIMEOUT_MS) } /** Stop an in-progress scan. Idempotent. */ fun stopScan() { _scanning.value = false + handler.removeCallbacks(scanTimeoutRunnable) if (adapter?.isEnabled == true) runCatching { scanner?.stopScan(scanCallback) } } @@ -891,6 +1081,7 @@ class OuraLiveSource( /** Connect to the chosen discovered ring (by address) and start the auth → enable → stream flow. */ override fun connect(address: String) { + autoConnectScan = false stopScan() _needsPairing.value = null // Remember the paired ring so an involuntary drop auto-reconnects to it (#912). An explicit connect @@ -904,6 +1095,7 @@ class OuraLiveSource( private fun connectToDevice(device: BluetoothDevice) { lastDevice = device // remembered so a status-133 disconnect can auto-retry the same ring + currentAdvertisedSerial = seenSerials[device.address] log("Oura: connecting to ${device.address}") // Tear down any prior link first so we never run two GATTs for this source. gatt?.let { runCatching { it.disconnect(); it.close() } } @@ -916,14 +1108,23 @@ class OuraLiveSource( // actually sends (raw bytes per kind, decoded MET for 0x50) so the layouts can be validated // against real captures. It can never leak a value into scoring: OuraStreamMapping drops // TierB/ActivityInfo unconditionally - the Tier-discipline gate that matters lives there, not here. - driver = OuraDriver(ringGen = ringGen, authKey = authKey(), allowTierB = true, - allowKeyInstall = adoptIntent) + val recovered = runCatching { + authKeyCandidates(device.address, currentAdvertisedSerial) + }.getOrDefault(emptyList()) + val fallback = runCatching { authKey() }.getOrNull() + sessionAuthKeys = OuraKeyCandidates.normalize(recovered + listOfNotNull(fallback)) + sessionAuthKeyIndex = if (sessionAuthKeys.isEmpty()) -1 else 0 + currentAuthKey = sessionAuthKeys.firstOrNull() + driver = newDriver(currentAuthKey) + clearWritePipeline() reassembler.reset() pendingInstallKey = null // a new connection starts with no install in flight _adoptPhase.value = AdoptPhase.Idle // a stale outcome must never drive the wizard's transition + _historySyncing.value = false resetWear() // #628: fresh session — clear any stale worn/charging badge // A fresh session: reset the one-shot streaming/anchor state, and never replay a stale-anchor guess. reachedStreaming = false + liveHRActive = false loggedFirstTemp = false loggedFirstSpo2 = false loggedAnchor = false @@ -971,6 +1172,13 @@ class OuraLiveSource( } } + private fun newDriver(key: IntArray?): OuraDriver = OuraDriver( + ringGen = ringGen, + authKey = key, + allowTierB = true, + allowKeyInstall = adoptIntent, + ) + /** Tear down: cancel the connection and stop scanning, persisting anything still buffered. Idempotent. */ override fun stop() { // A deliberate teardown (device switch / removal) must NOT auto-reconnect: mark it intentional and @@ -982,10 +1190,14 @@ class OuraLiveSource( failedReconnectAttempts = 0 handler.removeCallbacks(reconnectRunnable) handler.removeCallbacks(retry133Runnable) + handler.removeCallbacks(scanReconnectRunnable) + clearWritePipeline() stopScan() + autoConnectScan = false pendingConnectAddress = null cancelReengage() cancelHistoryFetch() + _historySyncing.value = false handler.removeCallbacks(batchQuietRunnable) handler.removeCallbacks(chainedDrainRunnable) pendingContinuation = false @@ -1023,10 +1235,15 @@ class OuraLiveSource( loggedFeatureStatuses.clear() loggedProductInfo.clear() reachedStreaming = false + liveHRActive = false // A stop MID-install is an honest failure (no ack will come); a stop after streaming leaves the // completed Streaming outcome intact so the wizard's success transition is not undone. if (_adoptPhase.value == AdoptPhase.InstallingKey) _adoptPhase.value = AdoptPhase.Failed pendingInstallKey = null + sessionAuthKeys = emptyList() + sessionAuthKeyIndex = -1 + currentAuthKey = null + currentAdvertisedSerial = null _batteryPct.value = null // a stale charge must not outlive the link resetWear() // #628: clear the wear badge too flush() @@ -1086,7 +1303,14 @@ class OuraLiveSource( // Batched by resolved second, exactly like the live path (#1072): a record's parked beats are // released as ONE persist so `ord` records their emission order instead of restarting at 0 per beat. val stamped = pendingAnchorEvents.map { (event, ringTimestamp) -> - event to (d.unixSeconds(forRingTimestamp = ringTimestamp)?.toInt() ?: now) + val resolved = d.unixSeconds(forRingTimestamp = ringTimestamp) + // A parked IBI may be a live beat that arrived before the anchor; it must never skip ahead of + // an unfinished history backlog. Every other parked family is history-only and may safely bank + // the durable cursor once its real time resolves (Swift parity). + if (resolved != null && event !is OuraEvent.Ibi) { + drain.noteStoredRingTime(ringTimestamp, resumeCursorAtFetchStart) + } + event to (resolved?.toInt() ?: now) } for ((ts, batch) in OuraStreamMapping.batched(stamped)) enqueue(batch, ts) pendingAnchorEvents.clear() @@ -1106,11 +1330,14 @@ class OuraLiveSource( if (firstSight) log("Oura: found $name ($address) rssi ${result.rssi}") // Best-effort generation guess from the advertised name (confirmed by the model the user picks). val detectedGen = OuraRingGen.recognise(name) + val advertisedSerial = OuraIdentity.advertisedSerial(name) + if (advertisedSerial != null) seenSerials[address] = advertisedSerial val ring = DiscoveredRing( address = address, name = name.ifBlank { "Oura" }, rssi = result.rssi, detectedGen = detectedGen, + advertisedSerial = advertisedSerial, ) val list = _discovered.value.toMutableList() val i = list.indexOfFirst { it.address == address } @@ -1121,13 +1348,35 @@ class OuraLiveSource( pendingConnectAddress = null handler.post { connectToDevice(device) } } + if (autoConnectScan) { + val suffix = deviceId.substringAfter("${ExperimentalBrand.OURA.idPrefix}-", "") + val expectedSerial = suffix.takeIf(OuraIdentity::isPlausibleSerial) + if (OuraIdentity.mayMatchExpectedSerial(expectedSerial, advertisedSerial)) { + autoConnectScan = false + failedReconnectAttempts = 0 + handler.removeCallbacks(scanReconnectRunnable) + reconnectAddress = address + intentionalDisconnect = false + stopScan() + handler.post { connectToDevice(device) } + } + } } } // MARK: - GATT callback + /** True when Android delivers a late callback from a link superseded by a newer [gatt]. */ + private fun isStaleGatt(candidate: BluetoothGatt): Boolean = + gatt?.let { active -> active !== candidate } == true + private val gattCallback = object : BluetoothGattCallback() { override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) = guardedCallback("connection-state") { + if (isStaleGatt(g)) { + if (newState == BluetoothProfile.STATE_DISCONNECTED) runCatching { g.close() } + log("Oura: ignoring connection callback from a superseded GATT") + return@guardedCallback + } when (newState) { BluetoothProfile.STATE_CONNECTED -> { if (status != BluetoothGatt.GATT_SUCCESS) { @@ -1140,6 +1389,8 @@ class OuraLiveSource( } BluetoothProfile.STATE_DISCONNECTED -> { log("Oura: disconnected (status=$status)") + _historySyncing.value = false + clearWritePipeline() loggedFirstHr = false // a reconnect should log its first sample again _batteryPct.value = null resetWear() // #628: the wear badge must not survive the link dropping @@ -1161,8 +1412,9 @@ class OuraLiveSource( loggedAnchor = false loggedTierBKinds.clear() loggedFeatureStatuses.clear() - loggedProductInfo.clear() + loggedProductInfo.clear() reachedStreaming = false + liveHRActive = false // A disconnect MID-install is an honest failure (no 0x25 ack will arrive); a disconnect // after streaming leaves the completed Streaming outcome intact. Drop any in-flight key // WITHOUT persisting it (a failed install must never leave a wrongly-trusted key). @@ -1196,6 +1448,7 @@ class OuraLiveSource( } override fun onServicesDiscovered(g: BluetoothGatt, status: Int) = guardedCallback("services-discovered") { + if (isStaleGatt(g)) return@guardedCallback log("Oura: services discovered (status=$status)") if (status != BluetoothGatt.GATT_SUCCESS) { log("Oura: WARNING service discovery failed (status=$status) - giving up on this ring") @@ -1213,6 +1466,7 @@ class OuraLiveSource( } override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) = guardedCallback("mtu-changed") { + if (isStaleGatt(g)) return@guardedCallback log("Oura: MTU negotiated = $mtu (status=$status)") setUpNotifications(g) } @@ -1222,6 +1476,7 @@ class OuraLiveSource( descriptor: BluetoothGattDescriptor, status: Int, ) = guardedCallback("descriptor-write") { + if (isStaleGatt(g)) return@guardedCallback if (descriptor.uuid != CCCD) return@guardedCallback if (status == BluetoothGatt.GATT_SUCCESS) { log("Oura: notifications enabled (CCCD write status=$status) - beginning auth") @@ -1239,6 +1494,7 @@ class OuraLiveSource( ch: BluetoothGattCharacteristic, value: ByteArray, ) { + if (isStaleGatt(g)) return if (ch.uuid == NOTIFY_UUID) handleNotification(value) } @@ -1246,6 +1502,7 @@ class OuraLiveSource( @Deprecated("Deprecated in Java") @Suppress("DEPRECATION") override fun onCharacteristicChanged(g: BluetoothGatt, ch: BluetoothGattCharacteristic) { + if (isStaleGatt(g)) return if (ch.uuid == NOTIFY_UUID) handleNotification(ch.value ?: return) } } @@ -1296,6 +1553,9 @@ class OuraLiveSource( val d = driver ?: return@guardedCallback val commands = d.nextStep(transition) for (cmd in commands) write(cmd) + if (transition is OuraTransition.AuthCompleted && transition.status == com.noop.oura.OuraAuthStatus.SUCCESS) { + persistWorkingKeyUnderStableSerial() + } when (d.phase) { OuraDriverPhase.Streaming -> { // The driver returns to Streaming after EACH history-fetch pass completes, so gate all the @@ -1307,12 +1567,22 @@ class OuraLiveSource( // The OK ack already persisted the key; nothing is left in flight. _adoptPhase.value = AdoptPhase.Streaming pendingInstallKey = null - log("Oura: live HR enabled - streaming") - scheduleReengage() + log("Oura: authenticated - streaming transport ready") + // The auth state machine enabled daytime HR to establish this transport. Record that + // fact, then immediately reconcile it with UI demand: background sessions disable it; + // the Live/Health screen keeps it active and owns the 15 s re-engage watchdog. + liveHRActive = true // SyncTime FIRST (s5.4, twin of Swift): sets the ring clock AND its 0x13 response // carries the ring's current clock counter — the deterministic anchor that lets the // whole drain below resolve real times without waiting for a lucky 0x42. write(OuraCommands.syncTime(System.currentTimeMillis() / 1000L)) + // A factory-reset takeover can leave the SEPARATE resting-HR feature disabled even + // though daytime live HR works. Restore its normal automatic/state configuration so + // the ring records overnight PPG/IBI/HRV while the phone is disconnected, then read it + // back so the exported log proves the result. [ring4-ble] + write(OuraCommands.restingHRAutomatic()) + write(OuraCommands.restingHRStateSubscribe()) + write(OuraCommands.restingHRReadStatus()) // Pull last night's banked temp/SpO2/HRV/sleep-phase right away + keep a periodic pass // running, and ask for battery once (the 0x0D reply routes to onBattery). scheduleHistoryFetch() @@ -1333,6 +1603,9 @@ class OuraLiveSource( write(OuraCommands.getProductSerial()) write(OuraCommands.getProductHardware()) } + // Also runs after each history fetch returns to Streaming, covering demand that changed + // while the driver was FetchingHistory. + applyLiveHRDemand() } OuraDriverPhase.NeedsKeyInstall -> { // Factory-reset ring (auth status 0x02) or no key. The dangerous key install is the ONLY @@ -1341,13 +1614,38 @@ class OuraLiveSource( if (adoptIntent) provisionKeyInstall(d) else announceNeedsPairing(KEY_INSTALL_MESSAGE) } is OuraDriverPhase.AuthFailed -> { - log("Oura: auth failed - the stored install key does not match this ring") - announceNeedsPairing(AUTH_FAILED_MESSAGE) + if (tryNextStoredAuthKey()) { + log("Oura: stored pairing key was rejected - trying another locally stored key") + } else { + log("Oura: auth failed - no stored install key matches this ring") + announceNeedsPairing(AUTH_FAILED_MESSAGE) + } } else -> Unit } } + /** Retry the read-only challenge handshake with the next legacy address-keyed credential. */ + private fun tryNextStoredAuthKey(): Boolean { + val nextIndex = sessionAuthKeyIndex + 1 + if (nextIndex !in sessionAuthKeys.indices) return false + sessionAuthKeyIndex = nextIndex + currentAuthKey = sessionAuthKeys[nextIndex] + driver = newDriver(currentAuthKey) + advance(OuraTransition.Ready) + return true + } + + /** Once a candidate authenticates, pin it to the advertised serial so address rotation stops mattering. */ + private fun persistWorkingKeyUnderStableSerial() { + val serial = currentAdvertisedSerial ?: return + val key = currentAuthKey ?: return + val stableId = "${ExperimentalBrand.OURA.idPrefix}-$serial" + if (OuraInstallKeyStore.save(appContext, stableId, key) && stableId != deviceId) { + log("Oura: authenticated key recovered under the ring's stable serial identity") + } + } + // MARK: - Adopt key-install handshake (s3.2) - ONLY ever reached with explicit adopt consent /** @@ -1393,6 +1691,7 @@ class OuraLiveSource( * command. Kotlin twin of Swift's `handleKeyInstallAck`. */ private fun handleKeyInstallAck(d: OuraDriver, frame: OuraOuterFrame) = guardedCallback("key-install-ack") { + completeProtocolWait("key-install acknowledgement") val key = pendingInstallKey ?: return@guardedCallback // no install in flight if (d.phase != OuraDriverPhase.InstallingKey) return@guardedCallback // not our install in flight val status = frame.body.firstOrNull() @@ -1403,6 +1702,10 @@ class OuraLiveSource( announceNeedsPairing(KEY_INSTALL_MESSAGE) return@guardedCallback } + currentAuthKey = key + currentAdvertisedSerial?.let { serial -> + OuraInstallKeyStore.save(appContext, "${ExperimentalBrand.OURA.idPrefix}-$serial", key) + } log("Oura: key installed and stored - re-authenticating with the new key") pendingInstallKey = null // Re-auth with the freshly-installed key. The driver returns enable-notify + get-nonce; the @@ -1430,23 +1733,120 @@ class OuraLiveSource( write(OuraCommands.liveHRUnsubscribe()) } - /** Write one built command to the ring's write characteristic (Write Without Response). Logged by its - * short label only (never bytes or an address). */ - private fun write(cmd: OuraCommand) = guardedCallback("write") { - val g = gatt ?: return@guardedCallback - val ch = writeChar ?: return@guardedCallback + /** Enqueue one Write Without Response. All queue mutation and GATT submission runs on main. */ + private fun write(cmd: OuraCommand) { + handler.post { + guardedCallback("write-enqueue") { + writeQueue.add(cmd) + scheduleWritePump(0L) + } + } + } + + private fun scheduleWritePump(delayMs: Long) { + if (writePumpScheduled) return + writePumpScheduled = true + val effectiveDelay = GattWritePacing.delayMs( + nowMs = SystemClock.uptimeMillis(), + nextAllowedAtMs = nextWriteAllowedAtMs, + requestedDelayMs = delayMs, + ) + handler.postDelayed(writePumpRunnable, effectiveDelay) + } + + /** Submit only the FIFO head and honor Android 13+'s integer status result. */ + private fun drainWriteQueue() = guardedCallback("write") { + val cmd = writeQueue.peek() ?: return@guardedCallback + val g = gatt + val ch = writeChar + if (g == null || ch == null) { + writeQueue.clear() + return@guardedCallback + } val bytes = ByteArray(cmd.bytes.size) { cmd.bytes[it].toByte() } - log("Oura: → ${cmd.label}") - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - g.writeCharacteristic(ch, bytes, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) + val expectedResponse = expectedResponseFor(cmd) + if (expectedResponse != null) { + // Publish the expectation BEFORE submission: a fast notification can arrive on the binder + // thread immediately after writeCharacteristic returns. If it completes this expectation, + // armPreparedProtocolTimeout observes null and does not install a stale timer afterward. + protocolTimeoutExpected = expectedResponse + handler.removeCallbacks(protocolTimeoutRunnable) + } + val rc = runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + g.writeCharacteristic(ch, bytes, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) + } else { + @Suppress("DEPRECATION") + run { + ch.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + ch.value = bytes + if (g.writeCharacteristic(ch)) GATT_API_SUCCESS else LEGACY_WRITE_REJECTED + } + } + }.getOrElse { + log("Oura: ${cmd.label} write threw ${it.javaClass.simpleName}") + LEGACY_WRITE_REJECTED + } + + if (rc == GATT_API_SUCCESS) { + writeQueue.accepted() + // Keep pacing even when the queue is momentarily empty: advance() can post the next command + // from a binder thread just after this acceptance, and it must not slip through immediately. + nextWriteAllowedAtMs = SystemClock.uptimeMillis() + WRITE_SPACING_MS + log("Oura: → ${cmd.label} (accepted by Android)") + armPreparedProtocolTimeout(expectedResponse) + if (writeQueue.size > 0) scheduleWritePump(0L) } else { - @Suppress("DEPRECATION") - run { - ch.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE - ch.value = bytes - g.writeCharacteristic(ch) + if (expectedResponse != null && protocolTimeoutExpected == expectedResponse) { + protocolTimeoutExpected = null } + when (writeQueue.rejected()) { + GattWriteQueue.Rejection.Retry -> { + log("Oura: Android rejected ${cmd.label} write (rc=$rc) - retrying") + scheduleWritePump(WRITE_RETRY_MS) + } + GattWriteQueue.Rejection.Dropped -> { + log("Oura: Android repeatedly rejected ${cmd.label} write (rc=$rc) - stopping this attempt") + writeQueue.clear() + announceNeedsPairing(WRITE_FAILED_MESSAGE) + } + } + } + } + + private fun expectedResponseFor(cmd: OuraCommand): String? = when { + cmd.label == "DANGEROUS_install_key" -> "key-install acknowledgement" + cmd.label == "get_nonce" -> "authentication nonce" + cmd.label == "submit_proof" -> "authentication result" + !reachedStreaming && cmd.label.startsWith("dhr_") -> "live-HR acknowledgement" + else -> null + } + + private fun armPreparedProtocolTimeout(expected: String?) { + if (expected == null || protocolTimeoutExpected != expected) return + handler.postDelayed(protocolTimeoutRunnable, PROTOCOL_TIMEOUT_MS) + } + + private fun completeProtocolWait(expected: String) { + if (protocolTimeoutExpected != expected) return + protocolTimeoutExpected = null + handler.removeCallbacks(protocolTimeoutRunnable) + } + + private fun clearWritePipeline() { + // add/drain already run on main; clear must obey the same confinement. Disconnect callbacks arrive + // on a binder thread and coordinator stop() may arrive on its IO scope, so mutating ArrayDeque here + // directly could race an accepted head and remove the next command instead. + if (Looper.myLooper() != handler.looper) { + handler.post { clearWritePipeline() } + return } + writeQueue.clear() + writePumpScheduled = false + nextWriteAllowedAtMs = 0L + handler.removeCallbacks(writePumpRunnable) + protocolTimeoutExpected = null + handler.removeCallbacks(protocolTimeoutRunnable) } /** @@ -1488,7 +1888,7 @@ class OuraLiveSource( log("Oura: generation from hardware id $str is ${gen.displayName} (was ${ringGen.displayName}) - correcting model") onModel(gen.displayName) } - } else if (isPlausibleSerial(str)) { + } else if (OuraIdentity.isPlausibleSerial(str)) { onSerial(str) } } @@ -1538,11 +1938,18 @@ class OuraLiveSource( // envelope ring-time advances the drain's in-session continuation cursor (open_oura // drain_events tracks the max timestamp of EVERY batch event), and while a continuation // is pending the batch-quiet window stays open as long as records keep arriving. - val events = d.ingest(rec) - for (e in events) { - e.envelopeRingTimestamp?.let { drain.noteSeenRingTime(it) } + val isHistoryRecord = d.phase == OuraDriverPhase.FetchingHistory + if (isHistoryRecord) { + // The continuation cursor tracks EVERY TLV record, not only records whose tag/payload + // decoded into an OuraEvent. Otherwise an all-unknown Ring-4 batch appears to make no + // progress and gets re-served forever. + drain.noteSeenRingTime(rec.ringTimestamp) + historyRecordCount += 1 + historyTagCounts[rec.type] = (historyTagCounts[rec.type] ?: 0L) + 1L } - if (pendingContinuation && events.isNotEmpty()) { + val events = d.ingest(rec) + if (isHistoryRecord && events.isNotEmpty()) historyDecodedRecordCount += 1 + if (pendingContinuation && isHistoryRecord) { handler.removeCallbacks(batchQuietRunnable) handler.postDelayed(batchQuietRunnable, batchQuietMs) } @@ -1554,12 +1961,19 @@ class OuraLiveSource( /** Route a 0x2F secure sub-frame to the driver and turn its result into a transition or live events. */ private fun routeSecure(d: OuraDriver, secure: com.noop.oura.OuraSecureFrame) = guardedCallback("secure-route") { when (val routing = d.handleSecureFrame(secure)) { - is OuraDriver.SecureRouting.Nonce -> advance(OuraTransition.NonceReceived(routing.nonce)) + is OuraDriver.SecureRouting.Nonce -> { + completeProtocolWait("authentication nonce") + advance(OuraTransition.NonceReceived(routing.nonce)) + } is OuraDriver.SecureRouting.AuthStatus -> { + completeProtocolWait("authentication result") log("Oura: auth status = ${routing.status.name}") advance(OuraTransition.AuthCompleted(routing.status)) } - OuraDriver.SecureRouting.EnableAck -> advance(OuraTransition.EnableAckReceived) + OuraDriver.SecureRouting.EnableAck -> { + completeProtocolWait("live-HR acknowledgement") + advance(OuraTransition.EnableAckReceived) + } is OuraDriver.SecureRouting.FeatureStatus -> logFeatureStatus(routing.value) // read-only; no advance is OuraDriver.SecureRouting.LiveHRPush -> emit(d.ingestLiveHRPush(routing.body)) OuraDriver.SecureRouting.Unhandled -> Unit @@ -1578,13 +1992,19 @@ class OuraLiveSource( OuraCommands.featureSpO2 -> "SpO2 (0x04)" OuraCommands.featureRealSteps -> "real_steps (0x0b)" OuraCommands.featureDaytimeHR -> "daytime-HR (0x02)" + OuraCommands.featureRestingHR -> "resting-HR / overnight PPG (0x08)" else -> "0x${st.feature.toString(16)}" } // A gated/unavailable feature reports ALL-ZERO (mode/status/state); the streaming daytime-HR, by // contrast, reads mode=1 status=0x11 state=2. Flag the all-zero case as the honest "cloud never // enabled it" — NOT `subscription==0` alone, since daytime-HR is subscription=0 yet active. val off = st.mode == 0 && st.status == 0 && st.state == 0 - val gate = if (off) " - INACTIVE (server-gated off; the cloud never enabled it, not emitted offline)" else "" + val gate = when { + !off -> "" + st.feature == OuraCommands.featureRestingHR -> + " - INACTIVE (automatic overnight HR/HRV recording is off)" + else -> " - INACTIVE (server-gated off; the cloud never enabled it, not emitted offline)" + } // Name the enum fields so the log reads plainly (OURA_PROTOCOL.md s7.1 [ring4-ble]) — e.g. a gated // feature prints `mode=0 (off) … subscription=0 (off)`, the active daytime-HR `mode=1 (automatic)`. log("Oura: feature status $name mode=${st.mode} (${featureModeName(st.mode)}) status=${st.status} " + @@ -1937,7 +2357,14 @@ class OuraLiveSource( */ private fun enqueueAnchoredOrPark(event: OuraEvent, ringTimestamp: Long, d: OuraDriver) { val ts = d.unixSeconds(forRingTimestamp = ringTimestamp) - if (ts != null) enqueue(listOf(event), ts.toInt()) else pendingAnchorEvents.add(event to ringTimestamp) + if (ts != null) { + enqueue(listOf(event), ts.toInt()) + // This helper is used only by history-only signal families (temp/SpO2/native-HRV/motion). + // Android had lost Swift's matching call, leaving cursor 0 forever despite successful stores. + drain.noteStoredRingTime(ringTimestamp, resumeCursorAtFetchStart) + } else { + pendingAnchorEvents.add(event to ringTimestamp) + } } private fun handleBattery(pct: Int) = guardedCallback("battery") { @@ -1974,6 +2401,7 @@ class OuraLiveSource( * Mirrors the Swift `announceNeedsPairing`. */ private fun announceNeedsPairing(message: String) { + clearWritePipeline() // A failed install must drop its pending key whether or not this is the first announce. pendingInstallKey = null _adoptPhase.value = AdoptPhase.Failed @@ -2016,6 +2444,18 @@ class OuraLiveSource( * constant). We auto-retry it once. */ private const val GATT_ERROR_133 = 133 + /** `BluetoothStatusCodes.SUCCESS`; kept numeric so API 26-30 never resolve the API-31 class. */ + private const val GATT_API_SUCCESS = 0 + private const val LEGACY_WRITE_REJECTED = -1 + /** Conservative pacing for Samsung/Android GATT Write Without Response serialization. */ + private const val WRITE_SPACING_MS = 75L + private const val WRITE_RETRY_MS = 50L + private const val PROTOCOL_TIMEOUT_MS = 12_000L + private const val SCAN_TIMEOUT_MS = 30_000L + + /** Keep the raw tag census compact enough for the rolling strap log while preserving its leaders. */ + private const val HISTORY_TAG_LOG_LIMIT = 24 + /** Max self-chained drain passes per session (twin of Swift's maxChainedDrainPasses). */ private const val MAX_CHAINED_DRAIN_PASSES = 6 @@ -2064,11 +2504,6 @@ class OuraLiveSource( * TLV decode. */ private val PRODUCT_INFO_RESPONSE_OPS = setOf(0x18, 0x19) - /** A GetProductInfo string is a usable ring SERIAL only when it is plain alphanumeric and a sane - * length, so a misframed reply can never mint a bogus `oura-` id (#771 honest-data guard). - * Twin of Swift's `isPlausibleSerial`. */ - private fun isPlausibleSerial(s: String): Boolean = - s.length in 8..24 && s.all { it.isLetterOrDigit() } private const val SET_AUTH_KEY_OK = 0x00 /** Generate a fresh cryptographically-random 16-byte install key as unsigned bytes 0..255 @@ -2094,6 +2529,18 @@ class OuraLiveSource( "This Oura ring rejected the stored pairing key. Live data isn't available. The ring is not " + "damaged: re-pair it in the Oura app to set it up again, or export from the Oura app and " + "use file import." + + private const val PROTOCOL_TIMEOUT_MESSAGE = + "The ring stopped answering during authentication. This attempt was stopped safely; NOOP will " + + "not install another key automatically. Keep the ring near the phone and reconnect." + + private const val WRITE_FAILED_MESSAGE = + "Android's Bluetooth stack would not accept a ring command, so this attempt was stopped safely. " + + "Toggle Bluetooth and reconnect; do not factory-reset the ring again." + + private const val SCAN_TIMEOUT_MESSAGE = + "NOOP couldn't find the ring before the scan timed out. Keep it close to the phone and try " + + "again; do not factory-reset or take over the ring again." } } diff --git a/android/app/src/main/java/com/noop/ble/SourceCoordinator.kt b/android/app/src/main/java/com/noop/ble/SourceCoordinator.kt index f56d4de32b..095970e925 100644 --- a/android/app/src/main/java/com/noop/ble/SourceCoordinator.kt +++ b/android/app/src/main/java/com/noop/ble/SourceCoordinator.kt @@ -110,10 +110,12 @@ class SourceCoordinator( * production retains the encrypted-store behavior through this default. */ private val migrateOuraInstallKey: (fromId: String, toId: String) -> Unit = { fromId, toId -> context?.let { ctx -> - OuraInstallKeyStore.load(ctx, fromId)?.let { key -> - OuraInstallKeyStore.save(ctx, toId, key) - OuraInstallKeyStore.clear(ctx, fromId) + // A candidate-key handshake may already have recovered a newer address-keyed credential under + // the stable serial. Never replace that authenticated winner with a stale registry-id key. + if (!OuraInstallKeyStore.hasKey(ctx, toId)) { + OuraInstallKeyStore.copy(ctx, fromId, toId, overwrite = false) } + OuraInstallKeyStore.clear(ctx, fromId) } }, ) { @@ -146,6 +148,15 @@ class SourceCoordinator( private val _ouraWearState = MutableStateFlow(null) val ouraWearState: StateFlow = _ouraWearState.asStateFlow() + /** Actual active-source identity, independent of the application startup's cached device id. This is + * especially important after Oura adopts its stable serial id during the running process. */ + private val _ouraActive = MutableStateFlow(false) + val ouraActive: StateFlow = _ouraActive.asStateFlow() + + /** The Oura source's own GetEvents state. WHOOP's LiveState.backfilling never represents this drain. */ + private val _ouraHistorySyncing = MutableStateFlow(false) + val ouraHistorySyncing: StateFlow = _ouraHistorySyncing.asStateFlow() + /** Collects the active Oura source's adoptPhase / needsPairing into the mirrors above; cancelled and * nulled on teardown so a forgotten ring never leaks a stale outcome. */ private var ouraStateJob: kotlinx.coroutines.Job? = null @@ -156,9 +167,13 @@ class SourceCoordinator( * scans / stops it. Built by [makeSource]; each source owns its OWN scanner/GATT and never touches the * WHOOP BLE client, so the WHOOP path cannot regress. (An Oura ring additionally surfaces only its OWN * raw signals + open event tags — NOOP computes its own Charge/Rest — never Oura's encrypted scores.) */ + @Volatile private var activeSource: LiveHrSource? = null /** The deviceId the active non-WHOOP source ([activeSource]) runs for. */ private var activeStrapId: String? = null + /** UI demand for instantaneous Oura HR. Retained across reconnect/serial adoption so a visible Live + * screen resumes automatically, while the normal background default remains false. */ + @Volatile private var ouraLiveHrRequested = false /** The WHOOP registry id we last pointed the connection at, so a WHOOP→WHOOP switch is detected and a * repeat activation of the SAME WHOOP is a no-op. null until the first WHOOP activation. (MW-3) */ private var activeWhoopId: String? = null @@ -229,6 +244,21 @@ class SourceCoordinator( registry.touchLastSeen(row.id) } + /** Ask the active Oura source to pull banked history immediately. Oura owns its own cursor/phase guard, + * so a tap during an existing drain is harmless. Other source families keep their existing sync path. */ + fun syncOuraNow(): Boolean { + val source = activeSource as? OuraLiveSource ?: return false + source.syncNow() + return true + } + + /** Keep Oura's daytime live measurement scoped to screens that actually display it. History sync and + * automatic overnight recording remain connected independently. */ + fun setOuraLiveHrRequested(requested: Boolean) { + ouraLiveHrRequested = requested + (activeSource as? OuraLiveSource)?.setLiveHRRequested(requested) + } + /** * The BLE engine connected to a WHOOP strap at [address] (null on disconnect). Persist that stable * identity onto the CURRENTLY ACTIVE device when it's a WHOOP and hasn't adopted one yet — so the @@ -387,14 +417,24 @@ class SourceCoordinator( // Build the isolated source for this device's registered kind (the ONE place that maps a kind to a // concrete driver), then bring it up. Adding a brand adds ONE arm in [makeSource] plus a conforming // source; nothing else in the coordinator changes. - val source = makeSource(id, row) + val source = makeSource(id, row, devices) // CONNECT to the active strap's known BLE address, don't just scan. A bare scan discovers + lists // the strap but never connects — so a Polar H10 etc. showed up as "found" yet never streamed // (#421). connect(address) connects directly via getRemoteDevice; a bare scan is the fallback only // when the registry row has no address. - if (!address.isNullOrEmpty()) source.connect(address) else source.scan() + if (source is OuraLiveSource) { + source.setLiveHRRequested(ouraLiveHrRequested) + // Oura uses a rotating BLE privacy address. Resolve the current advertisement (and its stable + // serial) on every session instead of treating the last MAC as the ring's identity. + source.scanForPairedRing() + } else if (!address.isNullOrEmpty()) { + source.connect(address) + } else { + source.scan() + } activeSource = source activeStrapId = id + _ouraActive.value = source is OuraLiveSource onStrap = true } @@ -407,7 +447,7 @@ class SourceCoordinator( * connecting — the caller ([switchToStrap]) does the connect-by-address-else-scan bring-up. Mirrors * macOS `SourceCoordinator.makeSource(for:)`. */ - private fun makeSource(id: String, row: PairedDeviceRow?): LiveHrSource { + private fun makeSource(id: String, row: PairedDeviceRow?, devices: List): LiveHrSource { // Non-null in production (set at the composition root); only the JVM-test paths that never reach a // strap switch leave it null. Fail loudly rather than silently no-op if that invariant breaks. val ctx = requireNotNull(context) { "SourceCoordinator.context is required to run a strap source" } @@ -431,7 +471,7 @@ class SourceCoordinator( onBattery = batterySink, ) } - SourceKind.oura.name -> makeOuraSource(id, ctx, row) + SourceKind.oura.name -> makeOuraSource(id, ctx, row, devices) else -> { val repo = requireNotNull(repository) { "SourceCoordinator.repository is required to persist strap samples" } StandardHrSource( @@ -482,12 +522,25 @@ class SourceCoordinator( * so they live here rather than in the plain FTMS / Huami / Standard arms of [makeSource]. Mirrors the * Oura branch of macOS `makeOuraSource`. */ - private fun makeOuraSource(id: String, ctx: Context, row: PairedDeviceRow?): OuraLiveSource { + private fun makeOuraSource( + id: String, + ctx: Context, + row: PairedDeviceRow?, + devices: List, + ): OuraLiveSource { val repo = requireNotNull(repository) { "SourceCoordinator.repository is required to persist Oura samples" } // The ring generation is carried on the row's model ("Oura Ring 3/4/5"); recover it so the transport // clamps the MTU + picks the gen-appropriate live-HR enable command set. Defaults to gen3 if the // model is missing/unrecognised (OuraRingGen.from). val ringGen = OuraRingGen.from(row?.model ?: "") + // Pre-fix takeover attempts stored one key per rotating address. Registry timestamps preserve the + // actual attempt order, unlike SharedPreferences.getAll(), so try the newest Oura rows first. + val legacyIdsNewestFirst = devices + .asSequence() + .filter { it.sourceKind == SourceKind.oura.name } + .sortedWith(compareByDescending { it.lastSeenAt }.thenByDescending { it.addedAt }) + .map { it.id } + .toList() val source = OuraLiveSource( context = ctx, deviceId = id, @@ -498,6 +551,17 @@ class SourceCoordinator( // path (no faked data). Read fresh on each connect so a key provisioned mid-session (the adopt // install) is picked up on the post-install re-auth. authKey = { OuraInstallKeyStore.load(ctx, id) }, + authKeyCandidates = { address, advertisedSerial -> + val addressId = "${ExperimentalBrand.OURA.idPrefix}-$address" + val serialId = advertisedSerial?.let { "${ExperimentalBrand.OURA.idPrefix}-$it" } + val preferredIds = OuraKeyCandidates.preferredDeviceIds( + addressDeviceId = addressId, + serialDeviceId = serialId, + currentDeviceId = id, + legacyIdsNewestFirst = legacyIdsNewestFirst, + ) + OuraInstallKeyStore.loadCandidates(ctx, preferredIds) + }, persist = { batch: StreamBatch, deviceId: String -> scope.launch { runCatching { repo.insert(batch, deviceId) } } }, @@ -587,6 +651,7 @@ class SourceCoordinator( launch { source.adoptPhase.collect { _ouraAdoptPhase.value = it } } launch { source.needsPairing.collect { _ouraNeedsPairing.value = it } } launch { source.ouraWearState.collect { _ouraWearState.value = it } } + launch { source.historySyncing.collect { _ouraHistorySyncing.value = it } } } return source } @@ -625,6 +690,8 @@ class SourceCoordinator( _ouraAdoptPhase.value = OuraLiveSource.AdoptPhase.Idle _ouraNeedsPairing.value = null _ouraWearState.value = null // #628: no live Oura source -> no wear badge + _ouraActive.value = false + _ouraHistorySyncing.value = false // A stale speed/cadence/power readout must not outlive the strap session (the source's own stop() // already pushes an empty SensorMetrics, but reset here too so leaving for WHOOP / FTMS / Huami — // none of which feed this flow — is clean and immediate). diff --git a/android/app/src/main/java/com/noop/data/WhoopDao.kt b/android/app/src/main/java/com/noop/data/WhoopDao.kt index 818d9f7cfd..cc95f3ae55 100644 --- a/android/app/src/main/java/com/noop/data/WhoopDao.kt +++ b/android/app/src/main/java/com/noop/data/WhoopDao.kt @@ -462,6 +462,21 @@ interface WhoopDao : DeviceRegistryDao { ) suspend fun events(deviceId: String, from: Long, to: Long, limit: Int): List + /** One event stream without reading every other event kind in the same window. Used by the Oura-HRV + * comparison UI, where a long history can otherwise hit the generic event limit before newer RMSSD + * buckets are reached. Read-only; no schema or persisted-value change. */ + @Query( + "SELECT * FROM event WHERE deviceId = :deviceId AND kind = :kind AND ts >= :from AND ts <= :to " + + "ORDER BY ts ASC LIMIT :limit" + ) + suspend fun eventsByKind( + deviceId: String, + kind: String, + from: Long, + to: Long, + limit: Int, + ): List + @Query( "SELECT * FROM battery WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to " + "ORDER BY ts ASC LIMIT :limit" diff --git a/android/app/src/main/java/com/noop/data/WhoopRepository.kt b/android/app/src/main/java/com/noop/data/WhoopRepository.kt index b80e3b4c16..f68825dc24 100644 --- a/android/app/src/main/java/com/noop/data/WhoopRepository.kt +++ b/android/app/src/main/java/com/noop/data/WhoopRepository.kt @@ -825,9 +825,15 @@ class WhoopRepository( startTs, endTs, System.currentTimeMillis() / 1000L, ) ?: return val computedId = computedDeviceId(strapDeviceId) - val stagesJSON = com.noop.analytics.SleepStageHealer.restageFromRaw(this, strapDeviceId, safeStartTs, safeEndTs) + val stagesJSON = com.noop.analytics.SleepStageHealer.restageFromRaw( + this, strapDeviceId, safeStartTs, safeEndTs, + allowCardioOnly = strapDeviceId.startsWith("oura-", ignoreCase = true), + ) ?: com.noop.analytics.AnalyticsEngine.encodeStages( - listOf(com.noop.analytics.StageSegment(start = safeStartTs, end = safeEndTs, stage = "wake")), + // The user explicitly said this interval was sleep. With no raw rows yet, retain that + // evidence as an approximate light block rather than the old all-wake fallback, which + // made a successfully added nap contribute zero sleep and disappear from Rest. + listOf(com.noop.analytics.StageSegment(start = safeStartTs, end = safeEndTs, stage = "light")), ) dao.insertSleepSession( SleepSession( @@ -1153,6 +1159,14 @@ class WhoopRepository( suspend fun events(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT) = dao.events(deviceId, from, to, limit) + suspend fun eventsByKind( + deviceId: String, + kind: String, + from: Long, + to: Long, + limit: Int = DEFAULT_LIMIT, + ) = dao.eventsByKind(deviceId, kind, from, to, limit) + suspend fun batterySamples(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT) = dao.batterySamples(deviceId, from, to, limit) diff --git a/android/app/src/main/java/com/noop/oura/Commands.kt b/android/app/src/main/java/com/noop/oura/Commands.kt index 9b6f3b2a05..079cec5f7c 100644 --- a/android/app/src/main/java/com/noop/oura/Commands.kt +++ b/android/app/src/main/java/com/noop/oura/Commands.kt @@ -25,6 +25,11 @@ object OuraCommands { // The live daytime-HR feature id. Per OURA_PROTOCOL.md s5.6 / s7.1. const val featureDaytimeHR = 0x02 + // The ring's automatic resting-HR / overnight PPG feature. This is separate from daytime live HR: + // live 0x02 can work while 0x08 is off, which leaves no banked IBI/HRV/sleep records after a takeover. + // Confirmed by the Ring-4 APK feature table and on-device feature-status captures [ring4-ble]. + const val featureRestingHR = 0x08 + // The SpO2 feature id. Per OURA_PROTOCOL.md s7.1. const val featureSpO2 = 0x04 // The real-steps feature id. Server-flag-gated (activity/real_steps, default off), so it is never @@ -156,6 +161,23 @@ object OuraCommands { fun liveHRUnsubscribe(): OuraCommand = OuraCommand("dhr_unsubscribe", intArrayOf(0x2F, 0x03, 0x26, featureDaytimeHR, 0x00)) + // MARK: - Automatic overnight measurement (resting-HR feature 0x08) + + /** Put the ring's resting-HR feature in its normal automatic mode: `2f 03 22 08 01`. This is the + * firmware mode that records overnight PPG/IBI independently of a connected phone. [ring4-ble] */ + fun restingHRAutomatic(): OuraCommand = + OuraCommand("resting_hr_automatic", intArrayOf(0x2F, 0x03, 0x22, featureRestingHR, 0x01)) + + /** Subscribe to resting-HR state transitions: `2f 03 26 08 01`. This matches the captured normal + * `automatic/searching/measuring/state` configuration and does not request vendor scores. */ + fun restingHRStateSubscribe(): OuraCommand = + OuraCommand("resting_hr_state", intArrayOf(0x2F, 0x03, 0x26, featureRestingHR, 0x01)) + + /** Read back resting-HR status: `2f 02 20 08`, so the strap log proves whether automatic overnight + * measurement actually took effect. */ + fun restingHRReadStatus(): OuraCommand = + OuraCommand("resting_hr_status", intArrayOf(0x2F, 0x02, 0x20, featureRestingHR)) + // Feature-status diagnostics (READ-ONLY; s5.6 / s7.1) /** diff --git a/android/app/src/main/java/com/noop/oura/OuraIdentity.kt b/android/app/src/main/java/com/noop/oura/OuraIdentity.kt new file mode 100644 index 0000000000..7027cea89e --- /dev/null +++ b/android/app/src/main/java/com/noop/oura/OuraIdentity.kt @@ -0,0 +1,32 @@ +package com.noop.oura + +/** Stable identity helpers for Oura advertisements and product-info replies. */ +object OuraIdentity { + /** + * Extract the stable serial from reset-mode advertisements such as + * `Oura 4016082535033542`. Marketing names (`Oura Ring 4`, `Oura Horizon`, …) are not serials. + */ + fun advertisedSerial(name: String?): String? { + val value = name?.trim().orEmpty() + if (!value.startsWith("Oura ", ignoreCase = true)) return null + val suffix = value.substringAfter(' ').trim() + return suffix.takeIf { isPlausibleSerial(it) && it.any(Char::isDigit) } + } + + /** Guard against a malformed frame or a marketing name minting a bogus registry id. */ + fun isPlausibleSerial(value: String): Boolean = + value.length in 8..24 && value.all { it.isLetterOrDigit() } + + /** + * Whether an advertisement may belong to the expected stable serial. + * + * A reset ring advertises `Oura `, but after setup the same ring can advertise only + * `Oura Ring 4`. A missing advertised serial is therefore UNKNOWN, not a mismatch: the authenticated + * challenge is what verifies the ring. An explicitly different serial remains a hard rejection. + */ + fun mayMatchExpectedSerial(expectedSerial: String?, advertisedSerial: String?): Boolean = when { + expectedSerial == null -> true + advertisedSerial == null -> true + else -> expectedSerial.equals(advertisedSerial, ignoreCase = true) + } +} diff --git a/android/app/src/main/java/com/noop/oura/RingGen.kt b/android/app/src/main/java/com/noop/oura/RingGen.kt index 965c0a6c4b..9f0c66ae29 100644 --- a/android/app/src/main/java/com/noop/oura/RingGen.kt +++ b/android/app/src/main/java/com/noop/oura/RingGen.kt @@ -110,6 +110,11 @@ enum class OuraRingGen(val raw: String) { * `OuraRingGen.from(hardwareId:)`. */ fun fromHardwareId(hardwareId: String): OuraRingGen? { + // Ring 4's observed Frodo hardware id is ORE_06 (Defying/oura-ring4-ble real-device capture). + // Its suffix is a hardware revision, not the product generation, so handle it explicitly + // before the older BLB-style `_NN == generation` convention below. + if (hardwareId.equals("ORE_06", ignoreCase = true) || + hardwareId.uppercase().endsWith("ORE_06")) return GEN4 val underscore = hardwareId.lastIndexOf('_') if (underscore < 0) return null val digits = hardwareId.substring(underscore + 1).takeWhile { it.isDigit() } diff --git a/android/app/src/main/java/com/noop/ui/AddDeviceWizard.kt b/android/app/src/main/java/com/noop/ui/AddDeviceWizard.kt index cd97888b81..8f6e634570 100644 --- a/android/app/src/main/java/com/noop/ui/AddDeviceWizard.kt +++ b/android/app/src/main/java/com/noop/ui/AddDeviceWizard.kt @@ -67,6 +67,7 @@ import com.noop.data.PairedDeviceRow import com.noop.data.SourceKind import com.noop.data.WhoopLiveCapabilities import com.noop.oura.OuraRingGen +import kotlinx.coroutines.delay import kotlinx.coroutines.launch // MARK: - Add a device - guided, branching wizard (MW-4) @@ -359,7 +360,8 @@ fun AddDeviceWizard( /** * Register an adopted (or Advanced-key) Oura ring. Builds the oura [PairedDeviceRow] - id - * "oura-
", model = the picked generation's display name (the row recovers the gen via + * "oura-" when the reset advertisement exposes it (address fallback only for old firmware), + * model = the picked generation's display name (the row recovers the gen via * OuraRingGen.from(model)), sourceKind "oura" (routes the SourceCoordinator to [OuraLiveSource]), * gen-filtered capabilities - then registers it active so the live source starts. The Advanced path * also stores the user-supplied 16-byte key in the encrypted key store under the SAME id so the live @@ -381,7 +383,13 @@ fun AddDeviceWizard( val now = System.currentTimeMillis() / 1000 // Brand string, id prefix, and the "oura" routing come from the catalog via the type->brand bridge. val oura = ExperimentalBrand.OURA - val deviceId = "${oura.idPrefix}-${ring.address}" + val addressDeviceId = "${oura.idPrefix}-${ring.address}" + val deviceId = ring.advertisedSerial?.let { "${oura.idPrefix}-$it" } ?: addressDeviceId + // A previous attempt may already have received the ring's successful 0x25 install ACK before the + // old unpaced get_nonce write was dropped. Recover that exact address-keyed key under the serial and + // authenticate with it; never issue another install merely because the earlier UI stayed spinning. + val recoveredAcceptedKey = + deviceId != addressDeviceId && viewModel.recoverOuraInstallKey(addressDeviceId, deviceId) // Advanced (B-Alt): persist the pasted key BEFORE registering so the active source authenticates // with it WITHOUT a factory reset (the Oura app keeps working). This path NEVER arms adopt-intent, // so the live source never sends the dangerous install opcode. @@ -392,7 +400,7 @@ fun AddDeviceWizard( if (ouraAdvanced) { val key = parseHexKey(ouraKeyDraft) if (key != null) viewModel.saveOuraInstallKey(deviceId, key) - } else { + } else if (!recoveredAcceptedKey) { viewModel.armOuraAdopt(deviceId) } val device = PairedDeviceRow( @@ -659,6 +667,15 @@ fun AddDeviceWizard( adoptNeedsPairing != null -> ouraStep = OuraStep.Failed } } + // Covers failures before the live source reaches a protocol phase at all (scan/connect/CCCD stalls). + // The transport has its own 12 s per-response deadline; this outer ceiling guarantees the wizard can + // never remain on "Taking over" indefinitely even when Android delivers no callback. + LaunchedEffect(type, ouraStep) { + if (type == DeviceType.Oura && ouraStep == OuraStep.Adopting) { + delay(45_000L) + if (ouraStep == OuraStep.Adopting) ouraStep = OuraStep.Failed + } + } } private fun headerTitle(step: WizardStep, type: DeviceType?): String = when (step) { diff --git a/android/app/src/main/java/com/noop/ui/AppViewModel.kt b/android/app/src/main/java/com/noop/ui/AppViewModel.kt index a1f8dbdbfa..fd124cc94c 100644 --- a/android/app/src/main/java/com/noop/ui/AppViewModel.kt +++ b/android/app/src/main/java/com/noop/ui/AppViewModel.kt @@ -237,12 +237,14 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { } } - /** Archive (remove) a device — keeps its row + samples (invariant I4). H3 (#520): when the removed + /** Archive (remove) a device — keeps its row, samples, and reusable credentials (invariant I4). H3 (#520): when the removed * device is a WHOOP, also RELEASE the BLE link so the band can enter pairing mode — archiving the * registry row alone left NOOP re-grabbing it (the 3s reconnect timer + the persisted pin still * pointed at it), so it stayed connected and couldn't show its blue pairing LEDs. iOS already does * this in forgetDevice; this brings Android to parity. A non-WHOOP source (FTMS/HR strap) is owned by - * the SourceCoordinator, not the WHOOP client, so it isn't touched here. */ + * the SourceCoordinator, not the WHOOP client, so it isn't touched here. Oura's installed application + * key MUST survive an archive: the ring retains that key too, so deleting NOOP's copy would make the + * UI's promised re-add impossible without another factory reset/takeover. */ suspend fun archivePairedDevice(id: String) { val devices = runCatching { noopApp.deviceRegistry.all() }.getOrDefault(emptyList()) noopApp.deviceRegistry.archive(id) @@ -360,6 +362,11 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { val ouraWearState: StateFlow = noopApp.sourceCoordinator.ouraWearState + /** Actual active-source state from the coordinator. Unlike [activeStrapId], this cannot go stale when + * Oura adopts its stable serial id while the process is already running. */ + val ouraActive: StateFlow = noopApp.sourceCoordinator.ouraActive + val ouraHistorySyncing: StateFlow = noopApp.sourceCoordinator.ouraHistorySyncing + /** #656: a journal day-offset (daysBack; -1 = Tomorrow) the Today journal widget asks the journal * (Insights) to open at, so tapping a SPECIFIC day's bar lands on THAT day instead of always today. * InsightsScreen consumes it on open and clears it via [requestJournalDay]`(null)`. */ @@ -409,6 +416,19 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { fun saveOuraInstallKey(deviceId: String, key: IntArray): Boolean = com.noop.ble.OuraInstallKeyStore.save(appContext, deviceId, key) + /** + * Promote a key accepted during an older address-keyed takeover to the ring's advertised stable serial. + * Returns true when a valid key already existed, allowing the wizard to recover by authentication + * instead of issuing another dangerous install. + */ + fun recoverOuraInstallKey(addressDeviceId: String, stableDeviceId: String): Boolean = + com.noop.ble.OuraInstallKeyStore.copy( + appContext, + fromDeviceId = addressDeviceId, + toDeviceId = stableDeviceId, + overwrite = true, + ) + /** * Arm (or clear) the one-shot adopt-intent for an Oura ring (keyed by its registry device id). The * Add-Oura wizard's DESTRUCTIVE factory-reset-and-adopt path calls this with true AFTER its @@ -730,6 +750,10 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { * `trySend` never suspends the main-thread resume callback. */ private val analyzeKick = Channel(Channel.CONFLATED) + /** Android can keep the current Compose destination mounted while the Activity is backgrounded, so + * [realtimeWanters] alone is not proof that anyone can see live HR. Main-thread lifecycle callbacks and + * request/release calls own this flag together. */ + private var appActivityResumed = false /** * #78 hole-4: the app-foreground hook for the bond-loop salvage probe. Every activity resume runs @@ -741,6 +765,8 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { */ private val salvageProbeLifecycleCallbacks = object : Application.ActivityLifecycleCallbacks { override fun onActivityResumed(activity: android.app.Activity) { + appActivityResumed = true + noopApp.sourceCoordinator.setOuraLiveHrRequested(realtimeWanters > 0) ble.salvageProbeIfBondLoopPaused() // #386 self-heal: nudge the analyze loop so a night the killed overnight tick never scored is // caught up now. Gated + coalesced downstream, so a healthy resume costs one fingerprint read. @@ -748,8 +774,14 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { } override fun onActivityCreated(activity: android.app.Activity, savedInstanceState: android.os.Bundle?) {} override fun onActivityStarted(activity: android.app.Activity) {} - override fun onActivityPaused(activity: android.app.Activity) {} - override fun onActivityStopped(activity: android.app.Activity) {} + override fun onActivityPaused(activity: android.app.Activity) { + appActivityResumed = false + noopApp.sourceCoordinator.setOuraLiveHrRequested(false) + } + override fun onActivityStopped(activity: android.app.Activity) { + appActivityResumed = false + noopApp.sourceCoordinator.setOuraLiveHrRequested(false) + } override fun onActivitySaveInstanceState(activity: android.app.Activity, outState: android.os.Bundle) {} override fun onActivityDestroyed(activity: android.app.Activity) {} } @@ -768,6 +800,20 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { // keeps going across the process being killed (this phone class is not battery-exempt and Android // kills the background BLE overnight — the very window a battery capture needs to span). if (NoopPrefs.detailedCapture(appContext)) ble.setDetailedCapture(true) + // An Oura drain previously waited for the unrelated 15-minute analytics backstop, so a completed + // morning sync could leave Sleep/HRV blank for another full interval. Wake the existing + // fingerprint-gated scorer as soon as the drain ends. The short settle delay lets the coordinator's + // asynchronous Room inserts commit first; Channel.CONFLATED safely coalesces overlapping triggers. + viewModelScope.launch { + var wasSyncing = false + ouraHistorySyncing.collect { syncing -> + if (wasSyncing && !syncing) { + delay(OURA_HISTORY_PERSIST_SETTLE_MS) + analyzeKick.trySend(Unit) + } + wasSyncing = syncing + } + } // #78 hole-4: wire the app-foreground salvage probe (see salvageProbeLifecycleCallbacks above). noopApp.registerActivityLifecycleCallbacks(salvageProbeLifecycleCallbacks) // Resolve the active band's name for the Live screen header (MW-6). Falls back to "WHOOP" in the @@ -1001,7 +1047,10 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { // moves the raw-HR fingerprint, so skip the heavy 21-day rescore when the HR stream is unchanged // since the last COMPLETED run. Mirrors the Swift analyzeRecent(force:false) gate; the watermark // advances only on success (below), so an interrupted run can never hide unscored data. - val analyzeFp = repository.hrFingerprint() + // Version the fingerprint as well as recording raw-HR changes. A release that learns a new + // way to interpret already-banked data (the Oura resting-window fallback here) must score + // once after upgrade even when no HR row changed; subsequent idle ticks are cheap again. + val analyzeFp = "$ANALYTICS_WATERMARK_REVISION:${repository.hrFingerprint()}" // #1538: attribute the tick that is about to run. An idle-tick pass previously emitted NO // trigger line at all — a "re-score: done" with nothing before it — so a strap log could not // be read by pairing trigger->done, and a stalled background pass was easy to misattribute to @@ -1669,10 +1718,42 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { * Swift SleepView's analyzeRecent() after addManualNap. Swallows persist failures; the Sleep screen * recomputes from the persisted rows on its next reload. */ suspend fun addManualNap(startTs: Long, endTs: Long) { - runCatching { repository.addManualNap(deviceId, startTs, endTs) } + runCatching { repository.addManualNap(resolvedActiveDeviceId(), startTs, endTs) } rescoreAfterEdit() } + /** + * Persist a sleep boundary and turn a valid bedtime→wake pair into an actual user-owned session. + * The exact pending onset lives in SharedPreferences so closing Android between the two taps is safe. + * Short pairs are accepted (naps); only accidental sub-5-minute taps and impossible >16-hour spans are + * left as diagnostic marks without creating a session. + */ + suspend fun recordSleepMark(type: SleepMarkType): String { + val mark = SleepMark.now(type) + ble.externalLog(mark.logLine()) + runCatching { repository.upsertMetricSeries(listOf(mark.metricPoint("my-whoop"))) } + + if (type == SleepMarkType.BEDTIME) { + NoopPrefs.setPendingSleepStart(appContext, mark.tsMs / 1_000L) + return mark.confirmation() + } + + val start = NoopPrefs.pendingSleepStart(appContext) + val end = mark.tsMs / 1_000L + val duration = end - start + if (start <= 0L) return "${mark.confirmation()} No pending bedtime was found." + if (duration !in MIN_MARKED_SLEEP_SECONDS..MAX_MARKED_SLEEP_SECONDS) { + ble.externalLog("Sleep mark · pair not recorded durationMin=${duration / 60}") + return "${mark.confirmation()} Sleep session not added because the marked interval was invalid." + } + + val saved = runCatching { repository.addManualNap(resolvedActiveDeviceId(), start, end) }.isSuccess + if (!saved) return "${mark.confirmation()} The sleep session could not be saved." + NoopPrefs.clearPendingSleepStart(appContext) + rescoreAfterEdit() + return "Sleep session recorded (${duration / 60} min)." + } + // --- On-device short-nap detection (PR #569 reimpl under NoopApp). Candidates are detected on the // offload hook (WhoopBleClient.maybeDetectNaps) and queued in NapStore; this is the review surface. --- @@ -1713,10 +1794,11 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { */ private suspend fun rescoreAfterEdit() { runCatching { + val scoringDeviceId = resolvedActiveDeviceId() IntelligenceEngine.analyzeRecent( repo = repository, profile = currentProfile(), - importedDeviceId = deviceId, + importedDeviceId = scoringDeviceId, maxHROverride = profileStore.hrMaxOverride .takeIf { it > 0 }?.toDouble(), ownerSource = RegistryDayOwnerSource(noopApp.deviceRegistry), @@ -1747,6 +1829,12 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { }.onFailure { if (it is kotlin.coroutines.cancellation.CancellationException) throw it } } + /** Registry read-through for actions that can happen after Oura replaces its temporary address id + * with the stable serial during this process. [deviceId] remains the startup fallback for WHOOP and + * for a transient registry read failure. */ + private suspend fun resolvedActiveDeviceId(): String = + runCatching { noopApp.deviceRegistry.activeDeviceId() }.getOrNull() ?: deviceId + /** Re-read every source + the dismissed markers and republish [workouts]. */ fun loadWorkouts() { viewModelScope.launch { @@ -2354,6 +2442,7 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { fun requestRealtimeHr() { if (realtimeWanters++ == 0) { resetSmoothing() + noopApp.sourceCoordinator.setOuraLiveHrRequested(appActivityResumed) ble.startRealtime() } } @@ -2361,7 +2450,10 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { /** A live-HR screen went away. Stops the realtime stream only when the last one leaves. */ fun releaseRealtimeHr() { realtimeWanters = (realtimeWanters - 1).coerceAtLeast(0) - if (realtimeWanters == 0) ble.stopRealtime() + if (realtimeWanters == 0) { + noopApp.sourceCoordinator.setOuraLiveHrRequested(false) + ble.stopRealtime() + } } /** Refresh the battery reading. Reads the standard 0x2A19 characteristic (works on 5/MG, where the @@ -2374,7 +2466,13 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { * not-already-backfilling guard the auto-kick and 900s periodic timer use — so it's a safe no-op * when the strap isn't ready or a session is already running. Progress is unknowable from the * protocol, so the UI shows an indeterminate indicator + live.syncChunksThisSession, never a percent. */ - fun syncNow() = ble.syncNow() + fun syncNow() { + // Route by the coordinator's ACTUAL running source, not the startup-cached device id. Oura can + // adopt a stable serial identity in-process, which previously sent this tap to the WHOOP no-op. + if (!noopApp.sourceCoordinator.syncOuraNow()) { + ble.syncNow() + } + } /** Force an immediate Fitness Age recompute from stored history , the not-ready card's refresh button. * Light (no raw-HR rescoring), so it returns fast and works even when the strap is offline. Applies the @@ -2751,17 +2849,12 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { ble.buzz(1) } - /** Record a "sleep mark" via the existing [SleepMark] analytics + the shareable strap log, with a - * confirming buzz — the same logging-only path the Sleep screen's mark card uses (#461). A double-tap - * can't pick bedtime vs wake, so it defaults to bedtime ([SleepMark.nowDefault]). */ + /** Record bedtime via the same durable mark path as the Sleep screen. A double-tap cannot choose a + * boundary, so it starts/replaces the pending bedtime; the user can finish with "I'm awake". */ private fun markSleep() { - val mark = SleepMark.nowDefault() - ble.externalLog(mark.logLine()) ble.buzz(1) viewModelScope.launch { - // Use the SAME "my-whoop" series source the Sleep screen's mark card writes (SleepScreen.kt) - // and reads back from, so a double-tap mark lands in the same place a tapped one does. - runCatching { repository.upsertMetricSeries(listOf(mark.metricPoint("my-whoop"))) } + recordSleepMark(SleepMarkType.BEDTIME) } } @@ -2847,6 +2940,10 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { const val STRAP_ALARM_REARM_INTERVAL_MS = 24 * 60 * 60 * 1_000L /** SharedPreferences key for the persisted double-tap action (stored as the enum NAME). */ const val DOUBLE_TAP_ACTION_KEY = "noop.doubleTapAction" + const val ANALYTICS_WATERMARK_REVISION = "oura-sleep-v2" + const val MIN_MARKED_SLEEP_SECONDS = 5L * 60L + const val MAX_MARKED_SLEEP_SECONDS = 16L * 60L * 60L + const val OURA_HISTORY_PERSIST_SETTLE_MS = 1_500L } } diff --git a/android/app/src/main/java/com/noop/ui/HealthScreen.kt b/android/app/src/main/java/com/noop/ui/HealthScreen.kt index 625c1d9308..f8fd5e2f84 100644 --- a/android/app/src/main/java/com/noop/ui/HealthScreen.kt +++ b/android/app/src/main/java/com/noop/ui/HealthScreen.kt @@ -83,6 +83,7 @@ import com.noop.analytics.FitnessReadinessItem import com.noop.analytics.FitnessReadinessRole import com.noop.analytics.FitnessReadinessStatus import com.noop.analytics.SkinTempDisplay +import com.noop.analytics.SleepStager import com.noop.analytics.VitalBands import com.noop.ble.LiveState import com.noop.data.DailyMetric @@ -93,9 +94,11 @@ import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Locale import kotlin.math.roundToInt +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.withContext // MARK: - Health Monitor (ported from Strand/Screens/HealthView.swift) // @@ -263,11 +266,8 @@ fun HealthScreen( // // Manual "Sync now" control + honest sync status, mirroring HealthView.swift's SyncStatusSection (which // itself mirrors this screen's Android Sync-now button). Reads only LiveState (connection + backfill + -// last-sync), so the ~1Hz HR hero doesn't drag it through re-renders. The button reaches the BLE engine's -// gated entry point (vm.syncNow → WhoopBleClient.syncNow) — a no-op when no strap is connected or a sync -// is already running, so it's safe regardless of state. The status line explains itself when no strap is -// connected; while a sync runs it shows the shared in-progress note + live chunk count; otherwise it -// shows when history last synced. +// last-sync), so the ~1Hz HR hero doesn't drag it through re-renders. The button reaches the active Oura +// source or the WHOOP BLE engine. The status line follows that source's own in-progress state. @Composable private fun SyncStatusSection(vm: AppViewModel, onSyncNow: () -> Unit) { @@ -277,13 +277,21 @@ private fun SyncStatusSection(vm: AppViewModel, onSyncNow: () -> Unit) { // ~1Hz churn to the (cheap) sync card alone. The fields read below are slow-changing; only this // leaf re-runs per tick. Appearance + behaviour identical. val live by vm.live.collectAsStateWithLifecycle() - // The strap link is usable for a manual offload kick (matches WhoopBleClient.syncNow's own gate). - val canSync = live.connected && live.bonded && !live.backfilling + val activeIsOura by vm.ouraActive.collectAsStateWithLifecycle() + val ouraHistorySyncing by vm.ouraHistorySyncing.collectAsStateWithLifecycle() + // Oura authenticates with its own application key and intentionally never sets WHOOP's `bonded` + // flag. Live HR proves the Oura transport is authenticated, so don't mislabel it "Pairing…" or + // disable its own GetEvents sync button merely because a WHOOP-only field is false. + val connected = live.connected && (!activeIsOura || live.streamingLiveHR) + // Oura owns a separate GetEvents driver; WHOOP's `backfilling` flag never describes it. Previously + // the initial automatic Oura drain looked "Ready to sync", while a tap was rejected as already busy. + val syncing = if (activeIsOura) ouraHistorySyncing else live.backfilling + val canSync = connected && !syncing && (activeIsOura || live.bonded) Column(verticalArrangement = Arrangement.spacedBy(Metrics.gap)) { SectionHeader( "Sync", overline = "Strap history", - trailing = if (live.connected) (if (live.bonded) "Connected" else "Pairing…") else "Offline", + trailing = if (connected) "Connected" else if (live.connected) "Waiting for HR…" else "Offline", ) NoopCard(tint = Palette.chargeColor) { @@ -291,13 +299,19 @@ private fun SyncStatusSection(vm: AppViewModel, onSyncNow: () -> Unit) { // Status line: an in-progress note while syncing (with the live chunk count), an honest // "not connected" pill, a last-synced read-out, else a "ready to sync"/"pairing" pill. when { - live.backfilling -> SyncingHistoryNote(chunks = live.syncChunksThisSession) - !live.connected -> StatePill( + syncing -> SyncingHistoryNote(chunks = live.syncChunksThisSession) + !connected -> StatePill( title = uiString(R.string.l10n_health_screen_no_strap_connected_fb37b99e), tone = StrandTone.Neutral, showsDot = false, ) - live.lastSyncAt != null -> Row( + !activeIsOura && !live.bonded -> StatePill( + title = "Finishing pairing", + tone = StrandTone.Neutral, + showsDot = true, + pulsing = true, + ) + !activeIsOura && live.lastSyncAt != null -> Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Metrics.space8), ) { @@ -309,10 +323,10 @@ private fun SyncStatusSection(vm: AppViewModel, onSyncNow: () -> Unit) { ) } else -> StatePill( - title = if (live.bonded) "Ready to sync" else "Pairing…", + title = "Ready to sync", tone = StrandTone.Accent, showsDot = true, - pulsing = !live.bonded, + pulsing = false, ) } @@ -322,7 +336,7 @@ private fun SyncStatusSection(vm: AppViewModel, onSyncNow: () -> Unit) { // connected+bonded and not already syncing; the gated BLE entry point is a safe no-op // otherwise. (Total pending records are unknowable from the protocol, so no progress %.) NoopButton( - text = if (live.backfilling) "Syncing…" else "Sync now", + text = if (syncing) "Syncing…" else "Sync now", leadingIcon = Icons.Filled.Sync, kind = NoopButtonKind.Secondary, fullWidth = true, @@ -331,7 +345,7 @@ private fun SyncStatusSection(vm: AppViewModel, onSyncNow: () -> Unit) { contentDescription = if (canSync) { "Sync now. Pulls your strap's stored history immediately, without waiting " + "for the next automatic sync." - } else if (live.backfilling) { + } else if (syncing) { "Sync now. A sync is already in progress." } else { "Sync now. Connect your strap first." @@ -341,7 +355,7 @@ private fun SyncStatusSection(vm: AppViewModel, onSyncNow: () -> Unit) { ) Text( - syncHelperText(live), + syncHelperText(live, activeIsOura, connected, syncing), style = NoopType.footnote, color = Palette.textTertiary, ) @@ -352,11 +366,18 @@ private fun SyncStatusSection(vm: AppViewModel, onSyncNow: () -> Unit) { /** The helper line below the Sync-now button: explains the current state (syncing / offline / pairing / * ready), copy-matched to HealthView.swift's SyncStatusSection.helperText. */ -private fun syncHelperText(live: LiveState): String = when { - live.backfilling -> "Pulling your strap's stored history. This drains oldest-first; a deep backlog " + +private fun syncHelperText( + live: LiveState, + activeIsOura: Boolean, + connected: Boolean, + syncing: Boolean, +): String = when { + syncing -> "Pulling ${if (activeIsOura) "your ring's" else "your strap's"} stored history. This drains oldest-first; a deep backlog " + "now continues automatically across passes instead of waiting between syncs." - !live.connected -> "Connect your strap to sync its stored history. Until then, only imported data " + + !connected -> "Connect your strap to sync its stored history. Until then, only imported data " + "shows here." + activeIsOura -> "Pulls the ring's banked temperature, IBI, HRV, and sleep records now. NOOP also checks " + + "for new ring history automatically every 15 minutes while connected." !live.bonded -> "Finishing the pairing handshake. Sync now becomes available once the strap is paired." else -> "Syncs your strap's stored history right away, instead of waiting for the next automatic sync." } @@ -1803,6 +1824,7 @@ internal fun spo2EmptyState( @Composable fun VitalDetailScreen(vm: AppViewModel, key: String) { val days by vm.recentDays.collectAsStateWithLifecycle() + val ouraHistorySyncing by vm.ouraHistorySyncing.collectAsStateWithLifecycle() val context = LocalContext.current val tempUnit = UnitPrefs.temperature(context) // The Effort detail renders per the user's Effort display scale (0-100 vs 0-21), like the Today tile. @@ -1859,6 +1881,29 @@ fun VitalDetailScreen(vm: AppViewModel, key: String) { // button recomputes then bumps this tick, re-running the series read so a fresh value shows at once. var refreshTick by remember { mutableStateOf(0) } var refreshing by remember { mutableStateOf(false) } + // Oura's open 0x5D stream is deliberately a SECONDARY, read-only comparison. It never replaces + // DailyMetric.avgHrv and never feeds Charge. Load it only on the HRV detail so no other screen pays + // for the event/sleep-session read. Re-run after a daily-metric update, which is the normal end of a + // history-sync/analyze pass and therefore the point at which fresh ring buckets become useful here. + // Oura's own drain state is also a key because Ring 4 can add HRV rows without creating a DailyMetric. + var ouraHrv by remember(key, vm.activeStrapId) { mutableStateOf>(emptyList()) } + var noopComputedHrv by remember(key, vm.activeStrapId) { + mutableStateOf>(emptyList()) + } + var ouraHrvLoaded by remember(key, vm.activeStrapId) { mutableStateOf(key != "hrv") } + if (key == "hrv") { + LaunchedEffect(key, days, vm.activeStrapId, ouraHistorySyncing) { + ouraHrvLoaded = false + // Oura's source publishes drain completion just before its queued Room inserts necessarily + // finish. Give those already-launched writes one scheduling turn, then query the newly stored + // buckets/R-R rows. Opening this screen later still loads immediately after this small delay. + if (!ouraHistorySyncing) delay(300) + val sources = loadOuraHrvSources(vm, days) + ouraHrv = sources.oura + noopComputedHrv = sources.noop + ouraHrvLoaded = true + } + } if (isSeriesBacked) { LaunchedEffect(key, refreshTick) { seriesDetail = buildSeriesVitalDetail(vm, key) @@ -1900,7 +1945,26 @@ fun VitalDetailScreen(vm: AppViewModel, key: String) { ) return@ScreenScaffold } + if (key == "hrv" && !ouraHrvLoaded) { + DataPendingNote( + title = uiString(R.string.l10n_health_screen_loading_33ce4174), + body = "Fetching the ring's native HRV history.", + ) + return@ScreenScaffold + } if (detail == null || detail.points.size < 2) { + // Oura may have native five-minute RMSSD buckets before NOOP has enough computed nights for a + // trend. Keep that real evidence visible rather than hiding it behind the generic two-reading + // empty state. A single NOOP value is shown beside the native value when their wake-days match. + if (key == "hrv" && ouraHrv.isNotEmpty()) { + HrvSourcesCard(noopComputedHrv, ouraHrv) + HrvComparisonTable(noopComputedHrv, ouraHrv) + DataPendingNote( + title = "NOOP HRV trend is still calibrating", + body = "The resting-window comparison is available above. NOOP's nightly trend still needs detected sleep sessions.", + ) + return@ScreenScaffold + } // Fitness Age with NO value yet (zero points): show the readiness checklist + the "N more // nights of wear" countdown — what it actually needs — instead of the generic "needs two // readings to chart" note, which describes the trend line and left the Today card's tap-through @@ -1983,6 +2047,20 @@ fun VitalDetailScreen(vm: AppViewModel, key: String) { val filteredReadings = remember(detail, effectiveRange) { filterVitalReadings(detail.readings, effectiveRange) } val filteredPoints = filteredReadings.map { it.day to it.value } if (filteredPoints.size < 2) { + if (key == "hrv" && ouraHrv.isNotEmpty()) { + val anchorDay = filteredReadings.lastOrNull()?.day + val firstDay = anchorDay?.let { day -> + effectiveRange.days?.let { LocalDate.parse(day).minusDays(it - 1).toString() } + } + val filteredOura = if (firstDay == null) ouraHrv else { + ouraHrv.filter { it.day >= firstDay && it.day <= anchorDay } + } + val filteredNoop = if (firstDay == null) noopComputedHrv else { + noopComputedHrv.filter { it.day >= firstDay && it.day <= anchorDay } + } + HrvSourcesCard(filteredNoop, filteredOura) + HrvComparisonTable(filteredNoop, filteredOura) + } DataPendingNote( title = uiString(R.string.l10n_health_screen_not_enough_history_in_this_range_2da72f80), body = "Try a longer interval like 3M, 6M, 1Y, or ALL to see this vital’s trend.", @@ -2002,6 +2080,14 @@ fun VitalDetailScreen(vm: AppViewModel, key: String) { val max = values.maxOrNull() val avg = values.average() + if (key == "hrv" && ouraHrv.isNotEmpty()) { + val firstDay = filteredReadings.first().day + val lastDay = filteredReadings.last().day + val filteredOura = ouraHrv.filter { it.day >= firstDay && it.day <= lastDay } + val filteredNoop = noopComputedHrv.filter { it.day >= firstDay && it.day <= lastDay } + if (filteredOura.isNotEmpty()) HrvSourcesCard(filteredNoop, filteredOura) + } + SectionHeader(detail.title, overline = "Vital Signs", trailing = "${filteredReadings.size} readings") NoopCard { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { @@ -2086,6 +2172,285 @@ fun VitalDetailScreen(vm: AppViewModel, key: String) { vitalReadingRows(filteredReadings, detail.unit, strapId, detail.format) } VitalReadingsTable(rows = readingRows) + if (key == "hrv" && ouraHrv.isNotEmpty()) { + val firstDay = filteredReadings.first().day + val lastDay = filteredReadings.last().day + val filteredOura = ouraHrv.filter { it.day >= firstDay && it.day <= lastDay } + val filteredNoop = noopComputedHrv.filter { it.day >= firstDay && it.day <= lastDay } + HrvComparisonTable(filteredNoop, filteredOura) + } + } +} + +/** Read Oura's native RMSSD event stream. A detected session owns its buckets normally; a Ring 4 history + * with no sessions uses the adapter's noon-to-noon resting-window grouping. The query is kind-specific so + * a long motion/sleep-event history cannot consume the generic event limit before newer HRV rows arrive. */ +private data class OuraHrvSources( + val noop: List, + val oura: List, +) + +/** One NOOP-side value used only in the Oura comparison card. A canonical value came from a detected + * sleep session and is already a DailyMetric; a resting-window fallback is freshly calculated from the + * durable R-R rows spanning Oura's native 0x5D buckets and deliberately does not alter DailyMetric/Charge. */ +private data class NoopHrvComparisonReading( + val day: String, + val value: Double, + val fromRestingWindow: Boolean, +) + +private suspend fun loadOuraHrvSources( + vm: AppViewModel, + days: List, +): OuraHrvSources { + val zone = ZoneId.systemDefault() + val parsedDays = days.mapNotNull { runCatching { LocalDate.parse(it.day) }.getOrNull() } + // An Oura-only install can have real 0x5D/R-R history before it has any computed DailyMetric at all. + // Query a bounded recent window in that cold-start case instead of making HRV visibility depend on the + // very sleep session Ring 4 did not expose. + val today = LocalDate.now(zone) + val oldestDate = parsedDays.minOrNull() ?: today.minusDays(30) + val newestDate = parsedDays.maxOrNull() ?: today + // A wake-day's night usually begins on the prior calendar day. Pad both ends so the oldest requested + // night and a late wake/nap are fully covered. + val from = oldestDate.minusDays(1).atStartOfDay(zone).toEpochSecond() + val to = newestDate.plusDays(2).atStartOfDay(zone).toEpochSecond() + val sourceId = vm.activeStrapId + val computedId = vm.repo.computedDeviceId(sourceId) + val oldestDay = oldestDate.toString() + val newestDay = newestDate.toString() + // Read the computed namespace directly for the comparison label. The existing HRV chart keeps its + // normal merged/import-aware semantics; the side-by-side card must specifically mean NOOP's own value. + val canonicalNoop = runCatching { vm.repo.days(computedId) } + .getOrDefault(emptyList()) + .asSequence() + .filter { it.day >= oldestDay && it.day <= newestDay } + .mapNotNull { row -> + row.avgHrv?.let { + NoopHrvComparisonReading(day = row.day, value = it, fromRestingWindow = false) + } + } + .toList() + val events = runCatching { + vm.repo.eventsByKind( + deviceId = sourceId, + kind = com.noop.data.OuraStreamMapping.EVENT_HRV, + from = from, + to = to, + limit = OURA_NATIVE_HRV_EVENT_LIMIT, + ) + }.getOrDefault(emptyList()) + if (events.isEmpty()) return OuraHrvSources(canonicalNoop, emptyList()) + val sleeps = runCatching { + vm.repo.sleepSessions(computedId, from, to, OURA_NATIVE_HRV_SLEEP_LIMIT) + }.getOrDefault(emptyList()) + val oura = withContext(Dispatchers.Default) { OuraNativeHrv.aggregate(events, sleeps, zone) } + if (oura.isEmpty()) return OuraHrvSources(canonicalNoop, emptyList()) + + // Ring 4's automatic-resting mode supplied real IBI + native RMSSD but no sleep phases in the field + // capture. Fill only comparison-card gaps from the SAME resting interval; do not persist these values + // as a DailyMetric, do not call it detected sleep, and do not let it affect Charge. + val canonicalDays = canonicalNoop.mapTo(HashSet()) { it.day } + val missing = oura.filter { it.day !in canonicalDays } + val fallbackNoop = if (missing.isEmpty()) { + emptyList() + } else { + val rrFrom = missing.minOf { it.windowStartTs } + val rrTo = missing.maxOf { it.windowEndTs } + val rr = runCatching { + vm.repo.rrIntervals(sourceId, rrFrom, rrTo, OURA_HRV_RR_LIMIT) + }.getOrDefault(emptyList()) + withContext(Dispatchers.Default) { + missing.mapNotNull { native -> + SleepStager.sessionAvgHRV(native.windowStartTs, native.windowEndTs, rr)?.let { value -> + NoopHrvComparisonReading( + day = native.day, + value = value, + fromRestingWindow = true, + ) + } + } + } + } + return OuraHrvSources( + noop = (canonicalNoop + fallbackNoop).sortedBy { it.day }, + oura = oura, + ) +} + +private const val OURA_NATIVE_HRV_EVENT_LIMIT = 250_000 +private const val OURA_NATIVE_HRV_SLEEP_LIMIT = 5_000 +private const val OURA_HRV_RR_LIMIT = 250_000 + +/** Side-by-side values for the newest wake-day both sources share. If no day overlaps yet, each column + * shows its own latest day and labels it explicitly, so unlike-looking dates are never presented as a + * same-night comparison. */ +@Composable +private fun HrvSourcesCard( + noopReadings: List, + ouraReadings: List, +) { + if (ouraReadings.isEmpty()) return + val ouraByDay = ouraReadings.associateBy { it.day } + val sharedDay = noopReadings.asReversed().firstNotNullOfOrNull { reading -> + reading.day.takeIf { it in ouraByDay } + } + val noop = sharedDay?.let { day -> noopReadings.lastOrNull { it.day == day } } + ?: noopReadings.lastOrNull() + val oura = sharedDay?.let(ouraByDay::get) ?: ouraReadings.last() + val sameNight = noop != null && noop.day == oura.day + + NoopCard { + Column(verticalArrangement = Arrangement.spacedBy(Metrics.space12)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Overline("HRV sources", modifier = Modifier.weight(1f)) + Text( + if (sameNight) vitalReadingDateLabel(oura.day) else "Latest available", + style = NoopType.caption, + color = Palette.textTertiary, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(Metrics.space12)) { + HrvSourceValue( + modifier = Modifier.weight(1f), + label = "NOOP computed", + value = noop?.value, + day = noop?.day, + tint = Palette.metricPurple, + ) + HrvSourceValue( + modifier = Modifier.weight(1f), + label = "Oura ring", + value = oura.valueMs, + day = oura.day, + tint = Palette.metricCyan, + ) + } + Text( + if (noop?.fromRestingWindow == true) { + "NOOP computes RMSSD from stored intervals in the same ring-provided resting window. " + + "This comparison does not feed Charge until NOOP has a detected sleep session. " + + "Oura ring is the mean of ${oura.bucketCount} native five-minute RMSSD " + + if (oura.bucketCount == 1) "bucket." else "buckets." + } else { + "NOOP computes RMSSD from stored intervals and remains the value used by Charge. " + + "Oura ring is the mean of ${oura.bucketCount} native five-minute RMSSD " + + if (oura.bucketCount == 1) "bucket." else "buckets." + }, + style = NoopType.footnote, + color = Palette.textSecondary, + ) + } + } +} + +@Composable +private fun HrvSourceValue( + modifier: Modifier, + label: String, + value: Double?, + day: String?, + tint: Color, +) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(Metrics.space4)) { + Overline(label, color = Palette.textTertiary) + Text( + value?.let { "${it.roundToInt()} ms" } ?: "—", + style = NoopType.metricInline, + color = if (value != null) tint else Palette.textTertiary, + ) + Text( + day?.let(::vitalReadingDateLabel) ?: "No computed night", + style = NoopType.caption, + color = Palette.textTertiary, + ) + } +} + +/** Wake-day history for BOTH sides of the comparison. Keeping NOOP out of this table made an older ring + * row look as if its matching interval-derived value had never been calculated; a dash now means the R-R + * window genuinely lacked enough clean beats. Oura bucket counts remain explicit provenance. */ +@Composable +private fun HrvComparisonTable( + noopReadings: List, + ouraReadings: List, +) { + if (ouraReadings.isEmpty()) return + val noopByDay = noopReadings.associateBy { it.day } + NoopCard { + Column(verticalArrangement = Arrangement.spacedBy(Metrics.space10)) { + Overline("HRV history") + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text("Date", style = NoopType.footnote, color = Palette.textSecondary, modifier = Modifier.weight(1f)) + Text( + "NOOP", + style = NoopType.footnote, + color = Palette.textSecondary, + textAlign = TextAlign.End, + modifier = Modifier.weight(0.8f), + ) + Text( + "Oura", + style = NoopType.footnote, + color = Palette.textSecondary, + textAlign = TextAlign.End, + modifier = Modifier.weight(0.8f), + ) + Text( + "Buckets", + style = NoopType.footnote, + color = Palette.textSecondary, + textAlign = TextAlign.End, + modifier = Modifier.weight(0.8f), + ) + } + ouraReadings.asReversed().forEachIndexed { index, reading -> + val noop = noopByDay[reading.day] + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text( + vitalReadingDateLabel(reading.day), + style = NoopType.subhead, + color = Palette.textSecondary, + modifier = Modifier.weight(1f), + ) + Text( + noop?.let { "${it.value.roundToInt()} ms" } ?: "—", + style = NoopType.bodyNumber, + color = if (noop != null) Palette.metricPurple else Palette.textTertiary, + textAlign = TextAlign.End, + modifier = Modifier.weight(0.8f), + ) + Text( + "${reading.valueMs.roundToInt()} ms", + style = NoopType.bodyNumber, + color = Palette.metricCyan, + textAlign = TextAlign.End, + modifier = Modifier.weight(0.8f), + ) + Text( + reading.bucketCount.toString(), + style = NoopType.footnote, + color = Palette.textTertiary, + textAlign = TextAlign.End, + modifier = Modifier.weight(0.8f), + ) + } + if (index < ouraReadings.lastIndex) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(Metrics.divider) + .background(Palette.hairline), + ) + } + } + Text( + "Buckets are Oura's five-minute resting measurements. NOOP values use the matching " + + "stored R-R intervals; — means there were not enough clean intervals.", + style = NoopType.footnote, + color = Palette.textTertiary, + ) + } } } diff --git a/android/app/src/main/java/com/noop/ui/MainActivity.kt b/android/app/src/main/java/com/noop/ui/MainActivity.kt index ae06965ef1..ba4422c9a5 100644 --- a/android/app/src/main/java/com/noop/ui/MainActivity.kt +++ b/android/app/src/main/java/com/noop/ui/MainActivity.kt @@ -264,6 +264,11 @@ object NoopPrefs { const val KEY_ANALYZE_WATERMARK = "noop.analyzeWatermark" + /** Exact persisted onset for the Sleep screen's "Going to sleep" → "I'm awake" pair. Unlike the + * legacy metric-series marker (whose natural key only preserves a day), this survives an app/process + * restart and lets the wake mark create the real manual sleep/nap session. Epoch seconds; 0 = none. */ + const val KEY_PENDING_SLEEP_START = "noop.pendingSleepStart" + /** "Power saving" (#477): when on, NOOP stretches its periodic strap-sync cadence (15 → 45 min) while * the STRAP is discharging at/below [KEY_POWER_SAVING_BATTERY_PCT]. * @@ -409,6 +414,17 @@ object NoopPrefs { of(context).edit().putString(KEY_ANALYZE_WATERMARK, fingerprint).apply() } + fun pendingSleepStart(context: Context): Long = + of(context).getLong(KEY_PENDING_SLEEP_START, 0L) + + fun setPendingSleepStart(context: Context, epochSeconds: Long) { + of(context).edit().putLong(KEY_PENDING_SLEEP_START, epochSeconds.coerceAtLeast(0L)).apply() + } + + fun clearPendingSleepStart(context: Context) { + of(context).edit().remove(KEY_PENDING_SLEEP_START).apply() + } + /** Whether NOOP should hold the strap connection open via a foreground service. Default true. */ fun backgroundConnection(context: Context): Boolean = of(context).getBoolean(KEY_BACKGROUND_CONNECTION, true) diff --git a/android/app/src/main/java/com/noop/ui/OuraNativeHrv.kt b/android/app/src/main/java/com/noop/ui/OuraNativeHrv.kt new file mode 100644 index 0000000000..3af5d03ea4 --- /dev/null +++ b/android/app/src/main/java/com/noop/ui/OuraNativeHrv.kt @@ -0,0 +1,92 @@ +package com.noop.ui + +import com.noop.data.EventRow +import com.noop.data.OuraStreamMapping +import com.noop.data.SleepSession +import java.time.Instant +import java.time.ZoneId +import org.json.JSONObject + +/** One wake-day summary of the Oura ring's own open 0x5D five-minute RMSSD buckets. */ +internal data class OuraNativeHrvReading( + val day: String, + val valueMs: Double, + val bucketCount: Int, + /** Inclusive bounds of the ring's five-minute buckets. The end is one bucket past the last event so + * the UI-only NOOP comparator can read the same resting interval from the durable R-R stream. */ + val windowStartTs: Long, + val windowEndTs: Long, +) + +/** + * UI-only comparison adapter for Oura's native RMSSD stream. + * + * NOOP's canonical [com.noop.data.DailyMetric.avgHrv] remains unchanged and continues to feed Charge. + * This adapter only makes the ring's separately stored `OURA_HRV` evidence visible beside it. Buckets are + * assigned to a detected sleep session, then to that session's LOCAL wake day, matching the daily metric's + * night ownership. Ring 4 can also expose automatic-resting 0x5D buckets outside a sleep session (including + * short naps/rest); those ring-owned buckets remain visible and are grouped into local noon-to-noon wake + * days. This does not invent a sleep session or feed Charge: it only makes the ring's real resting + * measurement visible and provides its exact interval to the separately-labelled NOOP RMSSD comparator. + */ +internal object OuraNativeHrv { + fun aggregate( + events: List, + sleepSessions: List, + zone: ZoneId = ZoneId.systemDefault(), + ): List { + if (events.isEmpty()) return emptyList() + + val sessions = sleepSessions.sortedBy { it.effectiveStartTs } + val hrvEvents = events.filter { it.kind == OuraStreamMapping.EVENT_HRV }.sortedBy { it.ts } + val byDay = linkedMapOf>>() + var sessionIndex = 0 + for (event in hrvEvents) { + val value = parseRmssd(event.payloadJSON) ?: continue + // Both inputs are chronological. Advance past sessions that have ended, then inspect only the + // sessions whose onset is at/before this event. This stays linear for a long Oura history rather + // than scanning hundreds of past nights for every five-minute bucket. + while (sessionIndex < sessions.size && sessions[sessionIndex].endTs < event.ts) sessionIndex++ + var candidateIndex = sessionIndex + var session: SleepSession? = null + while (candidateIndex < sessions.size && sessions[candidateIndex].effectiveStartTs <= event.ts) { + val candidate = sessions[candidateIndex] + if (event.ts <= candidate.endTs) { + session = candidate + break + } + candidateIndex++ + } + val day = if (session != null) { + Instant.ofEpochSecond(session.endTs).atZone(zone).toLocalDate().toString() + } else { + // Keep short ring-resting windows visible even after NOOP has learned a separate main sleep + // session. A noon boundary is calendar ownership, not a claim that this bucket was sleep: + // 23:00 and 06:00 belong to the same next-morning wake day, including UTC/local-date edges. + val local = Instant.ofEpochSecond(event.ts).atZone(zone) + val wakeDate = if (local.hour >= 12) local.toLocalDate().plusDays(1) else local.toLocalDate() + wakeDate.toString() + } + byDay.getOrPut(day) { ArrayList() }.add(event.ts to value) + } + + return byDay.entries + .sortedBy { it.key } + .map { (day, buckets) -> + OuraNativeHrvReading( + day = day, + valueMs = buckets.map { it.second }.average(), + bucketCount = buckets.size, + windowStartTs = buckets.minOf { it.first }, + windowEndTs = buckets.maxOf { it.first } + FIVE_MINUTES_SECONDS, + ) + } + } + + private fun parseRmssd(payloadJSON: String): Double? = runCatching { + val value = JSONObject(payloadJSON).optDouble("rmssd_ms", Double.NaN) + value.takeIf { it.isFinite() && it > 0.0 && it <= 255.0 } + }.getOrNull() + + private const val FIVE_MINUTES_SECONDS = 5L * 60L +} diff --git a/android/app/src/main/java/com/noop/ui/SleepScreen.kt b/android/app/src/main/java/com/noop/ui/SleepScreen.kt index 7dd933d800..35bfb06f70 100644 --- a/android/app/src/main/java/com/noop/ui/SleepScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SleepScreen.kt @@ -4,7 +4,6 @@ import com.noop.R import androidx.compose.ui.res.stringResource import android.app.TimePickerDialog import android.widget.Toast -import com.noop.analytics.SleepMark import com.noop.analytics.SleepMarkType import com.noop.analytics.SleepWindowReclip import androidx.compose.foundation.Canvas @@ -604,15 +603,10 @@ fun SleepScreen( Spacer(Modifier.height(Metrics.selectorTopUp)) SleepMarkCard( onMark = { type -> - val mark = SleepMark.now(type) - // The shareable strap log is the human-readable surface in a debug export. - vm.ble.externalLog(mark.logLine()) scope.launch { - runCatching { - vm.repo.upsertMetricSeries(listOf(mark.metricPoint("my-whoop"))) - } + val message = vm.recordSleepMark(type) + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() } - Toast.makeText(context, mark.confirmation(), Toast.LENGTH_SHORT).show() }, ) } @@ -1958,4 +1952,3 @@ private fun MotionStrip(epochs: List) { // MARK: - Sleep metric cards, debt ledger, stages, trends + chart helpers live in SleepMetricCardsUi.kt // MARK: - Sleep metric detail sheet UI lives in SleepMetricDetailSheet.kt - diff --git a/android/app/src/test/java/com/noop/analytics/OuraRestingWindowsTest.kt b/android/app/src/test/java/com/noop/analytics/OuraRestingWindowsTest.kt new file mode 100644 index 0000000000..98919fece7 --- /dev/null +++ b/android/app/src/test/java/com/noop/analytics/OuraRestingWindowsTest.kt @@ -0,0 +1,57 @@ +package com.noop.analytics + +import com.noop.data.EventRow +import com.noop.data.OuraStreamMapping +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class OuraRestingWindowsTest { + private fun event(ts: Long, hrv: Int = 40, hr: Int = 58) = EventRow( + deviceId = "oura-ring", + ts = ts, + kind = OuraStreamMapping.EVENT_HRV, + payloadJSON = "{\"hr_bpm\":$hr,\"rmssd_ms\":$hrv}", + ) + + @Test + fun `eight bucket awake rest is retained but not promoted to sleep`() { + val events = (0 until 8).map { event(10_000L + it * 300L) } + + val all = OuraRestingWindows.windows(events) + + assertEquals(1, all.size) + assertEquals(8, all.single().bucketCount) + assertEquals(40L * 60L, all.single().durationSeconds) + assertTrue(OuraRestingWindows.sleepCandidates(events).isEmpty()) + } + + @Test + fun `long continuous late sleep becomes a candidate`() { + val events = (0 until 105).map { event(30_000L + it * 300L, hrv = 42) } + + val candidate = OuraRestingWindows.sleepCandidates(events).single() + + assertEquals(105, candidate.bucketCount) + assertEquals(8L * 60L * 60L + 45L * 60L, candidate.durationSeconds) + assertEquals(42.0, candidate.avgHrvMs, 0.0) + } + + @Test + fun `gap separates windows without dropping the short one`() { + val events = (0 until 4).map { event(1_000L + it * 300L) } + + (0 until 3).map { event(8_000L + it * 300L) } + + val windows = OuraRestingWindows.windows(events) + + assertEquals(listOf(4, 3), windows.map { it.bucketCount }) + } + + @Test + fun `sparse resting scatter is retained but not promoted`() { + val events = (0 until 18).map { event(20_000L + it * 15L * 60L) } + + assertEquals(1, OuraRestingWindows.windows(events).size) + assertTrue(OuraRestingWindows.sleepCandidates(events).isEmpty()) + } +} diff --git a/android/app/src/test/java/com/noop/analytics/SleepWindowHintTest.kt b/android/app/src/test/java/com/noop/analytics/SleepWindowHintTest.kt new file mode 100644 index 0000000000..7366b7cf0e --- /dev/null +++ b/android/app/src/test/java/com/noop/analytics/SleepWindowHintTest.kt @@ -0,0 +1,40 @@ +package com.noop.analytics + +import com.noop.data.HrSample +import com.noop.data.RrInterval +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SleepWindowHintTest { + @Test + fun `explicit forty minute nap contributes sleep rest and vitals without motion`() { + val start = 10_000L + val end = start + 40L * 60L + val hr = (start..end step 30L).map { HrSample("oura-ring", it, 58) } + val rr = (start..end step 2L).mapIndexed { index, ts -> + RrInterval("oura-ring", ts, if (index % 2 == 0) 980 else 1_020, seq = index) + } + val hint = SleepWindowHint( + start = start, + end = end, + stages = listOf(StageSegment(start, end, "light")), + ) + + val result = AnalyticsEngine.analyzeDay( + day = AnalyticsEngine.dayString(end), + hr = hr, + rr = rr, + gravity = emptyList(), + profile = UserProfile(), + sleepWindowHints = listOf(hint), + ) + + assertEquals(1, result.sleepSessions.size) + assertEquals(40.0, result.daily.totalSleepMin!!, 0.01) + assertEquals(58, result.daily.restingHr) + assertNotNull(result.daily.avgHrv) + assertTrue(result.rest?.let { it > 0.0 } == true) + } +} diff --git a/android/app/src/test/java/com/noop/ble/GattWriteQueueTest.kt b/android/app/src/test/java/com/noop/ble/GattWriteQueueTest.kt new file mode 100644 index 0000000000..3eb2d0ed44 --- /dev/null +++ b/android/app/src/test/java/com/noop/ble/GattWriteQueueTest.kt @@ -0,0 +1,53 @@ +package com.noop.ble + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class GattWriteQueueTest { + @Test + fun `commands remain FIFO until Android accepts each head`() { + val queue = GattWriteQueue() + queue.add("notify_all") + queue.add("get_nonce") + + assertEquals("notify_all", queue.peek()) + assertEquals("notify_all", queue.accepted()) + assertEquals("get_nonce", queue.peek()) + assertEquals("get_nonce", queue.accepted()) + assertNull(queue.peek()) + } + + @Test + fun `busy head is retried without allowing the next command past it`() { + val queue = GattWriteQueue(maxRejects = 2) + queue.add("notify_all") + queue.add("get_nonce") + + assertEquals(GattWriteQueue.Rejection.Retry, queue.rejected()) + assertEquals("notify_all", queue.peek()) + assertEquals(GattWriteQueue.Rejection.Retry, queue.rejected()) + assertEquals("notify_all", queue.peek()) + assertEquals(GattWriteQueue.Rejection.Dropped, queue.rejected()) + assertEquals("get_nonce", queue.peek()) + } + + @Test + fun `clear removes queued commands and rejection state`() { + val queue = GattWriteQueue(maxRejects = 1) + queue.add("get_nonce") + assertEquals(GattWriteQueue.Rejection.Retry, queue.rejected()) + queue.clear() + assertEquals(0, queue.size) + assertNull(queue.peek()) + } + + @Test + fun `pacing survives a momentarily empty queue`() { + // Command A was accepted at t=1,000 and the FIFO became empty. Command B arrives on the handler + // 5 ms later: it must still wait the remaining 70 ms rather than slipping through immediately. + assertEquals(70L, GattWritePacing.delayMs(nowMs = 1_005L, nextAllowedAtMs = 1_075L, requestedDelayMs = 0L)) + assertEquals(0L, GattWritePacing.delayMs(nowMs = 1_075L, nextAllowedAtMs = 1_075L, requestedDelayMs = 0L)) + assertEquals(50L, GattWritePacing.delayMs(nowMs = 2_000L, nextAllowedAtMs = 0L, requestedDelayMs = 50L)) + } +} diff --git a/android/app/src/test/java/com/noop/ble/OuraKeyCandidatesTest.kt b/android/app/src/test/java/com/noop/ble/OuraKeyCandidatesTest.kt new file mode 100644 index 0000000000..fad83b168f --- /dev/null +++ b/android/app/src/test/java/com/noop/ble/OuraKeyCandidatesTest.kt @@ -0,0 +1,47 @@ +package com.noop.ble + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test + +class OuraKeyCandidatesTest { + @Test + fun `current address and stable serial precede newest legacy rows without duplicates`() { + assertEquals( + listOf("oura-current", "oura-serial", "oura-active", "oura-newest", "oura-older"), + OuraKeyCandidates.preferredDeviceIds( + addressDeviceId = "oura-current", + serialDeviceId = "oura-serial", + currentDeviceId = "oura-active", + legacyIdsNewestFirst = listOf("oura-newest", "oura-active", "oura-older"), + ), + ) + } + + @Test + fun `preferred order is preserved while duplicates and malformed keys are removed`() { + val preferred = IntArray(16) { it } + val older = IntArray(16) { 0xA0 + it } + val normalized = OuraKeyCandidates.normalize( + listOf( + preferred, + preferred.copyOf(), + intArrayOf(1, 2, 3), + IntArray(16) { 256 }, + older, + ), + ) + + assertEquals(2, normalized.size) + assertArrayEquals(preferred, normalized[0]) + assertArrayEquals(older, normalized[1]) + } + + @Test + fun `normalized keys do not alias mutable preference results`() { + val source = IntArray(16) { it } + val normalized = OuraKeyCandidates.normalize(listOf(source)) + source[0] = 99 + assertEquals(0, normalized.single()[0]) + } +} diff --git a/android/app/src/test/java/com/noop/oura/OuraFeatureStatusTest.kt b/android/app/src/test/java/com/noop/oura/OuraFeatureStatusTest.kt index a62a868d90..0c497f545a 100644 --- a/android/app/src/test/java/com/noop/oura/OuraFeatureStatusTest.kt +++ b/android/app/src/test/java/com/noop/oura/OuraFeatureStatusTest.kt @@ -23,6 +23,22 @@ class OuraFeatureStatusTest { assertEquals(0x20, OuraCommands.realStepsReadStatus().bytes[2]) } + @Test + fun restingHRAutomaticConfigurationMatchesRing4Protocol() { + assertArrayEquals( + intArrayOf(0x2F, 0x03, 0x22, 0x08, 0x01), + OuraCommands.restingHRAutomatic().bytes, + ) + assertArrayEquals( + intArrayOf(0x2F, 0x03, 0x26, 0x08, 0x01), + OuraCommands.restingHRStateSubscribe().bytes, + ) + assertArrayEquals( + intArrayOf(0x2F, 0x02, 0x20, 0x08), + OuraCommands.restingHRReadStatus().bytes, + ) + } + @Test fun decodesTheFiveStatusBytes() { // The observed daytime-HR reply `2f 06 21 02 01 11 02 00` → sub-body `02 01 11 02 00`. @@ -44,7 +60,7 @@ class OuraFeatureStatusTest { } @Test - fun spo2AndStepsReadsSurfaceFeatureStatus() { + fun nonDaytimeReadsSurfaceFeatureStatus() { val d = OuraDriver(ringGen = OuraRingGen.GEN3, authKey = key) assertEquals( OuraDriver.SecureRouting.FeatureStatus(OuraFeatureStatus(0x04, 0, 0, 0, 0)), @@ -54,6 +70,10 @@ class OuraFeatureStatusTest { OuraDriver.SecureRouting.FeatureStatus(OuraFeatureStatus(0x0B, 0, 0, 0, 0)), d.handleSecureFrame(OuraSecureFrame(0x21, intArrayOf(0x0B, 0, 0, 0, 0))), ) + assertEquals( + OuraDriver.SecureRouting.FeatureStatus(OuraFeatureStatus(0x08, 1, 2, 2, 1)), + d.handleSecureFrame(OuraSecureFrame(0x21, intArrayOf(0x08, 1, 2, 2, 1))), + ) } @Test diff --git a/android/app/src/test/java/com/noop/oura/RingGenProductInfoTest.kt b/android/app/src/test/java/com/noop/oura/RingGenProductInfoTest.kt index cae685925c..9ddd487428 100644 --- a/android/app/src/test/java/com/noop/oura/RingGenProductInfoTest.kt +++ b/android/app/src/test/java/com/noop/oura/RingGenProductInfoTest.kt @@ -10,6 +10,22 @@ import org.junit.Test * generation comes from the GetProductInfo hardware id. */ class RingGenProductInfoTest { + @Test + fun `reset advertisement exposes stable serial without guessing generation`() { + assertEquals("4016082535033542", OuraIdentity.advertisedSerial("Oura 4016082535033542")) + assertEquals("2H3B2405003655", OuraIdentity.advertisedSerial("oura 2H3B2405003655")) + assertNull(OuraIdentity.advertisedSerial("Oura Ring 4")) + assertNull(OuraIdentity.advertisedSerial("Oura Horizon")) + assertNull(OuraIdentity.advertisedSerial("Oura Heritage")) + assertNull(OuraIdentity.advertisedSerial("WHOOP 5.0")) + } + + @Test + fun `serial-less post-setup advertisement is authenticated rather than rejected`() { + assertEquals(true, OuraIdentity.mayMatchExpectedSerial("4016082535033542", null)) + assertEquals(true, OuraIdentity.mayMatchExpectedSerial("4016082535033542", "4016082535033542")) + assertEquals(false, OuraIdentity.mayMatchExpectedSerial("4016082535033542", "2H3B2405003655")) + } @Test fun recogniseExplicitGenToken() { assertEquals(OuraRingGen.GEN3, OuraRingGen.recognise("Oura Ring Gen3")) @@ -32,6 +48,8 @@ class RingGenProductInfoTest { assertEquals(OuraRingGen.GEN3, OuraRingGen.fromHardwareId("BLB_03")) // validated on-device assertEquals(OuraRingGen.GEN4, OuraRingGen.fromHardwareId("BLB_04")) assertEquals(OuraRingGen.GEN5, OuraRingGen.fromHardwareId("BLB_05")) + assertEquals(OuraRingGen.GEN4, OuraRingGen.fromHardwareId("ORE_06")) // observed Ring 4 hardware id + assertEquals(OuraRingGen.GEN4, OuraRingGen.fromHardwareId("XXXXORE_06")) } @Test fun fromHardwareIdUnrecognisedYieldsNull() { diff --git a/android/app/src/test/java/com/noop/ui/OuraNativeHrvTest.kt b/android/app/src/test/java/com/noop/ui/OuraNativeHrvTest.kt new file mode 100644 index 0000000000..7aec233a71 --- /dev/null +++ b/android/app/src/test/java/com/noop/ui/OuraNativeHrvTest.kt @@ -0,0 +1,139 @@ +package com.noop.ui + +import com.noop.data.EventRow +import com.noop.data.OuraStreamMapping +import com.noop.data.SleepSession +import java.time.ZoneId +import org.junit.Assert.assertEquals +import org.junit.Test + +class OuraNativeHrvTest { + private val utc = ZoneId.of("UTC") + + @Test + fun `aggregates native buckets onto the sleep session wake day`() { + val sleep = SleepSession( + deviceId = "oura-ring-noop", + startTs = epoch("2026-08-08T23:00:00Z"), + endTs = epoch("2026-08-09T07:00:00Z"), + ) + val events = listOf( + hrv("2026-08-08T23:30:00Z", 42), + hrv("2026-08-09T06:30:00Z", 58), + ) + + val reading = OuraNativeHrv.aggregate(events, listOf(sleep), utc).single() + + assertEquals("2026-08-09", reading.day) + assertEquals(50.0, reading.valueMs, 1e-9) + assertEquals(2, reading.bucketCount) + assertEquals(epoch("2026-08-08T23:30:00Z"), reading.windowStartTs) + assertEquals(epoch("2026-08-09T06:35:00Z"), reading.windowEndTs) + } + + @Test + fun `ignores malformed invalid non hrv and retains out of sleep resting events`() { + val sleep = SleepSession( + deviceId = "oura-ring-noop", + startTs = epoch("2026-08-09T00:00:00Z"), + endTs = epoch("2026-08-09T08:00:00Z"), + ) + val events = listOf( + hrv("2026-08-09T01:00:00Z", 47), + hrv("2026-08-09T02:00:00Z", 0), + EventRow("oura-ring", epoch("2026-08-09T03:00:00Z"), OuraStreamMapping.EVENT_HRV, "not-json"), + EventRow("oura-ring", epoch("2026-08-09T04:00:00Z"), "OTHER", "{\"rmssd_ms\":99}"), + hrv("2026-08-09T12:00:00Z", 88), + ) + + val readings = OuraNativeHrv.aggregate(events, listOf(sleep), utc) + val reading = readings.first { it.day == "2026-08-09" } + + assertEquals(47.0, reading.valueMs, 1e-9) + assertEquals(1, reading.bucketCount) + assertEquals(88.0, readings.first { it.day == "2026-08-10" }.valueMs, 1e-9) + } + + @Test + fun `uses an edited onset and keeps separate wake days`() { + val first = SleepSession( + deviceId = "oura-ring-noop", + startTs = epoch("2026-08-07T23:30:00Z"), + startTsAdjusted = epoch("2026-08-07T22:30:00Z"), + endTs = epoch("2026-08-08T07:00:00Z"), + ) + val second = SleepSession( + deviceId = "oura-ring-noop", + startTs = epoch("2026-08-08T23:00:00Z"), + endTs = epoch("2026-08-09T07:00:00Z"), + ) + + val readings = OuraNativeHrv.aggregate( + listOf( + hrv("2026-08-07T22:45:00Z", 40), + hrv("2026-08-09T06:00:00Z", 60), + ), + listOf(first, second), + utc, + ) + + assertEquals(listOf("2026-08-08", "2026-08-09"), readings.map { it.day }) + assertEquals(listOf(40.0, 60.0), readings.map { it.valueMs }) + } + + @Test + fun `wake day uses the phone local zone rather than UTC`() { + val sleep = SleepSession( + deviceId = "oura-ring-noop", + startTs = epoch("2026-08-08T21:00:00Z"), + // 01:00 UTC on Aug 9 is still Aug 8 in New York. + endTs = epoch("2026-08-09T01:00:00Z"), + ) + + val reading = OuraNativeHrv.aggregate( + listOf(hrv("2026-08-09T00:30:00Z", 55)), + listOf(sleep), + ZoneId.of("America/New_York"), + ).single() + + assertEquals("2026-08-08", reading.day) + } + + @Test + fun `ring resting buckets use noon to noon wake days when sleep sessions are unavailable`() { + val readings = OuraNativeHrv.aggregate( + listOf( + hrv("2026-08-08T23:30:00Z", 40), + hrv("2026-08-09T06:30:00Z", 60), + hrv("2026-08-09T12:30:00Z", 80), + ), + emptyList(), + utc, + ) + + assertEquals(listOf("2026-08-09", "2026-08-10"), readings.map { it.day }) + assertEquals(listOf(50.0, 80.0), readings.map { it.valueMs }) + assertEquals(2, readings.first().bucketCount) + } + + @Test + fun `no-session wake day fallback uses phone local noon`() { + val reading = OuraNativeHrv.aggregate( + // 15:30 UTC is 11:30 in New York, so this still belongs to Aug 9's wake day. + listOf(hrv("2026-08-09T15:30:00Z", 55)), + emptyList(), + ZoneId.of("America/New_York"), + ).single() + + assertEquals("2026-08-09", reading.day) + } + + private fun hrv(iso: String, rmssd: Int) = EventRow( + deviceId = "oura-ring", + ts = epoch(iso), + kind = OuraStreamMapping.EVENT_HRV, + payloadJSON = "{\"rmssd_ms\":$rmssd}", + ) + + private fun epoch(iso: String): Long = java.time.Instant.parse(iso).epochSecond +} From ead8836bab51edc140c9842c16a261ba62233249 Mon Sep 17 00:00:00 2001 From: llmassisted Date: Tue, 25 Aug 2026 17:57:01 -0400 Subject: [PATCH 2/6] fix(oura): follow serial identity in health data flows --- .../src/main/java/com/noop/ui/AppViewModel.kt | 54 ++++++++++++++----- .../com/noop/ui/ActiveReadDeviceIdsTest.kt | 19 +++++++ 2 files changed, 60 insertions(+), 13 deletions(-) create mode 100644 android/app/src/test/java/com/noop/ui/ActiveReadDeviceIdsTest.kt diff --git a/android/app/src/main/java/com/noop/ui/AppViewModel.kt b/android/app/src/main/java/com/noop/ui/AppViewModel.kt index fd124cc94c..b9a3f9762d 100644 --- a/android/app/src/main/java/com/noop/ui/AppViewModel.kt +++ b/android/app/src/main/java/com/noop/ui/AppViewModel.kt @@ -56,10 +56,13 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -109,6 +112,14 @@ internal fun circadianBinsFrom( return bins to days.size } +/** + * Read-side device id for long-lived UI flows. Oura starts life under a rotating address id and adopts its + * stable serial after the first authenticated connection; the fallback covers startup/read failures, while + * distinct emissions make the address→serial handoff rebind Room collectors exactly once. + */ +internal fun activeReadDeviceIds(activeIds: Flow, fallback: String): Flow = + activeIds.map { it ?: fallback }.distinctUntilChanged() + class AppViewModel(app: Application) : AndroidViewModel(app) { /** Process-wide context for prefs + the background-connection service. */ @@ -452,6 +463,12 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { * recordings under a read pinned to the literal "my-whoop" (#814 twin of the Workouts screen). */ val deviceId = noopApp.activeDeviceId + /** Reactive counterpart to [deviceId]. This follows Oura's in-process address→serial adoption and any + * later active-device switch, so Health/Today never remain pinned to a namespace the writer left. */ + private val activeReadDeviceId: StateFlow = + activeReadDeviceIds(activeStrapIdFlow, deviceId) + .stateIn(viewModelScope, SharingStarted.Eagerly, deviceId) + /** Last (bins, daysObserved) computed on the collector pass. Reused by the SYNCHRONOUS settings * re-evaluate below, which has no coroutine to read the store from — without this, flipping the * cycle-tracking toggle would blank the body clock until the next collector tick. @@ -695,14 +712,12 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { * rows (from [IntelligenceEngine]) gap-fill, so recovery/strain/sleep populate from * the strap with no WHOOP import. */ - val recentDays: StateFlow> = - // #797: bound the dashboard merge window. The unbounded daysMergedFlow re-merged the WHOLE daily - // history on every DB change; a years-deep import made that a heavy refresh feeding Today / Trends / - // illness watch. recentDaysMergedFlow caps each source to RECENT_DAYS_CAP most-recent days first, so - // the merge stays bounded while every current surface (deepest Trends range, 7-day Fitness Age / - // Vitality windows) keeps its data. Same oldest-first ordering as before. - repository.recentDaysMergedFlow(deviceId) - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + // #797: bound the dashboard merge window. The unbounded daysMergedFlow re-merged the WHOLE daily + // history on every DB change; a years-deep import made that a heavy refresh feeding Today / Trends / + // illness watch. The collector in init rebinds this flow when Oura adopts its stable serial, while each + // selected source remains capped and oldest-first. + private val _recentDays = MutableStateFlow>(emptyList()) + val recentDays: StateFlow> = _recentDays.asStateFlow() /** * #103: SpO₂ candidate @82 nightly mean per day, loaded from the "spo2_candidate" metricSeries @@ -716,7 +731,7 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { else { val from = days.first().day val to = days.last().day - repository.metricSeriesComputedUnion(deviceId, "spo2_candidate", from, to) + repository.metricSeriesComputedUnion(activeReadDeviceId.value, "spo2_candidate", from, to) .associate { it.day to it.value } } }.flowOn(Dispatchers.IO) @@ -731,7 +746,9 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { val hrvOverCountByDay: StateFlow> = recentDays.map { days -> if (days.isEmpty()) emptyMap() - else repository.metricSeriesComputedUnion(deviceId, "hrv_rr_overcount", days.first().day, days.last().day) + else repository.metricSeriesComputedUnion( + activeReadDeviceId.value, "hrv_rr_overcount", days.first().day, days.last().day, + ) .associate { it.day to it.value } }.flowOn(Dispatchers.IO) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap()) @@ -792,6 +809,16 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { // existing WHOOP flow below runs unchanged; it only acts when a non-WHOOP strap is the active // device. The Devices screen (next task) calls onActiveDeviceChanged after a setActive. noopApp.sourceCoordinator.start() + // Rebind the dashboard/Health Room flow when the active id changes in-process. This is especially + // important for Oura: the first authenticated connection replaces its rotating address id with the + // stable serial and re-keys the rows. A collector pinned to the startup id then sees no computed + // daily rows even though decoding/persistence succeeded (the missing skin-temperature symptom). + viewModelScope.launch { + activeReadDeviceId.collectLatest { id -> + _recentDays.value = emptyList() + repository.recentDaysMergedFlow(id).collect { _recentDays.value = it } + } + } // #1410: on the first launch after an update, append an APP_VERSION_CHANGED event so a single // export can answer "what ran when". Idempotent — the stored last-seen version only advances // once the transition is recorded, so a background-only launch is caught on the next UI open. @@ -1017,7 +1044,7 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { IntelligenceEngine.runEffortRescoreIfNeeded( repo = repository, profile = currentProfile(), - importedDeviceId = deviceId, + importedDeviceId = activeReadDeviceId.value, maxHROverride = profileStore.hrMaxOverride.takeIf { it > 0 }?.toDouble(), flagGet = { NoopPrefs.effortRescoreDone(appContext) }, flagSet = { NoopPrefs.setEffortRescoreDone(appContext) }, @@ -1060,12 +1087,13 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { // attribution. Read the watermark ONCE — the gate and the log line must agree, and a second // read could straddle a concurrent write from a completing pass. val analyzeHasNewData = analyzeFp != NoopPrefs.analyzeWatermark(appContext) + val scoringDeviceId = activeReadDeviceId.value if (analyzeHasNewData) ble.externalLog("re-score: trigger=idle newData=yes") if (analyzeHasNewData) runCatching { IntelligenceEngine.analyzeRecent( repo = repository, profile = currentProfile(), - importedDeviceId = deviceId, + importedDeviceId = scoringDeviceId, maxHROverride = profileStore.hrMaxOverride .takeIf { it > 0 }?.toDouble(), // I2 read-through (Phase 1B-4): resolve the single owning device per day from the @@ -1175,7 +1203,7 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { // cycle just upserts. Never let an HC hiccup (perm revoked mid-flight, provider // update) break the analysis loop. if (_hcWriteback.value) { - runCatching { HealthConnectWriter.write(appContext, repository, deviceId) } + runCatching { HealthConnectWriter.write(appContext, repository, scoringDeviceId) } refreshHcWritebackStatus() // #660: reflect the outcome the writer just persisted } // 15-min backstop cadence, but wake EARLY on an app-resume kick (#386 self-heal) so a diff --git a/android/app/src/test/java/com/noop/ui/ActiveReadDeviceIdsTest.kt b/android/app/src/test/java/com/noop/ui/ActiveReadDeviceIdsTest.kt new file mode 100644 index 0000000000..a7ceeab2be --- /dev/null +++ b/android/app/src/test/java/com/noop/ui/ActiveReadDeviceIdsTest.kt @@ -0,0 +1,19 @@ +package com.noop.ui + +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test + +class ActiveReadDeviceIdsTest { + @Test + fun addressToSerialAdoptionRebindsOnceAndNullUsesStartupFallback() = runBlocking { + val ids = activeReadDeviceIds( + activeIds = flowOf(null, "oura-address", "oura-address", "oura-serial"), + fallback = "my-whoop", + ).toList() + + assertEquals(listOf("my-whoop", "oura-address", "oura-serial"), ids) + } +} From 4ce17a8d42afeae8bacf1e91733ffbbaf7e29ad9 Mon Sep 17 00:00:00 2001 From: llmassisted Date: Sat, 26 Sep 2026 14:11:57 -0400 Subject: [PATCH 3/6] fix(oura): localize HRV comparison strings and record Android-only parity - Move the 13 hardcoded Oura/NOOP HRV comparison strings in HealthScreen into string resources for all shipped locales; reuse existing keys for "ms", "Date", "Oura" and "Oura ring". - Drop OuraRestingWindows.sleepCandidates(); callers use windows().filter(isSleepCandidate). - Add platform_specific parity dispositions for the Android-only Oura restoration and refresh the twin map. Co-Authored-By: Claude Opus 5.5 (1M context) --- Tools/parity_dispositions.json | 192 ++++++++++++++++++ Tools/parity_twin_map.json | 16 +- .../com/noop/analytics/OuraRestingWindows.kt | 5 +- .../src/main/java/com/noop/ui/HealthScreen.kt | 46 ++--- .../app/src/main/res/values-de/strings.xml | 15 ++ .../app/src/main/res/values-es/strings.xml | 15 ++ .../app/src/main/res/values-fr/strings.xml | 15 ++ .../app/src/main/res/values-pl/strings.xml | 15 ++ .../src/main/res/values-pt-rPT/strings.xml | 15 ++ .../app/src/main/res/values-ru/strings.xml | 15 ++ .../app/src/main/res/values-zh/strings.xml | 15 ++ android/app/src/main/res/values/strings.xml | 15 ++ .../noop/analytics/OuraRestingWindowsTest.kt | 6 +- 13 files changed, 344 insertions(+), 41 deletions(-) diff --git a/Tools/parity_dispositions.json b/Tools/parity_dispositions.json index b1efd58add..28cb193303 100644 --- a/Tools/parity_dispositions.json +++ b/Tools/parity_dispositions.json @@ -80,6 +80,198 @@ "identity_sha256": "ecbc737217fc7e501b46226d4560ed9f23d2bd636faeef2f5e59e0a7274821e1", "platform": "swift", "rationale": "Twin of the Kotlin `val OuraSpO2.channel` in android/app/src/main/java/com/noop/oura/OuraEvents.kt. Both are one-line extension properties over OuraSpO2 returning OuraSpO2Channel, added together in the same change. Kotlin declares its copy as a TOP-LEVEL extension property with a dotted receiver, which the ledger's Kotlin property scan does not index, so the Swift side reads as one-sided. The pair is real; only one half is visible to the inventory." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-file", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/OuraRestingWindows.kt", + "identity_sha256": "80fce4df6bb0e7f03d74627806319c90395caf11b8c88eb6613954ece7e036ec", + "platform": "kotlin", + "rationale": "Restores the previously shipped Android Ring 4 adapter from durable Room OURA_HRV events to explicit sleep-window hints. This is an Android fork recovery path after PR #1 omitted its integration, not a new shared scoring formula. Apple does not consume this Android persistence adapter; the shared stage and HRV computations remain unchanged." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-constant", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/OuraRestingWindows.kt::BUCKET_SECONDS", + "identity_sha256": "6492d32deb585c76bdf04b37318a597246417aabd425f6135bbf5ec19db9ecb5", + "platform": "kotlin", + "rationale": "Restores the previously shipped Android Ring 4 adapter from durable Room OURA_HRV events to explicit sleep-window hints. This is an Android fork recovery path after PR #1 omitted its integration, not a new shared scoring formula. Apple does not consume this Android persistence adapter; the shared stage and HRV computations remain unchanged." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-constant", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/OuraRestingWindows.kt::MAX_GAP_SECONDS", + "identity_sha256": "196ccdc15b8f66a20ebb4c3064011601678e9c2e714d20158ed563655a2cdf1f", + "platform": "kotlin", + "rationale": "Restores the previously shipped Android Ring 4 adapter from durable Room OURA_HRV events to explicit sleep-window hints. This is an Android fork recovery path after PR #1 omitted its integration, not a new shared scoring formula. Apple does not consume this Android persistence adapter; the shared stage and HRV computations remain unchanged." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-constant", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/OuraRestingWindows.kt::MIN_AUTOMATIC_SLEEP_SECONDS", + "identity_sha256": "da5893ae53af16d21b7241e51235860fd2297d60247cfa155a26a9c4d8ee8e25", + "platform": "kotlin", + "rationale": "Restores the previously shipped Android Ring 4 adapter from durable Room OURA_HRV events to explicit sleep-window hints. This is an Android fork recovery path after PR #1 omitted its integration, not a new shared scoring formula. Apple does not consume this Android persistence adapter; the shared stage and HRV computations remain unchanged." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/OuraRestingWindows.kt::windows/1#1", + "identity_sha256": "aee030bf076669b51ac3c9125e2777013e7888aefdb12ad54f08b641604d66bf", + "platform": "kotlin", + "rationale": "Restores the previously shipped Android Ring 4 adapter from durable Room OURA_HRV events to explicit sleep-window hints. This is an Android fork recovery path after PR #1 omitted its integration, not a new shared scoring formula. Apple does not consume this Android persistence adapter; the shared stage and HRV computations remain unchanged." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/OuraRestingWindows.kt::isSleepCandidate/1#1", + "identity_sha256": "875eeee4ac9567dceacbf4a8ea04af2d4073833a50f8964115740c0843c2de3b", + "platform": "kotlin", + "rationale": "Restores the previously shipped Android Ring 4 adapter from durable Room OURA_HRV events to explicit sleep-window hints. This is an Android fork recovery path after PR #1 omitted its integration, not a new shared scoring formula. Apple does not consume this Android persistence adapter; the shared stage and HRV computations remain unchanged." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-property", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/OuraRestingWindows.kt::durationSeconds@property#1", + "identity_sha256": "9f63d3ef831549c78c49bd09b664e12ea33a3e68564ece5d4253e76427d91394", + "platform": "kotlin", + "rationale": "Restores the previously shipped Android Ring 4 adapter from durable Room OURA_HRV events to explicit sleep-window hints. This is an Android fork recovery path after PR #1 omitted its integration, not a new shared scoring formula. Apple does not consume this Android persistence adapter; the shared stage and HRV computations remain unchanged." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt::sleepWindowHint/1#1", + "identity_sha256": "6aeb51bdc57f027ab91c04fa4daa71141c701968e1ad5ec98ed5355b30a22145", + "platform": "kotlin", + "rationale": "Restores the previously shipped Android Ring 4 adapter from durable Room OURA_HRV events to explicit sleep-window hints. This is an Android fork recovery path after PR #1 omitted its integration, not a new shared scoring formula. Apple does not consume this Android persistence adapter; the shared stage and HRV computations remain unchanged." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt::detectorIndependentSleepForDay/7#1", + "identity_sha256": "e248be8c76fd11321f7150b66b159ea27aaedb274f96f1cf8e7a0a39129be5c7", + "platform": "kotlin", + "rationale": "Restores the previously shipped Android Ring 4 adapter from durable Room OURA_HRV events to explicit sleep-window hints. This is an Android fork recovery path after PR #1 omitted its integration, not a new shared scoring formula. Apple does not consume this Android persistence adapter; the shared stage and HRV computations remain unchanged." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt::resolveSleepEvidence/12#1", + "identity_sha256": "ed42f64dd06d13e6343abc7aeb86b83d776ba55666edcd9db48acdebb414d762", + "platform": "kotlin", + "rationale": "Restores the previously shipped Android Ring 4 adapter from durable Room OURA_HRV events to explicit sleep-window hints. This is an Android fork recovery path after PR #1 omitted its integration, not a new shared scoring formula. Apple does not consume this Android persistence adapter; the shared stage and HRV computations remain unchanged." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt::belongsToThisWakeDay/1#1", + "identity_sha256": "f72c61064137a57fa6155072dcb5f6f84259d8e4b1780cb9d77639406644659d", + "platform": "kotlin", + "rationale": "Restores the previously shipped Android Ring 4 adapter from durable Room OURA_HRV events to explicit sleep-window hints. This is an Android fork recovery path after PR #1 omitted its integration, not a new shared scoring formula. Apple does not consume this Android persistence adapter; the shared stage and HRV computations remain unchanged." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt::addIfDisjoint/1#1", + "identity_sha256": "eeda61e4e8d466a62605782a6220db70a61e5dbde0c7ce0686a62c6d9aea9bc6", + "platform": "kotlin", + "rationale": "Restores the previously shipped Android Ring 4 adapter from durable Room OURA_HRV events to explicit sleep-window hints. This is an Android fork recovery path after PR #1 omitted its integration, not a new shared scoring formula. Apple does not consume this Android persistence adapter; the shared stage and HRV computations remain unchanged." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt::analyzeDay/34#1", + "identity_sha256": "b282e974d09eb5a7cda4f3b9bc682ac4bd9cb0f3ac2b603c8edd4dd1ca7f199f", + "platform": "kotlin", + "rationale": "Android fork overload restores the optional sleepWindowHints input used by the Ring 4 Room/native-resting and manual-sleep integration. An empty hint list preserves the common detector path. The extra input is Android-specific; it does not replace the shared scoring algorithms or claim an Apple implementation." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/SleepStageHealer.kt::restageFromRaw/7#1", + "identity_sha256": "b0a2f726614c8b740fd979119971bdc268c9e2c2279b2806af4acd3084262bba", + "platform": "kotlin", + "rationale": "Android fork manual-sleep repair adds the default-false allowCardioOnly option for user-supplied Ring 4 bounds, whose source has no continuous gravity stream. Other callers retain the motion-density gate. This restores the Android UI-to-Room editing workflow; no corresponding Apple edit flow is changed." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/SleepStageHealer.kt::restageFromSamples/10#1", + "identity_sha256": "13a4c89390e4801c569808de9a137908af186a24b47cb0950102bf76558f396e", + "platform": "kotlin", + "rationale": "Android fork manual-sleep repair adds the default-false allowCardioOnly option for user-supplied Ring 4 bounds, whose source has no continuous gravity stream. Other callers retain the motion-density gate. This restores the Android UI-to-Room editing workflow; no corresponding Apple edit flow is changed." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/data/WhoopRepository.kt::eventsByKind/5#1", + "identity_sha256": "60270df2a02d7abe16662d47a2816a214c3ca521e767aabcc411b4644af80faf", + "platform": "kotlin", + "rationale": "Android Room suspend-query facade for the restored native-HRV comparison UI. It exposes the existing eventsByKind DAO query without a schema change or a new stored value. Apple persistence uses GRDB and does not share this Room facade." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-file", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/oura/OuraIdentity.kt", + "identity_sha256": "f08ffc6928ec050c09e23f86b925256634e208e60659fbcc4f0228184d477fd1", + "platform": "kotlin", + "rationale": "Android BluetoothDevice addresses rotate for Oura Ring 4, so the restored scanner uses the advertised serial where available and authenticates candidate devices with locally stored keys. These Android advertisement-selection helpers do not apply to CoreBluetooth peripheral UUID identity on Apple." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/oura/OuraIdentity.kt::advertisedSerial/1#1", + "identity_sha256": "d60ed0486c49597ec92252412a360a197fddf3ae1d058f725ba3e4beed45e91f", + "platform": "kotlin", + "rationale": "Android BluetoothDevice addresses rotate for Oura Ring 4, so the restored scanner uses the advertised serial where available and authenticates candidate devices with locally stored keys. These Android advertisement-selection helpers do not apply to CoreBluetooth peripheral UUID identity on Apple." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/oura/OuraIdentity.kt::isPlausibleSerial/1#1", + "identity_sha256": "a02386ed82c1df960462e50bef5616a0b758263900f2955a49975569233757a4", + "platform": "kotlin", + "rationale": "Android BluetoothDevice addresses rotate for Oura Ring 4, so the restored scanner uses the advertised serial where available and authenticates candidate devices with locally stored keys. These Android advertisement-selection helpers do not apply to CoreBluetooth peripheral UUID identity on Apple." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/oura/OuraIdentity.kt::mayMatchExpectedSerial/2#1", + "identity_sha256": "14ca6e30ae505c690417b9bef9f022b20c454499ac95732d0cdce87f2e535dc2", + "platform": "kotlin", + "rationale": "Android BluetoothDevice addresses rotate for Oura Ring 4, so the restored scanner uses the advertised serial where available and authenticates candidate devices with locally stored keys. These Android advertisement-selection helpers do not apply to CoreBluetooth peripheral UUID identity on Apple." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-constant", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/oura/Commands.kt::featureRestingHR", + "identity_sha256": "f9e938afb6c3bcfba291bd189b7789b9aa31b67f5892fb0abaaff3dc3462d174", + "platform": "kotlin", + "rationale": "Restores the previous Android fork session setup for Oura resting-HR automatic mode, state subscription, and readback. These command constructors support Android foreground/live-HR lifecycle and overnight collection recovery; no Apple session setup is modified or represented as equivalent by this repair." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/oura/Commands.kt::restingHRAutomatic/0#1", + "identity_sha256": "3f0c9f30ec509766d766835cfadb1272fe4d6aafd9c48d38281da138c4abf216", + "platform": "kotlin", + "rationale": "Restores the previous Android fork session setup for Oura resting-HR automatic mode, state subscription, and readback. These command constructors support Android foreground/live-HR lifecycle and overnight collection recovery; no Apple session setup is modified or represented as equivalent by this repair." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/oura/Commands.kt::restingHRStateSubscribe/0#1", + "identity_sha256": "faf643badebb38e6a65bd6ce76c529243fcd4074a3e25fb6474ee5defe68890a", + "platform": "kotlin", + "rationale": "Restores the previous Android fork session setup for Oura resting-HR automatic mode, state subscription, and readback. These command constructors support Android foreground/live-HR lifecycle and overnight collection recovery; no Apple session setup is modified or represented as equivalent by this repair." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/oura/Commands.kt::restingHRReadStatus/0#1", + "identity_sha256": "c0269f58f2f76216709dc2f39e51be77ce5f622abfbac91be7fbb3a1c2567972", + "platform": "kotlin", + "rationale": "Restores the previous Android fork session setup for Oura resting-HR automatic mode, state subscription, and readback. These command constructors support Android foreground/live-HR lifecycle and overnight collection recovery; no Apple session setup is modified or represented as equivalent by this repair." } ] } diff --git a/Tools/parity_twin_map.json b/Tools/parity_twin_map.json index e63b734522..8a83781760 100644 --- a/Tools/parity_twin_map.json +++ b/Tools/parity_twin_map.json @@ -18,17 +18,17 @@ ] }, "authority": { - "files": {"count": 502, "sha256": "322bb433fab56fef5ec926d7847faef78e4c64ea409d161184baaf483c8eec95"}, - "functions": {"count": 4479, "sha256": "bdb69394501d448ac150e19fa571eb18999e54ee7312d4c5517367d35926a419"}, - "properties": {"count": 463, "sha256": "7d80508acfd6418a9f1dc099d65a6890e9b663fb9b056ac2e939b0e0e42d488e"}, - "constants": {"count": 1957, "sha256": "22820a60a99ee0bf0e9dbaf4f56e0416a445c552d8e05479c8cb09b47cf397a4"}, + "files": {"count": 504, "sha256": "437bdd89fb050111f11a2229768e5344d04115c91f5cc7670b27890f097d851f"}, + "functions": {"count": 4493, "sha256": "2f38675dc9ca7f4a0eb4f559c1c4a1323f04ad63b6ec00ddbb373f79c6d71feb"}, + "properties": {"count": 464, "sha256": "85338b70168a36af785255eb8959a41272cc02334529c2e3ef2a7d18444c7ef7"}, + "constants": {"count": 1961, "sha256": "047b864ba74c9ab725c498bc892b47e1d09f4f923871c348b8adb9a3b80a83ea"}, "file_pairs": {"count": 74, "sha256": "1023813dc72dbe4122281d5bae27ac2b7a657dae5904cb5fd603032928199f94"}, "function_pairs": {"count": 189, "sha256": "11df8bf87b16b535aeddd0c00471f87431236fd0b059484c07f6d5939ace9732"}, "property_pairs": {"count": 149, "sha256": "8a62616eca6270e3d8899c47cfe8ae93691f16846ed1959e19a762827b6ec185"}, "constant_pairs": {"count": 681, "sha256": "3484c8096ac8d2832e75f18900c052a19bfb7d92cbc2fa7fdb6137d8fa46ebf6"}, - "unpaired_files": {"count": 376, "sha256": "654d951b8137dbb2266b83734fa1e3f1cb1648f15c1aab3d015cdef7e79deb75"}, - "unpaired_functions": {"count": 4113, "sha256": "9455f235143adef089d343d45a1b43e10445953b866afb351dfa1ab9b27b4d5b"}, - "unpaired_properties": {"count": 165, "sha256": "fe609dd25384cda6de1616d36753fed68431b1a1f9a73b6fd664c7ff35089955"}, - "unpaired_constants": {"count": 595, "sha256": "f065b8e34db78569a84449444539956cb48df105c9b66274398422cf8c520bc7"} + "unpaired_files": {"count": 378, "sha256": "468445b59f27e4f2b277d58c119d76d7364038f51f50b1ce9053f1b7f514246e"}, + "unpaired_functions": {"count": 4127, "sha256": "efbc502b2276f1b9276bceb049a42e0f38f8cb235d3336a7d9b3e544a59cdf28"}, + "unpaired_properties": {"count": 166, "sha256": "280d96f0905fd623dfb66af86c99c3e82409b126a45aaf9e008eb8f6cf04671a"}, + "unpaired_constants": {"count": 599, "sha256": "ab83879bb6465fa0e5934592c0065ada37758d099fc41ae476c2d8a1ad55dca7"} } } diff --git a/android/app/src/main/java/com/noop/analytics/OuraRestingWindows.kt b/android/app/src/main/java/com/noop/analytics/OuraRestingWindows.kt index bd8842138a..065797ee74 100644 --- a/android/app/src/main/java/com/noop/analytics/OuraRestingWindows.kt +++ b/android/app/src/main/java/com/noop/analytics/OuraRestingWindows.kt @@ -18,7 +18,7 @@ data class OuraRestingWindow( /** * Converts durable Oura 0x5D events into resting windows without claiming that every rest is sleep. * - * Every valid bucket is retained in [windows], including short awake rest and naps. [sleepCandidates] + * Every valid bucket is retained in [windows], including short awake rest and naps. [isSleepCandidate] * is deliberately narrower: in the absence of the ring's SleepNet phases, only a long continuous block * is strong enough to supply an automatic in-bed boundary. Short naps are recorded through NOOP's * explicit bedtime/wake or add-nap paths; a 40-minute awake resting block must not become fake sleep. @@ -78,7 +78,4 @@ object OuraRestingWindows { // Tolerate an occasional missing native bucket, but do not join a sparse scatter of resting // values into one claimed sleep block merely because every individual gap stayed under 15 min. window.bucketCount * BUCKET_SECONDS * 10L >= window.durationSeconds * 7L - - fun sleepCandidates(events: List): List = - windows(events).filter(::isSleepCandidate) } diff --git a/android/app/src/main/java/com/noop/ui/HealthScreen.kt b/android/app/src/main/java/com/noop/ui/HealthScreen.kt index 964d8c1dd6..5b2a3f6947 100644 --- a/android/app/src/main/java/com/noop/ui/HealthScreen.kt +++ b/android/app/src/main/java/com/noop/ui/HealthScreen.kt @@ -302,7 +302,7 @@ private fun SyncStatusSection(vm: AppViewModel, onSyncNow: () -> Unit) { showsDot = false, ) !activeIsOura && !live.bonded -> StatePill( - title = "Finishing pairing", + title = uiString(R.string.oura_hrv_pairing), tone = StrandTone.Neutral, showsDot = true, pulsing = true, @@ -583,7 +583,7 @@ private fun HealthContributorsSection(day: DailyMetric?) { // a staggered fade+rise, mirroring iOS `.staggeredAppear(index:)`. ContributorBar( label = "HRV", - readout = hrv?.let { "${it.roundToInt()} ms" } ?: "—", + readout = hrv?.let { uiString(R.string.today_driver_value_ms, it.roundToInt()) } ?: "—", fraction = hrv?.let { (it - 20.0) / 100.0 }, color = Palette.metricCyan, modifier = Modifier.staggeredAppear(0), @@ -2040,7 +2040,7 @@ fun VitalDetailScreen(vm: AppViewModel, key: String) { if (key == "hrv" && !ouraHrvLoaded) { DataPendingNote( title = uiString(R.string.l10n_health_screen_loading_33ce4174), - body = "Fetching the ring's native HRV history.", + body = uiString(R.string.oura_hrv_loading), ) return@ScreenScaffold } @@ -2052,8 +2052,8 @@ fun VitalDetailScreen(vm: AppViewModel, key: String) { HrvSourcesCard(noopComputedHrv, ouraHrv) HrvComparisonTable(noopComputedHrv, ouraHrv) DataPendingNote( - title = "NOOP HRV trend is still calibrating", - body = "The resting-window comparison is available above. NOOP's nightly trend still needs detected sleep sessions.", + title = uiString(R.string.oura_hrv_calibrating), + body = uiString(R.string.oura_hrv_rest_available), ) return@ScreenScaffold } @@ -2518,9 +2518,9 @@ private fun HrvSourcesCard( NoopCard { Column(verticalArrangement = Arrangement.spacedBy(Metrics.space12)) { Row(verticalAlignment = Alignment.CenterVertically) { - Overline("HRV sources", modifier = Modifier.weight(1f)) + Overline(uiString(R.string.oura_hrv_sources), modifier = Modifier.weight(1f)) Text( - if (sameNight) vitalReadingDateLabel(oura.day) else "Latest available", + if (sameNight) vitalReadingDateLabel(oura.day) else uiString(R.string.oura_hrv_latest), style = NoopType.caption, color = Palette.textTertiary, ) @@ -2528,14 +2528,14 @@ private fun HrvSourcesCard( Row(horizontalArrangement = Arrangement.spacedBy(Metrics.space12)) { HrvSourceValue( modifier = Modifier.weight(1f), - label = "NOOP computed", + label = uiString(R.string.oura_hrv_computed), value = noop?.value, day = noop?.day, tint = Palette.metricPurple, ) HrvSourceValue( modifier = Modifier.weight(1f), - label = "Oura ring", + label = uiString(R.string.l10n_add_device_wizard_oura_ring_e3431536), value = oura.valueMs, day = oura.day, tint = Palette.metricCyan, @@ -2543,14 +2543,9 @@ private fun HrvSourcesCard( } Text( if (noop?.fromRestingWindow == true) { - "NOOP computes RMSSD from stored intervals in the same ring-provided resting window. " + - "This comparison does not feed Charge until NOOP has a detected sleep session. " + - "Oura ring is the mean of ${oura.bucketCount} native five-minute RMSSD " + - if (oura.bucketCount == 1) "bucket." else "buckets." + uiString(R.string.oura_hrv_resting_detail, oura.bucketCount) } else { - "NOOP computes RMSSD from stored intervals and remains the value used by Charge. " + - "Oura ring is the mean of ${oura.bucketCount} native five-minute RMSSD " + - if (oura.bucketCount == 1) "bucket." else "buckets." + uiString(R.string.oura_hrv_nightly_detail, oura.bucketCount) }, style = NoopType.footnote, color = Palette.textSecondary, @@ -2570,12 +2565,12 @@ private fun HrvSourceValue( Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(Metrics.space4)) { Overline(label, color = Palette.textTertiary) Text( - value?.let { "${it.roundToInt()} ms" } ?: "—", + value?.let { uiString(R.string.today_driver_value_ms, it.roundToInt()) } ?: "—", style = NoopType.metricInline, color = if (value != null) tint else Palette.textTertiary, ) Text( - day?.let(::vitalReadingDateLabel) ?: "No computed night", + day?.let(::vitalReadingDateLabel) ?: uiString(R.string.oura_hrv_no_night), style = NoopType.caption, color = Palette.textTertiary, ) @@ -2594,9 +2589,9 @@ private fun HrvComparisonTable( val noopByDay = noopReadings.associateBy { it.day } NoopCard { Column(verticalArrangement = Arrangement.spacedBy(Metrics.space10)) { - Overline("HRV history") + Overline(uiString(R.string.oura_hrv_history)) Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Text("Date", style = NoopType.footnote, color = Palette.textSecondary, modifier = Modifier.weight(1f)) + Text(uiString(R.string.l10n_health_screen_date_eb9a4bc1), style = NoopType.footnote, color = Palette.textSecondary, modifier = Modifier.weight(1f)) Text( "NOOP", style = NoopType.footnote, @@ -2605,14 +2600,14 @@ private fun HrvComparisonTable( modifier = Modifier.weight(0.8f), ) Text( - "Oura", + uiString(R.string.today_source_oura), style = NoopType.footnote, color = Palette.textSecondary, textAlign = TextAlign.End, modifier = Modifier.weight(0.8f), ) Text( - "Buckets", + uiString(R.string.oura_hrv_samples), style = NoopType.footnote, color = Palette.textSecondary, textAlign = TextAlign.End, @@ -2629,14 +2624,14 @@ private fun HrvComparisonTable( modifier = Modifier.weight(1f), ) Text( - noop?.let { "${it.value.roundToInt()} ms" } ?: "—", + noop?.let { uiString(R.string.today_driver_value_ms, it.value.roundToInt()) } ?: "—", style = NoopType.bodyNumber, color = if (noop != null) Palette.metricPurple else Palette.textTertiary, textAlign = TextAlign.End, modifier = Modifier.weight(0.8f), ) Text( - "${reading.valueMs.roundToInt()} ms", + uiString(R.string.today_driver_value_ms, reading.valueMs.roundToInt()), style = NoopType.bodyNumber, color = Palette.metricCyan, textAlign = TextAlign.End, @@ -2660,8 +2655,7 @@ private fun HrvComparisonTable( } } Text( - "Buckets are Oura's five-minute resting measurements. NOOP values use the matching " + - "stored R-R intervals; — means there were not enough clean intervals.", + uiString(R.string.oura_hrv_table_note), style = NoopType.footnote, color = Palette.textTertiary, ) diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 9dd40e58bd..380b6952a3 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -2833,4 +2833,19 @@ Koppelt sich direkt über Bluetooth mit deinem Strap: keine WHOOP-App, keine Cloud. Der Strap kann seine Herzfrequenz außerdem als Standard-Bluetooth-Sensor senden. Sendet die eigene Live-Herzfrequenz des Straps über Bluetooth für Garmin, Zwift oder Fitnessgeräte. Dein WHOOP 4.0 kann die Herzfrequenz direkt übertragen. Öffne Datenquellen und verwende „Herzfrequenz vom Band übertragen“ für Zwift, Peloton, Garmin oder Fitnessgeräte. Bei Bedarf kannst du alternativ „HF von diesem Telefon übertragen“ verwenden. + + + Neueste verfügbare Werte + Von NOOP berechnet + Keine berechnete Nacht + Messwerte + HRV-Quellen + HRV-Verlauf + NOOP-HRV wird noch kalibriert + Oura-HRV-Verlauf wird geladen. + Der Vergleich des Ruhezeitraums wird oben angezeigt. Der nächtliche Verlauf benötigt erkannten Schlaf. + Kopplung wird abgeschlossen + NOOP berechnet die HRV für denselben Ruhezeitraum. Sie beeinflusst Charge erst, wenn Schlaf erkannt wurde. Oura mittelt %1$d Fünf-Minuten-Messungen. + NOOP berechnet die HRV für Charge aus gespeicherten Schlagintervallen. Oura mittelt %1$d Fünf-Minuten-Messungen. + Jeder Oura-Messwert umfasst fünf Minuten Ruhe. NOOP verwendet die entsprechenden Schlagintervalle. — bedeutet zu wenige bereinigte Daten. diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index d94fbef93a..6d711b106e 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -2820,4 +2820,19 @@ Se empareja directamente con tu pulsera por Bluetooth: sin app de WHOOP, sin nube. La pulsera también puede anunciar su frecuencia cardíaca como un sensor Bluetooth estándar. Difunde por Bluetooth la frecuencia cardíaca en vivo de la propia pulsera para Garmin, Zwift o equipos de gimnasio. Tu WHOOP 4.0 puede transmitir la frecuencia cardíaca directamente. Abre Fuentes de datos y usa «Transmitir frecuencia cardíaca desde la pulsera» para Zwift, Peloton, Garmin o equipos de gimnasio. También puedes usar «Transmitir FC desde este teléfono» cuando sea necesario. + + + Últimos datos disponibles + Calculado por NOOP + Ninguna noche calculada + Muestras + Fuentes de VFC + Historial de VFC + La VFC de NOOP aún se está calibrando + Cargando el historial de VFC de Oura. + La comparación del periodo de reposo aparece arriba. La tendencia nocturna requiere sueño detectado. + Finalizando el emparejamiento + NOOP calcula la VFC del mismo periodo de reposo. No afecta a Charge hasta que se detecta sueño. Oura promedia %1$d mediciones de cinco minutos. + NOOP calcula la VFC para Charge a partir de los intervalos entre latidos almacenados. Oura promedia %1$d mediciones de cinco minutos. + Cada muestra de Oura abarca cinco minutos de reposo. NOOP usa los intervalos entre latidos correspondientes. — indica datos válidos insuficientes. diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index 520a36a4dc..844749bf44 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -2819,4 +2819,19 @@ S\'associe directement à votre bracelet par Bluetooth : pas d\'app WHOOP, pas de cloud. Le bracelet peut aussi annoncer sa fréquence cardiaque comme capteur Bluetooth standard. Diffuse en Bluetooth la fréquence cardiaque en direct propre au bracelet pour Garmin, Zwift ou les équipements de sport. Votre WHOOP 4.0 peut diffuser directement la fréquence cardiaque. Ouvrez Sources de données et utilisez « Diffuser la fréquence cardiaque du bracelet » pour Zwift, Peloton, Garmin ou les équipements de sport. Vous pouvez aussi utiliser « Diffuser la FC depuis ce téléphone » si nécessaire. + + + Dernières valeurs disponibles + Calculé par NOOP + Aucune nuit calculée + Mesures + Sources de VFC + Historique de VFC + La VFC NOOP est encore en cours de calibration + Chargement de l’historique de VFC Oura. + La comparaison de la période de repos apparaît ci-dessus. La tendance nocturne nécessite du sommeil détecté. + Finalisation de l’appairage + NOOP calcule la VFC sur la même période de repos. Elle ne contribue à Charge que lorsque du sommeil est détecté. Oura fait la moyenne de %1$d mesures de cinq minutes. + NOOP calcule la VFC pour Charge à partir des intervalles entre battements enregistrés. Oura fait la moyenne de %1$d mesures de cinq minutes. + Chaque mesure Oura couvre cinq minutes de repos. NOOP utilise les intervalles entre battements correspondants. — indique un manque de données valides. diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml index 2d742c87f5..7683c5c72e 100644 --- a/android/app/src/main/res/values-pl/strings.xml +++ b/android/app/src/main/res/values-pl/strings.xml @@ -2834,4 +2834,19 @@ Łączy się bezpośrednio z paskiem przez Bluetooth: bez aplikacji WHOOP i bez chmury. Pasek może też nadawać tętno jako standardowy czujnik Bluetooth. Transmituje własne tętno paska na żywo przez Bluetooth do Garmin, Zwift lub sprzętu na siłowni. WHOOP 4.0 może nadawać tętno bezpośrednio. Otwórz Źródła danych i użyj opcji „Transmituj tętno z opaski” dla Zwift, Peloton, Garmin lub sprzętu na siłowni. W razie potrzeby możesz też użyć opcji „Transmituj tętno z tego telefonu”. + + + Najnowsze dostępne dane + Obliczone przez NOOP + Brak obliczeń dla nocy + Pomiary + Źródła HRV + Historia HRV + Kalibracja HRV w NOOP nadal trwa + Wczytywanie historii HRV Oura. + Porównanie okresu odpoczynku jest widoczne powyżej. Trend nocny wymaga wykrytego snu. + Kończenie parowania + NOOP oblicza HRV dla tego samego okresu odpoczynku. Wynik wpływa na Charge dopiero po wykryciu snu. Oura oblicza średnią z pomiarów pięciominutowych; ich liczba: %1$d. + NOOP oblicza HRV dla Charge z zapisanych odstępów między uderzeniami serca. Oura oblicza średnią z pomiarów pięciominutowych; ich liczba: %1$d. + Każdy pomiar Oura obejmuje pięć minut odpoczynku. NOOP używa odpowiadających mu odstępów między uderzeniami serca. — oznacza za mało poprawnych danych. diff --git a/android/app/src/main/res/values-pt-rPT/strings.xml b/android/app/src/main/res/values-pt-rPT/strings.xml index 6110f3719b..da625f3a3f 100644 --- a/android/app/src/main/res/values-pt-rPT/strings.xml +++ b/android/app/src/main/res/values-pt-rPT/strings.xml @@ -2812,4 +2812,19 @@ Emparelha diretamente com a tua bracelete via Bluetooth: sem aplicação WHOOP, sem cloud. A bracelete também pode anunciar a frequência cardíaca como um sensor Bluetooth padrão. Transmite a frequência cardíaca em direto da bracelete por Bluetooth para Garmin, Zwift ou equipamento de ginásio. O teu WHOOP 4.0 pode transmitir a frequência cardíaca diretamente. Abre Fontes de dados e utiliza «Transmitir frequência cardíaca da bracelete» para Zwift, Peloton, Garmin ou equipamento de ginásio. Em alternativa, podes utilizar «Transmitir FC deste telemóvel» quando necessário. + + + Últimos valores disponíveis + Calculado pelo NOOP + Nenhuma noite calculada + Amostras + Fontes de VFC + Histórico de VFC + A VFC do NOOP ainda está em calibração + A carregar o histórico de VFC do Oura. + A comparação do período de repouso aparece acima. A tendência noturna necessita de sono detetado. + A concluir o emparelhamento + O NOOP calcula a VFC para o mesmo período de repouso. Não afeta o Charge até ser detetado sono. O Oura calcula a média de %1$d medições de cinco minutos. + O NOOP calcula a VFC para o Charge a partir dos intervalos entre batimentos guardados. O Oura calcula a média de %1$d medições de cinco minutos. + Cada amostra do Oura abrange cinco minutos em repouso. O NOOP utiliza os intervalos entre batimentos correspondentes. — indica dados válidos insuficientes. diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index bc8220829f..e742c518e8 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -2713,4 +2713,19 @@ Подключается к браслету напрямую по Bluetooth: без приложения WHOOP и без облака. Браслет также может передавать пульс как стандартный Bluetooth-датчик. Передаёт собственный пульс браслета в реальном времени по Bluetooth для Garmin, Zwift или тренажёров. WHOOP 4.0 может передавать пульс напрямую. Откройте «Источники данных» и включите «Транслировать пульс с браслета» для Zwift, Peloton, Garmin или тренажёров. При необходимости можно использовать «Передавать пульс с этого телефона». + + + Последние доступные данные + Рассчитано NOOP + Нет расчёта за ночь + Измерения + Источники ВСР + История ВСР + Калибровка ВСР NOOP ещё продолжается + Загрузка истории ВСР Oura. + Сравнение за период покоя показано выше. Для ночного тренда нужен обнаруженный сон. + Завершение сопряжения + NOOP рассчитывает ВСР за тот же период покоя. Результат влияет на Charge только после обнаружения сна. Oura усредняет пятиминутные измерения; их количество: %1$d. + NOOP рассчитывает ВСР для Charge по сохранённым интервалам между ударами сердца. Oura усредняет пятиминутные измерения; их количество: %1$d. + Каждое измерение Oura охватывает пять минут покоя. NOOP использует соответствующие интервалы между ударами сердца. — означает, что достоверных данных недостаточно. diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml index 5d5eea0d47..c7ace37ecc 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -2791,4 +2791,19 @@ 通过 Bluetooth 直接连接手环:无需 WHOOP 应用,也无需云端。手环还可以将心率作为标准 Bluetooth 传感器进行广播。 通过 Bluetooth 将手环自身的实时心率广播给 Garmin、Zwift 或健身器材。 WHOOP 4.0 可以直接广播心率。打开“数据来源”,启用“从手环广播心率”,即可连接 Zwift、Peloton、Garmin 或健身器材。需要时也可以使用“从此手机广播心率”。 + + + 最新可用数据 + NOOP 计算值 + 暂无夜间计算结果 + 样本 + HRV 数据来源 + HRV 历史 + NOOP HRV 仍在校准 + 正在加载 Oura HRV 历史。 + 上方显示了同一静息时段的对比。夜间趋势需要先检测到睡眠。 + 正在完成配对 + NOOP 根据同一静息时段计算 HRV。检测到睡眠后,该结果才会计入 Charge。Oura 取 %1$d 个五分钟测量值的平均值。 + NOOP 根据已保存的心搏间期计算用于 Charge 的 HRV。Oura 取 %1$d 个五分钟测量值的平均值。 + 每个 Oura 样本覆盖五分钟静息时段。NOOP 使用对应的心搏间期。— 表示有效数据不足。 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 62d321fced..cb283bc740 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2849,4 +2849,19 @@ Pairs directly with your strap over Bluetooth: no WHOOP app, no cloud. The strap can also advertise its heart rate as a standard Bluetooth sensor. Broadcasts the strap\'s own live heart rate over Bluetooth for Garmin, Zwift or gym equipment. Your WHOOP 4.0 can broadcast heart rate directly. Open Data Sources and use “Broadcast heart rate from the strap” for Zwift, Peloton, Garmin or gym equipment. You can alternatively use “Broadcast HR from this phone” when needed. + + + Latest available + NOOP calculated + No calculated night + Samples + HRV sources + HRV history + NOOP HRV is still calibrating + Loading Oura HRV history. + The resting-window comparison is shown above. The nightly trend needs detected sleep. + Finishing pairing + NOOP calculates HRV from the same resting window. It does not affect Charge until sleep is detected. Oura averages %1$d five-minute measurements. + NOOP calculates HRV from stored beat intervals for Charge. Oura averages %1$d five-minute measurements. + Each Oura sample covers five minutes at rest. NOOP uses matching beat intervals. — means insufficient clean data. diff --git a/android/app/src/test/java/com/noop/analytics/OuraRestingWindowsTest.kt b/android/app/src/test/java/com/noop/analytics/OuraRestingWindowsTest.kt index 98919fece7..1c54ed7a34 100644 --- a/android/app/src/test/java/com/noop/analytics/OuraRestingWindowsTest.kt +++ b/android/app/src/test/java/com/noop/analytics/OuraRestingWindowsTest.kt @@ -23,14 +23,14 @@ class OuraRestingWindowsTest { assertEquals(1, all.size) assertEquals(8, all.single().bucketCount) assertEquals(40L * 60L, all.single().durationSeconds) - assertTrue(OuraRestingWindows.sleepCandidates(events).isEmpty()) + assertTrue(OuraRestingWindows.windows(events).filter(OuraRestingWindows::isSleepCandidate).isEmpty()) } @Test fun `long continuous late sleep becomes a candidate`() { val events = (0 until 105).map { event(30_000L + it * 300L, hrv = 42) } - val candidate = OuraRestingWindows.sleepCandidates(events).single() + val candidate = OuraRestingWindows.windows(events).filter(OuraRestingWindows::isSleepCandidate).single() assertEquals(105, candidate.bucketCount) assertEquals(8L * 60L * 60L + 45L * 60L, candidate.durationSeconds) @@ -52,6 +52,6 @@ class OuraRestingWindowsTest { val events = (0 until 18).map { event(20_000L + it * 15L * 60L) } assertEquals(1, OuraRestingWindows.windows(events).size) - assertTrue(OuraRestingWindows.sleepCandidates(events).isEmpty()) + assertTrue(OuraRestingWindows.windows(events).filter(OuraRestingWindows::isSleepCandidate).isEmpty()) } } From 75b5bd142a39df1d7c4b7c023cd7d14d2c2666f0 Mon Sep 17 00:00:00 2001 From: llmassisted Date: Sat, 26 Sep 2026 15:41:27 -0400 Subject: [PATCH 4/6] feat(oura): Test Centre SpO2 feature-mode write on Android Port the Swift Test Centre feature-mode experiment (#2105, OURA_PROTOCOL.md s7.5) to Android, scoped to SpO2: Enable writes mode automatic, Disable writes mode off, each behind a confirmation and never sent automatically. The write is only sent while the ring is idle-streaming and is followed by a status re-read. Beyond the Swift version: - Route the ring's 2f 03 23 reply for any feature other than daytime HR as a verdict (accepted / NOT_SUPPORTED / NOT_AVAILABLE) and log it, instead of handing the driver a no-op live-HR ack. Outside EnablingLiveHR the triplet behaviour is unchanged. - Log the first raw 0x8b spo2_r_pi payloads per session, the tag a Ring 4 is expected to emit once SpO2 runs, as fixtures for a future decoder. Strings in all shipped locales; platform_specific parity dispositions for the Android-only declarations; versionCode 536. Refs #2105 Co-Authored-By: Claude Opus 5.5 (1M context) --- Tools/parity_dispositions.json | 48 +++++++++++ Tools/parity_twin_map.json | 8 +- android/app/build.gradle.kts | 2 +- .../main/java/com/noop/ble/OuraLiveSource.kt | 65 +++++++++++++++ .../java/com/noop/ble/SourceCoordinator.kt | 5 ++ .../src/main/java/com/noop/oura/Commands.kt | 25 ++++++ .../src/main/java/com/noop/oura/Decoders.kt | 10 +++ .../src/main/java/com/noop/oura/EventTags.kt | 5 ++ .../src/main/java/com/noop/oura/OuraDriver.kt | 15 +++- .../src/main/java/com/noop/oura/OuraEvents.kt | 8 ++ .../src/main/java/com/noop/ui/AppViewModel.kt | 9 +++ .../main/java/com/noop/ui/TestCentreScreen.kt | 49 ++++++++++++ .../app/src/main/res/values-de/strings.xml | 12 +++ .../app/src/main/res/values-es/strings.xml | 12 +++ .../app/src/main/res/values-fr/strings.xml | 12 +++ .../app/src/main/res/values-pl/strings.xml | 12 +++ .../src/main/res/values-pt-rPT/strings.xml | 12 +++ .../app/src/main/res/values-ru/strings.xml | 12 +++ .../app/src/main/res/values-zh/strings.xml | 12 +++ android/app/src/main/res/values/strings.xml | 12 +++ .../com/noop/oura/OuraFeatureModeWriteTest.kt | 79 +++++++++++++++++++ docs/OURA_PROTOCOL.md | 1 + 22 files changed, 419 insertions(+), 6 deletions(-) create mode 100644 android/app/src/test/java/com/noop/oura/OuraFeatureModeWriteTest.kt diff --git a/Tools/parity_dispositions.json b/Tools/parity_dispositions.json index 28cb193303..6df52bc3bb 100644 --- a/Tools/parity_dispositions.json +++ b/Tools/parity_dispositions.json @@ -272,6 +272,54 @@ "identity_sha256": "c0269f58f2f76216709dc2f39e51be77ce5f622abfbac91be7fbb3a1c2567972", "platform": "kotlin", "rationale": "Restores the previous Android fork session setup for Oura resting-HR automatic mode, state subscription, and readback. These command constructors support Android foreground/live-HR lifecycle and overnight collection recovery; no Apple session setup is modified or represented as equivalent by this repair." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-constant", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/oura/Commands.kt::featureModeAutomatic", + "identity_sha256": "95556eb80cc0ad50c22449962b10e905e3f53983e1470bc6239548ac47674805", + "platform": "kotlin", + "rationale": "Android port of the Swift Test Centre feature-mode experiment (#2105, OURA_PROTOCOL.md s7.5) for this Android-first fork. The Swift twins already exist in Packages/OuraProtocol; the Kotlin builders keep the same bytes and are reachable only from the Android Test Centre behind a confirmation, never automatically." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-constant", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/oura/Commands.kt::featureModeOff", + "identity_sha256": "246ef7473b503adb03d5ca991e229a38c1f60272b319e3a1ab21203d83d37763", + "platform": "kotlin", + "rationale": "Android port of the Swift Test Centre feature-mode experiment (#2105, OURA_PROTOCOL.md s7.5) for this Android-first fork. The Swift twins already exist in Packages/OuraProtocol; the Kotlin builders keep the same bytes and are reachable only from the Android Test Centre behind a confirmation, never automatically." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-constant", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/oura/EventTags.kt::SPO2_R_PI_UNDECODED", + "identity_sha256": "a3712c91c37eb0465f18ce767a15017a29039d9de56adbc252a10e3b5ac4db76", + "platform": "kotlin", + "rationale": "Android-only capture constant for the never-received 0x8b spo2_r_pi tag (OURA_PROTOCOL.md s6.5.1): the history drain logs its first raw payloads as decoder fixtures. No layout is decoded or stored." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/oura/Commands.kt::featureReadStatus/1#1", + "identity_sha256": "ef5617ddd83227e4b97a1c94a9c3bf85922af314fbcfa2a1fbbcfd1baf9a3b29", + "platform": "kotlin", + "rationale": "Android port of the Swift Test Centre feature-mode experiment (#2105, OURA_PROTOCOL.md s7.5) for this Android-first fork. The Swift twins already exist in Packages/OuraProtocol; the Kotlin builders keep the same bytes and are reachable only from the Android Test Centre behind a confirmation, never automatically." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/oura/Commands.kt::setFeatureMode/2#1", + "identity_sha256": "d2ff8f070a8c5c1558a577a489a6c845cf2503de7cc7745eb511feba62099eea", + "platform": "kotlin", + "rationale": "Android port of the Swift Test Centre feature-mode experiment (#2105, OURA_PROTOCOL.md s7.5) for this Android-first fork. The Swift twins already exist in Packages/OuraProtocol; the Kotlin builders keep the same bytes and are reachable only from the Android Test Centre behind a confirmation, never automatically." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/oura/Decoders.kt::decodeFeatureModeReply/1#1", + "identity_sha256": "c0190e47d1c92bead02bb9ee79ff344b057dd63c7fff4a63a47afb9d767737b2", + "platform": "kotlin", + "rationale": "Android-only decode of the ring's 2f 03 23 feature-mode verdict (OURA_PROTOCOL.md s7.5), so a Test Centre write logs accepted / NOT_SUPPORTED / NOT_AVAILABLE instead of being routed as a live-HR ack. Diagnostic only; never scored or stored." } ] } diff --git a/Tools/parity_twin_map.json b/Tools/parity_twin_map.json index 8a83781760..822c6749f0 100644 --- a/Tools/parity_twin_map.json +++ b/Tools/parity_twin_map.json @@ -19,16 +19,16 @@ }, "authority": { "files": {"count": 504, "sha256": "437bdd89fb050111f11a2229768e5344d04115c91f5cc7670b27890f097d851f"}, - "functions": {"count": 4493, "sha256": "2f38675dc9ca7f4a0eb4f559c1c4a1323f04ad63b6ec00ddbb373f79c6d71feb"}, + "functions": {"count": 4496, "sha256": "67673b5b2bd3fe1686693014e17eff3bf86e293e16c4a350d7f801987e197a2a"}, "properties": {"count": 464, "sha256": "85338b70168a36af785255eb8959a41272cc02334529c2e3ef2a7d18444c7ef7"}, - "constants": {"count": 1961, "sha256": "047b864ba74c9ab725c498bc892b47e1d09f4f923871c348b8adb9a3b80a83ea"}, + "constants": {"count": 1964, "sha256": "7fcab29530e8be337e314bd046c82288adf847cc116ea6c7850c2b6ec348b10c"}, "file_pairs": {"count": 74, "sha256": "1023813dc72dbe4122281d5bae27ac2b7a657dae5904cb5fd603032928199f94"}, "function_pairs": {"count": 189, "sha256": "11df8bf87b16b535aeddd0c00471f87431236fd0b059484c07f6d5939ace9732"}, "property_pairs": {"count": 149, "sha256": "8a62616eca6270e3d8899c47cfe8ae93691f16846ed1959e19a762827b6ec185"}, "constant_pairs": {"count": 681, "sha256": "3484c8096ac8d2832e75f18900c052a19bfb7d92cbc2fa7fdb6137d8fa46ebf6"}, "unpaired_files": {"count": 378, "sha256": "468445b59f27e4f2b277d58c119d76d7364038f51f50b1ce9053f1b7f514246e"}, - "unpaired_functions": {"count": 4127, "sha256": "efbc502b2276f1b9276bceb049a42e0f38f8cb235d3336a7d9b3e544a59cdf28"}, + "unpaired_functions": {"count": 4130, "sha256": "240865c1d6af56a3935869f899b236ce127951d73f09405bfbd1dd62a628d68b"}, "unpaired_properties": {"count": 166, "sha256": "280d96f0905fd623dfb66af86c99c3e82409b126a45aaf9e008eb8f6cf04671a"}, - "unpaired_constants": {"count": 599, "sha256": "ab83879bb6465fa0e5934592c0065ada37758d099fc41ae476c2d8a1ad55dca7"} + "unpaired_constants": {"count": 602, "sha256": "ba7eeffc907b91b7a154d26b498d1ab5d9ace0ffe03ae2d6151ec5ed4e2dce13"} } } diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 1ac91bf9fa..8a0ff68157 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -26,7 +26,7 @@ android { applicationId = "com.noop.whoop" minSdk = 26 targetSdk = 34 - versionCode = 535 + versionCode = 536 versionName = "11.8.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt b/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt index 19d163cb8a..2698c88eb9 100644 --- a/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt +++ b/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt @@ -385,6 +385,10 @@ class OuraLiveSource( /** Feature ids whose status we have already logged this session (SpO2 0x04 / real_steps 0x0b), so the * read-only feature-status diagnostic prints once per feature, not on every reconnect. */ private val loggedFeatureStatuses = mutableSetOf() + /** `0x8b` spo2_r_pi records whose raw payload has been logged this session. NOOP has never received the + * tag (OURA_PROTOCOL.md s6.5.1); if enabling SpO2 makes a Ring 4 emit it, the first few raw payloads are + * the fixtures a decoder must be validated against. Capped so a full night cannot flood the log. */ + private var spo2RatioRecordsLogged = 0 /** Product-info replies already logged this session, keyed by op+body so the #771/#772 serial/hardware * capture prints each DISTINCT reply once — get_serial and get_hardware both answer under op 0x19, so a * per-op guard would swallow the second (observed on-device: only the serial reached the log). Twin of @@ -816,6 +820,54 @@ class OuraLiveSource( } } + /** + * Send one `2f 03 22 ` feature-mode write. EXPERIMENT ONLY — reachable exclusively from the + * Test Centre Oura section behind a confirmation; nothing in the connect flow or [OuraDriver] calls it, + * and it is never sent automatically. UNVALIDATED per OURA_PROTOCOL.md s7.5. Twin of the Swift + * `writeFeatureMode`. + * + * Only sent while idle-streaming, never mid-auth or mid live-HR enable, so the ring's `0x23` reply can + * never be mistaken for a live-HR triplet step. Clears any already-logged status for this feature first + * so the read-status probe sent with it logs a fresh line (the connect-time probe has already consumed + * [logFeatureStatus]'s once-per-connection dedup). Returns false when no authenticated ring is idle. + */ + fun writeFeatureMode(feature: Int, mode: Int): Boolean { + if (driver?.phase != OuraDriverPhase.Streaming) { + log("Oura: feature-mode write SKIPPED - ring not connected and idle") + return false + } + handler.post { + guardedCallback("feature-mode-write") { + if (driver?.phase != OuraDriverPhase.Streaming) { + log("Oura: feature-mode write SKIPPED - ring left the idle streaming state") + return@guardedCallback + } + val cmd = OuraCommands.setFeatureMode(feature, mode) + val frameHex = cmd.bytes.joinToString("") { "%02x".format(it) } + log("Oura: feature-mode WRITE feature=0x${feature.toString(16)} mode=$mode frame=$frameHex" + + " - EXPERIMENT, unvalidated on NOOP hardware (OURA_PROTOCOL.md s7.5)") + loggedFeatureStatuses.remove(feature) + write(cmd) + write(OuraCommands.featureReadStatus(feature)) + } + } + return true + } + + /** Log the ring's verdict on a feature-mode write for any feature other than daytime HR (whose reply is + * step 2 of the live-HR triplet and stays with the driver). Always-on: it only fires after a write. */ + private fun logFeatureModeReply(reply: com.noop.oura.OuraFeatureModeReply) { + val feature = reply.feature + val status = reply.status + val verdict = when (status) { + 0 -> "accepted" + 1 -> "rejected NOT_SUPPORTED" + 2 -> "rejected NOT_AVAILABLE" + else -> "status ?" + } + log("Oura: feature-mode reply feature=0x${feature.toString(16)} status=$status ($verdict)") + } + private fun scheduleHistoryFetch() { if (historyFetchScheduled) return historyFetchScheduled = true @@ -1394,6 +1446,7 @@ class OuraLiveSource( pendingSyncTime = null loggedTierBKinds.clear() loggedFeatureStatuses.clear() + spo2RatioRecordsLogged = 0 loggedProductInfo.clear() pendingAnchorEvents.clear() // Per-drain / per-session protocol state starts clean (twin of Swift's connect-setup reset). @@ -1508,6 +1561,7 @@ class OuraLiveSource( pendingSyncTime = null loggedTierBKinds.clear() loggedFeatureStatuses.clear() + spo2RatioRecordsLogged = 0 loggedProductInfo.clear() reachedStreaming = false clearAuthWatchdog() @@ -1701,6 +1755,7 @@ class OuraLiveSource( pendingSyncTime = null loggedTierBKinds.clear() loggedFeatureStatuses.clear() + spo2RatioRecordsLogged = 0 loggedProductInfo.clear() reachedStreaming = false liveHRActive = false @@ -2267,6 +2322,12 @@ class OuraLiveSource( drain.noteSeenRingTime(rec.ringTimestamp) historyRecordCount += 1 historyTagCounts[rec.type] = (historyTagCounts[rec.type] ?: 0L) + 1L + if (rec.type == com.noop.oura.OuraEventTag.SPO2_R_PI_UNDECODED && spo2RatioRecordsLogged < SPO2_R_PI_LOG_LIMIT) { + spo2RatioRecordsLogged += 1 + log("Oura: spo2_r_pi (0x8b) raw rt=${rec.ringTimestamp} payload=" + + rec.payload.joinToString("") { "%02x".format(it) } + + " - UNDECODED, capture for OURA_PROTOCOL.md s6.5.1") + } } val events = d.ingest(rec) if (isHistoryRecord && events.isNotEmpty()) historyDecodedRecordCount += 1 @@ -2300,6 +2361,7 @@ class OuraLiveSource( advance(OuraTransition.EnableAckReceived) } is OuraDriver.SecureRouting.FeatureStatus -> logFeatureStatus(routing.value) // read-only; no advance + is OuraDriver.SecureRouting.FeatureModeReply -> logFeatureModeReply(routing.value) // no advance is OuraDriver.SecureRouting.LiveHRPush -> emit(d.ingestLiveHRPush(routing.body)) OuraDriver.SecureRouting.Unhandled -> Unit } @@ -2913,6 +2975,9 @@ class OuraLiveSource( /** Keep the raw tag census compact enough for the rolling strap log while preserving its leaders. */ private const val HISTORY_TAG_LOG_LIMIT = 24 + /** How many raw `0x8b` spo2_r_pi payloads to log per session (OURA_PROTOCOL.md s6.5.1). */ + private const val SPO2_R_PI_LOG_LIMIT = 8 + /** Max self-chained drain passes per session (twin of Swift's maxChainedDrainPasses). */ private const val MAX_CHAINED_DRAIN_PASSES = 6 diff --git a/android/app/src/main/java/com/noop/ble/SourceCoordinator.kt b/android/app/src/main/java/com/noop/ble/SourceCoordinator.kt index c6614d81d8..ab1c9142ff 100644 --- a/android/app/src/main/java/com/noop/ble/SourceCoordinator.kt +++ b/android/app/src/main/java/com/noop/ble/SourceCoordinator.kt @@ -271,6 +271,11 @@ class SourceCoordinator( (activeSource as? OuraLiveSource)?.setLiveHRRequested(requested) } + /** Test Centre's confirmation-gated Oura feature-mode experiment (OURA_PROTOCOL.md s7.5). False when no + * Oura ring is the active, idle-connected source. Never called automatically. */ + fun writeOuraFeatureMode(feature: Int, mode: Int): Boolean = + (activeSource as? OuraLiveSource)?.writeFeatureMode(feature, mode) ?: false + /** * The BLE engine connected to a WHOOP strap at [address] (null on disconnect). Persist that stable * identity onto the CURRENTLY ACTIVE device when it's a WHOOP and hasn't adopted one yet — so the diff --git a/android/app/src/main/java/com/noop/oura/Commands.kt b/android/app/src/main/java/com/noop/oura/Commands.kt index c669fae99d..a3550c603f 100644 --- a/android/app/src/main/java/com/noop/oura/Commands.kt +++ b/android/app/src/main/java/com/noop/oura/Commands.kt @@ -205,6 +205,31 @@ object OuraCommands { fun spo2ReadStatus(): OuraCommand = OuraCommand("spo2_status", intArrayOf(0x2F, 0x02, 0x20, featureSpO2)) + // Feature-mode write (s7.5; UNVALIDATED, opt-in only). Kotlin twins of the Swift builders. + + /** Feature MODE values for [setFeatureMode] (s7.1): off, and automatic (ring runs it when worn and banks + * the result for sync). The only two the [open_oura-feat] local-write evidence covers. */ + const val featureModeOff = 0x00 + const val featureModeAutomatic = 0x01 + + /** Read any feature's status: `2f 02 20 ` — the same read verb as [spo2ReadStatus] / + * [realStepsReadStatus], generalized so the feature-mode write below can re-probe after writing. */ + fun featureReadStatus(feature: Int): OuraCommand = + OuraCommand("feature_status_${feature.toString(16)}", intArrayOf(0x2F, 0x02, 0x20, feature)) + + /** + * Write a feature's MODE: `2f 03 22 `. UNVALIDATED on NOOP's own hardware — see + * OURA_PROTOCOL.md s7.5: [open_oura-feat] reports this write bypassing the account gate for several + * features on a consumer ring, tested there only with mode=0x01 (automatic); mode=0x00 ("off") always + * reverts. Gated to Test Centre / explicit user action only — nothing in [OuraDriver]'s own flow + * produces this call. + */ + fun setFeatureMode(feature: Int, mode: Int): OuraCommand = + OuraCommand( + "EXPERIMENT_set_feature_${feature.toString(16)}_mode$mode", + intArrayOf(0x2F, 0x03, 0x22, feature, mode), + ) + /** * Read the real-steps feature status, `2f 02 20 0b` (READ verb, not enable). The `0x21` reply reports * the ring's own real_steps gate state - NOT reliably "off" for an offline ring: on-device diff --git a/android/app/src/main/java/com/noop/oura/Decoders.kt b/android/app/src/main/java/com/noop/oura/Decoders.kt index 8c11227aab..d95b3c4ec8 100644 --- a/android/app/src/main/java/com/noop/oura/Decoders.kt +++ b/android/app/src/main/java/com/noop/oura/Decoders.kt @@ -528,6 +528,16 @@ object OuraDecoders { return OuraFeatureStatus(subBody[0], subBody[1], subBody[2], subBody[3], subBody[4]) } + /** + * Decode a feature-mode write reply's SUB-BODY (the bytes AFTER the `0x23` sub-op) into feature + status + * (OURA_PROTOCOL.md s7.5). Returns null on a short body (< 2) so a truncated reply never fabricates a + * verdict. + */ + fun decodeFeatureModeReply(subBody: IntArray): OuraFeatureModeReply? { + if (subBody.size < 2) return null + return OuraFeatureModeReply(subBody[0], subBody[1]) + } + // MARK: - Debug text (0x43; s6.15) /** diff --git a/android/app/src/main/java/com/noop/oura/EventTags.kt b/android/app/src/main/java/com/noop/oura/EventTags.kt index f3653c38c8..f10ddf4c29 100644 --- a/android/app/src/main/java/com/noop/oura/EventTags.kt +++ b/android/app/src/main/java/com/noop/oura/EventTags.kt @@ -158,5 +158,10 @@ enum class OuraEventTag(val raw: Int) { /** Map a `type` byte to its tag, or null when the byte is not in the dictionary (honest). */ fun fromRaw(raw: Int): OuraEventTag? = byRaw[raw] + + /** `spo2_r_pi_event` (OURA_PROTOCOL.md s6.5.1): SpO2 ratio-of-ratios + perfusion index. Deliberately + * NOT a dictionary entry — never received by NOOP, so no layout is validated; the history drain only + * logs its raw payloads as fixtures. */ + const val SPO2_R_PI_UNDECODED: Int = 0x8B } } diff --git a/android/app/src/main/java/com/noop/oura/OuraDriver.kt b/android/app/src/main/java/com/noop/oura/OuraDriver.kt index 1d53c3c04b..898657ba57 100644 --- a/android/app/src/main/java/com/noop/oura/OuraDriver.kt +++ b/android/app/src/main/java/com/noop/oura/OuraDriver.kt @@ -638,7 +638,19 @@ class OuraDriver( } return SecureRouting.EnableAck } - if (frame.subop == 0x23 || frame.subop == 0x27) { + if (frame.subop == 0x23) { + // 0x23 answers ANY feature-mode write. Only the daytime-HR one is step 2 of the live-HR triplet; + // another feature's reply (the resting-HR restore at connect, or the Test Centre experiment, + // s7.5) carries the ring's verdict on that write and must not advance the triplet. + val reply = OuraDecoders.decodeFeatureModeReply(frame.subBody) + if (reply != null && reply.feature != OuraCommands.featureDaytimeHR && + phase != OuraDriverPhase.EnablingLiveHR + ) { + return SecureRouting.FeatureModeReply(reply) + } + return SecureRouting.EnableAck + } + if (frame.subop == 0x27) { return SecureRouting.EnableAck } return SecureRouting.Unhandled @@ -668,6 +680,7 @@ class OuraDriver( object EnableAck : SecureRouting() data class FeatureStatus(val value: OuraFeatureStatus) : SecureRouting() + data class FeatureModeReply(val value: OuraFeatureModeReply) : SecureRouting() object Unhandled : SecureRouting() } diff --git a/android/app/src/main/java/com/noop/oura/OuraEvents.kt b/android/app/src/main/java/com/noop/oura/OuraEvents.kt index cc6c99c054..0f753630a8 100644 --- a/android/app/src/main/java/com/noop/oura/OuraEvents.kt +++ b/android/app/src/main/java/com/noop/oura/OuraEvents.kt @@ -328,6 +328,14 @@ data class OuraFeatureStatus( val subscription: Int, ) +/** + * The ring's `2f 03 23 ` reply to a feature-mode write (OURA_PROTOCOL.md s7.5): the feature id + * and the ring's own verdict — 0 accepted, 1 NOT_SUPPORTED, 2 NOT_AVAILABLE per [open_oura-feat]. That + * verdict is what separates "the ring refused" from "accepted but a no-op", which a follow-up status read + * cannot. Diagnostic only; never scored, never stored. + */ +data class OuraFeatureModeReply(val feature: Int, val status: Int) + /** A UTC anchor / time-sync event (OURA_PROTOCOL.md s6.11): epoch ms + timezone offset seconds. */ data class OuraTimeSync(val ringTimestamp: Long, val epochMs: Long, val tzOffsetSeconds: Int) diff --git a/android/app/src/main/java/com/noop/ui/AppViewModel.kt b/android/app/src/main/java/com/noop/ui/AppViewModel.kt index daf36f4487..fb2314a01b 100644 --- a/android/app/src/main/java/com/noop/ui/AppViewModel.kt +++ b/android/app/src/main/java/com/noop/ui/AppViewModel.kt @@ -2580,6 +2580,15 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { NoopPrefs.setOuraNotifyMaskFull(appContext, enabled) } + /** Test Centre's confirmation-gated Oura SpO2 feature-mode experiment (OURA_PROTOCOL.md s7.5): writes + * mode automatic (enable) or off (disable) to the connected ring. Never called automatically. False + * when no Oura ring is connected and idle; the ring's verdict lands in the strap log. */ + fun writeOuraSpO2Mode(enable: Boolean): Boolean = + noopApp.sourceCoordinator.writeOuraFeatureMode( + com.noop.oura.OuraCommands.featureSpO2, + if (enable) com.noop.oura.OuraCommands.featureModeAutomatic else com.noop.oura.OuraCommands.featureModeOff, + ) + /** #1121: toggle the opt-in rolling "detailed capture" strap-log file. Persisted so it survives a * process kill (re-armed in [init] below). */ fun setDetailedCapture(enabled: Boolean) { diff --git a/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt b/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt index 159535fbc1..6f962e852f 100644 --- a/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt +++ b/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt @@ -721,6 +721,10 @@ private fun DiagnosticToolsCard(vm: AppViewModel) { var ouraPaired by remember { mutableStateOf(false) } var ouraOnsetKeying by remember { mutableStateOf(NoopPrefs.ouraOnsetKeying(context)) } var ouraNotifyMaskFull by remember { mutableStateOf(NoopPrefs.ouraNotifyMaskFull(context)) } + // Oura SpO2 feature-mode experiment: the write awaiting confirmation (true = enable, false = disable) + // and the outcome line shown under the buttons after a send. + var pendingOuraSpO2Enable by remember { mutableStateOf(null) } + var ouraSpO2Result by remember { mutableStateOf(null) } LaunchedEffect(Unit) { val paired = runCatching { vm.pairedDevices() }.getOrDefault(emptyList()) polarIdentity = PolarModel.debugIdentification(paired.firstOrNull { PolarModel.isPolar(it.model) }?.model) @@ -821,6 +825,23 @@ private fun DiagnosticToolsCard(vm: AppViewModel) { checked = ouraNotifyMaskFull, onCheckedChange = { ouraNotifyMaskFull = it; vm.setOuraNotifyMaskFull(it) }, ) + // SpO2 feature-mode experiment (OURA_PROTOCOL.md s7.5). Each button only opens a confirmation; + // nothing is written automatically, and the ring's verdict lands in the strap log. + Text(stringResource(R.string.oura_spo2_mode_title), style = NoopType.subhead, color = Palette.textPrimary) + Text(stringResource(R.string.oura_spo2_mode_desc), style = NoopType.footnote, color = Palette.textSecondary) + NoopButton( + text = stringResource(R.string.oura_spo2_mode_enable), + kind = NoopButtonKind.Secondary, + fullWidth = true, + onClick = { pendingOuraSpO2Enable = true }, + ) + NoopButton( + text = stringResource(R.string.oura_spo2_mode_disable), + kind = NoopButtonKind.Secondary, + fullWidth = true, + onClick = { pendingOuraSpO2Enable = false }, + ) + ouraSpO2Result?.let { Text(it, style = NoopType.footnote, color = Palette.accent) } } // #1121 Detailed capture: an adb-like rolling on-device log, no computer needed. Off by default. ToggleRowTC( @@ -890,6 +911,34 @@ private fun DiagnosticToolsCard(vm: AppViewModel) { }, ) } + pendingOuraSpO2Enable?.let { enable -> + val sentText = stringResource(R.string.oura_spo2_mode_sent) + val notConnectedText = stringResource(R.string.oura_spo2_mode_not_connected) + AlertDialog( + onDismissRequest = { pendingOuraSpO2Enable = null }, + containerColor = Palette.surfaceOverlay, + title = { + Text( + stringResource(if (enable) R.string.oura_spo2_mode_confirm_enable else R.string.oura_spo2_mode_confirm_disable), + style = NoopType.title2, color = Palette.textPrimary, + ) + }, + text = { + Text(stringResource(R.string.oura_spo2_mode_confirm_body), style = NoopType.subhead, color = Palette.textSecondary) + }, + confirmButton = { + TextButton(onClick = { + ouraSpO2Result = if (vm.writeOuraSpO2Mode(enable)) sentText else notConnectedText + pendingOuraSpO2Enable = null + }) { Text(stringResource(R.string.oura_spo2_mode_send), style = NoopType.body, color = Palette.accent) } + }, + dismissButton = { + TextButton(onClick = { pendingOuraSpO2Enable = null }) { + Text(uiString(R.string.l10n_test_centre_screen_cancel_77dfd213), style = NoopType.body, color = Palette.textSecondary) + } + }, + ) + } } @Composable diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 380b6952a3..e1c9065686 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -2848,4 +2848,16 @@ NOOP berechnet die HRV für denselben Ruhezeitraum. Sie beeinflusst Charge erst, wenn Schlaf erkannt wurde. Oura mittelt %1$d Fünf-Minuten-Messungen. NOOP berechnet die HRV für Charge aus gespeicherten Schlagintervallen. Oura mittelt %1$d Fünf-Minuten-Messungen. Jeder Oura-Messwert umfasst fünf Minuten Ruhe. NOOP verwendet die entsprechenden Schlagintervalle. — bedeutet zu wenige bereinigte Daten. + + + Oura-SpO2-Messung (experimentell) + Bittet den Ring, nachts den Blutsauerstoff aufzuzeichnen. Ungeprüft bei Ringen, bei denen Oura SpO2 nie aktiviert hat. „Deaktivieren“ macht es rückgängig. Die Antwort des Rings erscheint im Strap-Log. + SpO2 am Ring aktivieren + SpO2 am Ring deaktivieren + SpO2 am Ring aktivieren? + SpO2 am Ring deaktivieren? + Dies sendet eine Einstellungsänderung an den Ring. Die andere Schaltfläche macht sie rückgängig. + Senden + Gesendet. Die Antwort des Rings steht im Strap-Log. + Ring nicht verbunden. Warte, bis er verbunden ist, und versuche es erneut. diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index 6d711b106e..f2f01dbc9e 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -2835,4 +2835,16 @@ NOOP calcula la VFC del mismo periodo de reposo. No afecta a Charge hasta que se detecta sueño. Oura promedia %1$d mediciones de cinco minutos. NOOP calcula la VFC para Charge a partir de los intervalos entre latidos almacenados. Oura promedia %1$d mediciones de cinco minutos. Cada muestra de Oura abarca cinco minutos de reposo. NOOP usa los intervalos entre latidos correspondientes. — indica datos válidos insuficientes. + + + Medición de SpO2 de Oura (experimental) + Pide al anillo que registre el oxígeno en sangre por la noche. Sin verificar en anillos donde Oura nunca activó SpO2. Desactivar lo revierte. La respuesta del anillo aparece en el registro de la banda. + Activar SpO2 en el anillo + Desactivar SpO2 en el anillo + ¿Activar SpO2 en el anillo? + ¿Desactivar SpO2 en el anillo? + Esto envía un cambio de ajuste al anillo. El otro botón lo revierte. + Enviar + Enviado. Consulta el registro de la banda para ver la respuesta del anillo. + Anillo no conectado. Espera a que se conecte e inténtalo de nuevo. diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index 844749bf44..afcd494a5d 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -2834,4 +2834,16 @@ NOOP calcule la VFC sur la même période de repos. Elle ne contribue à Charge que lorsque du sommeil est détecté. Oura fait la moyenne de %1$d mesures de cinq minutes. NOOP calcule la VFC pour Charge à partir des intervalles entre battements enregistrés. Oura fait la moyenne de %1$d mesures de cinq minutes. Chaque mesure Oura couvre cinq minutes de repos. NOOP utilise les intervalles entre battements correspondants. — indique un manque de données valides. + + + Mesure SpO2 Oura (expérimental) + Demande à la bague d\'enregistrer l\'oxygène sanguin la nuit. Non vérifié sur les bagues où Oura n\'a jamais activé la SpO2. Désactiver annule le réglage. La réponse de la bague apparaît dans le journal du bracelet. + Activer la SpO2 sur la bague + Désactiver la SpO2 sur la bague + Activer la SpO2 sur la bague ? + Désactiver la SpO2 sur la bague ? + Ceci envoie un changement de réglage à la bague. L\'autre bouton l\'annule. + Envoyer + Envoyé. Consultez le journal du bracelet pour la réponse de la bague. + Bague non connectée. Attendez la connexion, puis réessayez. diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml index 7683c5c72e..876a785e97 100644 --- a/android/app/src/main/res/values-pl/strings.xml +++ b/android/app/src/main/res/values-pl/strings.xml @@ -2849,4 +2849,16 @@ NOOP oblicza HRV dla tego samego okresu odpoczynku. Wynik wpływa na Charge dopiero po wykryciu snu. Oura oblicza średnią z pomiarów pięciominutowych; ich liczba: %1$d. NOOP oblicza HRV dla Charge z zapisanych odstępów między uderzeniami serca. Oura oblicza średnią z pomiarów pięciominutowych; ich liczba: %1$d. Każdy pomiar Oura obejmuje pięć minut odpoczynku. NOOP używa odpowiadających mu odstępów między uderzeniami serca. — oznacza za mało poprawnych danych. + + + Pomiar SpO2 Oura (eksperymentalny) + Prosi pierścień o rejestrowanie natlenienia krwi w nocy. Niesprawdzone na pierścieniach, w których Oura nigdy nie włączyła SpO2. Wyłączenie cofa zmianę. Odpowiedź pierścienia pojawia się w logu opaski. + Włącz SpO2 w pierścieniu + Wyłącz SpO2 w pierścieniu + Włączyć SpO2 w pierścieniu? + Wyłączyć SpO2 w pierścieniu? + Wysyła to jedną zmianę ustawienia do pierścienia. Drugi przycisk ją cofa. + Wyślij + Wysłano. Odpowiedź pierścienia znajdziesz w logu opaski. + Pierścień nie jest połączony. Poczekaj na połączenie i spróbuj ponownie. diff --git a/android/app/src/main/res/values-pt-rPT/strings.xml b/android/app/src/main/res/values-pt-rPT/strings.xml index da625f3a3f..5eacc2dbf1 100644 --- a/android/app/src/main/res/values-pt-rPT/strings.xml +++ b/android/app/src/main/res/values-pt-rPT/strings.xml @@ -2827,4 +2827,16 @@ O NOOP calcula a VFC para o mesmo período de repouso. Não afeta o Charge até ser detetado sono. O Oura calcula a média de %1$d medições de cinco minutos. O NOOP calcula a VFC para o Charge a partir dos intervalos entre batimentos guardados. O Oura calcula a média de %1$d medições de cinco minutos. Cada amostra do Oura abrange cinco minutos em repouso. O NOOP utiliza os intervalos entre batimentos correspondentes. — indica dados válidos insuficientes. + + + Medição de SpO2 Oura (experimental) + Pede ao anel que registe o oxigénio no sangue durante a noite. Não verificado em anéis onde a Oura nunca ativou a SpO2. Desativar reverte. A resposta do anel aparece no registo da pulseira. + Ativar SpO2 no anel + Desativar SpO2 no anel + Ativar SpO2 no anel? + Desativar SpO2 no anel? + Isto envia uma alteração de definição ao anel. O outro botão reverte-a. + Enviar + Enviado. Consulte o registo da pulseira para ver a resposta do anel. + Anel não ligado. Aguarde a ligação e tente novamente. diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index e742c518e8..1b0eb50e1b 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -2728,4 +2728,16 @@ NOOP рассчитывает ВСР за тот же период покоя. Результат влияет на Charge только после обнаружения сна. Oura усредняет пятиминутные измерения; их количество: %1$d. NOOP рассчитывает ВСР для Charge по сохранённым интервалам между ударами сердца. Oura усредняет пятиминутные измерения; их количество: %1$d. Каждое измерение Oura охватывает пять минут покоя. NOOP использует соответствующие интервалы между ударами сердца. — означает, что достоверных данных недостаточно. + + + Измерение SpO2 Oura (экспериментально) + Просит кольцо записывать уровень кислорода в крови ночью. Не проверено на кольцах, где Oura никогда не включала SpO2. «Выключить» отменяет изменение. Ответ кольца появится в журнале браслета. + Включить SpO2 на кольце + Выключить SpO2 на кольце + Включить SpO2 на кольце? + Выключить SpO2 на кольце? + Кольцу будет отправлено одно изменение настройки. Другая кнопка его отменяет. + Отправить + Отправлено. Ответ кольца смотрите в журнале браслета. + Кольцо не подключено. Дождитесь подключения и повторите попытку. diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml index c7ace37ecc..d2aa94b77b 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -2806,4 +2806,16 @@ NOOP 根据同一静息时段计算 HRV。检测到睡眠后,该结果才会计入 Charge。Oura 取 %1$d 个五分钟测量值的平均值。 NOOP 根据已保存的心搏间期计算用于 Charge 的 HRV。Oura 取 %1$d 个五分钟测量值的平均值。 每个 Oura 样本覆盖五分钟静息时段。NOOP 使用对应的心搏间期。— 表示有效数据不足。 + + + Oura 血氧测量(实验性) + 请求戒指在夜间记录血氧。在 Oura 从未启用血氧的戒指上尚未验证。点击停用即可恢复。戒指的回复会显示在手环日志中。 + 在戒指上启用血氧 + 在戒指上停用血氧 + 在戒指上启用血氧? + 在戒指上停用血氧? + 这会向戒指发送一项设置更改。另一个按钮可将其恢复。 + 发送 + 已发送。请在手环日志中查看戒指的回复。 + 戒指未连接。请等待连接后重试。 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index cb283bc740..ccad04caa8 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2864,4 +2864,16 @@ NOOP calculates HRV from the same resting window. It does not affect Charge until sleep is detected. Oura averages %1$d five-minute measurements. NOOP calculates HRV from stored beat intervals for Charge. Oura averages %1$d five-minute measurements. Each Oura sample covers five minutes at rest. NOOP uses matching beat intervals. — means insufficient clean data. + + + Oura SpO2 sensing (experimental) + Asks the ring to record blood oxygen overnight. Unverified on rings where Oura never enabled SpO2. Disable reverts it. The ring\'s answer appears in the strap log. + Enable SpO2 on ring + Disable SpO2 on ring + Enable SpO2 on the ring? + Disable SpO2 on the ring? + This sends one setting change to the ring. The other button reverts it. + Send + Sent. Check the strap log for the ring\'s reply. + Ring not connected. Wait for it to connect, then try again. diff --git a/android/app/src/test/java/com/noop/oura/OuraFeatureModeWriteTest.kt b/android/app/src/test/java/com/noop/oura/OuraFeatureModeWriteTest.kt new file mode 100644 index 0000000000..1ce79ee2b7 --- /dev/null +++ b/android/app/src/test/java/com/noop/oura/OuraFeatureModeWriteTest.kt @@ -0,0 +1,79 @@ +package com.noop.oura + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * The feature-mode WRITE (`2f 03 22 `) and its generalized status read (`2f 02 20 `) — + * UNVALIDATED on NOOP's own hardware (OURA_PROTOCOL.md s7.5). Kotlin twin of Swift's + * OuraFeatureModeWriteTests, plus the Android routing of the ring's `0x23` verdict. The builders carry no + * gate of their own; the Test Centre confirmation is what keeps this off the automatic connect path. + */ +class OuraFeatureModeWriteTest { + private val key: IntArray = IntArray(16) { it } + + @Test + fun featureReadStatusGeneralizesTheExistingSpo2AndRealStepsProbes() { + assertArrayEquals(OuraCommands.spo2ReadStatus().bytes, OuraCommands.featureReadStatus(OuraCommands.featureSpO2).bytes) + assertArrayEquals( + OuraCommands.realStepsReadStatus().bytes, + OuraCommands.featureReadStatus(OuraCommands.featureRealSteps).bytes, + ) + } + + @Test + fun setFeatureModeBytesExactForSpo2() { + assertArrayEquals(intArrayOf(0x2F, 0x03, 0x22, 0x04, 0x01), OuraCommands.setFeatureMode(OuraCommands.featureSpO2, 0x01).bytes) + assertArrayEquals(intArrayOf(0x2F, 0x03, 0x22, 0x04, 0x00), OuraCommands.setFeatureMode(OuraCommands.featureSpO2, 0x00).bytes) + } + + @Test + fun setFeatureModeSubOpIsTheEnableVerbNotTheReadVerb() { + assertEquals(0x22, OuraCommands.setFeatureMode(OuraCommands.featureSpO2, 0x01).bytes[2]) + assertEquals(0x20, OuraCommands.featureReadStatus(OuraCommands.featureSpO2).bytes[2]) + } + + @Test + fun decodesTheFeatureModeReply() { + assertEquals(OuraFeatureModeReply(0x04, 0x02), OuraDecoders.decodeFeatureModeReply(intArrayOf(0x04, 0x02))) + assertNull(OuraDecoders.decodeFeatureModeReply(intArrayOf(0x04))) + } + + @Test + fun nonDaytimeReplySurfacesItsVerdictWhenIdle() { + val d = OuraDriver(ringGen = OuraRingGen.GEN4, authKey = key) + assertEquals( + OuraDriver.SecureRouting.FeatureModeReply(OuraFeatureModeReply(0x04, 0x00)), + d.handleSecureFrame(OuraSecureFrame(0x23, intArrayOf(0x04, 0x00))), + ) + // The resting-HR restore sent at connect answers the same way. + assertEquals( + OuraDriver.SecureRouting.FeatureModeReply(OuraFeatureModeReply(0x08, 0x00)), + d.handleSecureFrame(OuraSecureFrame(0x23, intArrayOf(0x08, 0x00))), + ) + } + + @Test + fun daytimeHrReplyStillAdvancesTheTriplet() { + val d = OuraDriver(ringGen = OuraRingGen.GEN4, authKey = key) + assertEquals(OuraDriver.SecureRouting.EnableAck, d.handleSecureFrame(OuraSecureFrame(0x23, intArrayOf(0x02, 0x00)))) + } + + @Test + fun anyReplyDuringLiveHrEnableStaysWithTheTriplet() { + val d = OuraDriver(ringGen = OuraRingGen.GEN4, authKey = key) + d.nextStep(OuraTransition.Ready) + d.nextStep(OuraTransition.NonceReceived(IntArray(15) { it + 1 })) + d.nextStep(OuraTransition.AuthCompleted(OuraAuthStatus.SUCCESS)) + assertEquals(OuraDriverPhase.EnablingLiveHR, d.phase) + assertEquals(OuraDriver.SecureRouting.EnableAck, d.handleSecureFrame(OuraSecureFrame(0x23, intArrayOf(0x04, 0x00)))) + } + + @Test + fun shortReplyFallsBackToEnableAck() { + val d = OuraDriver(ringGen = OuraRingGen.GEN4, authKey = key) + assertEquals(OuraDriver.SecureRouting.EnableAck, d.handleSecureFrame(OuraSecureFrame(0x23, intArrayOf(0x04)))) + } +} diff --git a/docs/OURA_PROTOCOL.md b/docs/OURA_PROTOCOL.md index 57e59a3cb8..57e3d4c95b 100644 --- a/docs/OURA_PROTOCOL.md +++ b/docs/OURA_PROTOCOL.md @@ -1255,6 +1255,7 @@ NOOP ships a **read-only** probe that asks the ring to report a feature's own st - **SpO2 (`0x04`) is not covered by this evidence.** On the source ring, SpO2 already read AUTOMATIC by default before any write was sent — the experiment never demonstrated forcing SpO2 from a gate-OFF state, which is the case NOOP's own 2026-07-20 capture is actually in. Whether `2f 03 22 04 01` flips a gate-OFF SpO2 ring is untested by both projects. - The wire bytes (`2f 03 22 ` → `2f 03 23 `) are independently verifiable over BLE and are already cited into §7.1 from [ring4-ble]/[open_oura-feat]; only the *outcome of actually sending it on a gated ring* is new here. The *rationale* `ring-features.md` gives for why this works (`FeatureDefinitions.*` server-flag names, the `b0.smali` enable sequence) is attributed there to decompiled-app analysis and is cited here as background only — NOOP implements neither that code nor those literals. - **The account-gate bypass remains UNVALIDATED on NOOP's own hardware.** The write itself is now reachable, from Test Centre only (#2105), behind an explicit confirmation; no automatic path in `OuraDriver` sends it, and §7.4's status probe stays read-only. The write MECHANISM is confirmed here: disabling then re-enabling SpO2 flipped `mode` 1→0→1, reproduced in both directions on a real Gen 3. That ring was already cloud-entitled, so it does not test the bypass. Whether `2f 03 22 04 01` moves a gate-OFF SpO2 ring is still open work. +- **Android (fork):** the same write is reachable from the Android Test Centre (SpO2 only, enable = mode `0x01`, disable = mode `0x00`), confirmation-gated and never automatic. Android additionally routes the ring's `2f 03 23 ` reply for any feature other than daytime HR as a verdict (`0` accepted, `1` NOT_SUPPORTED, `2` NOT_AVAILABLE per [open_oura-feat]) and logs it, instead of treating it as a live-HR triplet ACK; this is what distinguishes "the ring refused" from "accepted, no data yet". If SpO2 then produces `0x8b` (§6.5.1), the history drain logs the first raw payloads per session as decoder fixtures; nothing is decoded or stored from them. --- From 04cd3af48eb537c78330a3dfc328100791d86164 Mon Sep 17 00:00:00 2001 From: llmassisted Date: Sun, 27 Sep 2026 00:44:28 -0400 Subject: [PATCH 5/6] fix(oura): correct live HR lifecycle and HRV comparison reads Synchronize live HR demand with the current Activity lifecycle, including an already-resumed cold launch. Read fallback HRV beats per resting window so older history cannot consume the cap for recent nights. Add lifecycle and multi-night HRV regression coverage. Validation: 54 focused JVM tests, source hygiene, protocol documentation checks, and i18n audit passed. --- .../src/main/java/com/noop/ui/AppViewModel.kt | 22 ++--- .../src/main/java/com/noop/ui/HealthScreen.kt | 36 +------- .../src/main/java/com/noop/ui/MainActivity.kt | 6 +- .../java/com/noop/ui/OuraLiveHrLifecycle.kt | 25 +++++ .../main/java/com/noop/ui/OuraNativeHrv.kt | 41 +++++++++ .../com/noop/ui/OuraHrvComparisonsTest.kt | 92 +++++++++++++++++++ .../com/noop/ui/OuraLiveHrLifecycleTest.kt | 58 ++++++++++++ 7 files changed, 234 insertions(+), 46 deletions(-) create mode 100644 android/app/src/main/java/com/noop/ui/OuraLiveHrLifecycle.kt create mode 100644 android/app/src/test/java/com/noop/ui/OuraHrvComparisonsTest.kt create mode 100644 android/app/src/test/java/com/noop/ui/OuraLiveHrLifecycleTest.kt diff --git a/android/app/src/main/java/com/noop/ui/AppViewModel.kt b/android/app/src/main/java/com/noop/ui/AppViewModel.kt index fb2314a01b..8ef94dd62e 100644 --- a/android/app/src/main/java/com/noop/ui/AppViewModel.kt +++ b/android/app/src/main/java/com/noop/ui/AppViewModel.kt @@ -876,10 +876,16 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { */ private val analyzeKick = Channel(Channel.CONFLATED) /** Android can keep the current Compose destination mounted while the Activity is backgrounded, so - * [realtimeWanters] alone is not proof that anyone can see live HR. Main-thread lifecycle callbacks and - * request/release calls own this flag together. */ + * [realtimeWanters] alone is not proof that anyone can see live HR. NoopRoot's lifecycle observer + * synchronizes this flag even when the ViewModel is created after the Activity has resumed. */ private var appActivityResumed = false + /** Apply the Activity's current lifecycle state without losing the mounted screens' HR demand. */ + fun setActivityResumed(resumed: Boolean) { + appActivityResumed = resumed + noopApp.sourceCoordinator.setOuraLiveHrRequested(resumed && realtimeWanters > 0) + } + /** * #78 hole-4: the app-foreground hook for the bond-loop salvage probe. Every activity resume runs * [WhoopBleClient.salvageProbeIfBondLoopPaused], which no-ops unless the #747 give-up pause is @@ -890,8 +896,6 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { */ private val salvageProbeLifecycleCallbacks = object : Application.ActivityLifecycleCallbacks { override fun onActivityResumed(activity: android.app.Activity) { - appActivityResumed = true - noopApp.sourceCoordinator.setOuraLiveHrRequested(realtimeWanters > 0) ble.salvageProbeIfBondLoopPaused() // #386 self-heal: nudge the analyze loop so a night the killed overnight tick never scored is // caught up now. Gated + coalesced downstream, so a healthy resume costs one fingerprint read. @@ -899,14 +903,8 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { } override fun onActivityCreated(activity: android.app.Activity, savedInstanceState: android.os.Bundle?) {} override fun onActivityStarted(activity: android.app.Activity) {} - override fun onActivityPaused(activity: android.app.Activity) { - appActivityResumed = false - noopApp.sourceCoordinator.setOuraLiveHrRequested(false) - } - override fun onActivityStopped(activity: android.app.Activity) { - appActivityResumed = false - noopApp.sourceCoordinator.setOuraLiveHrRequested(false) - } + override fun onActivityPaused(activity: android.app.Activity) {} + override fun onActivityStopped(activity: android.app.Activity) {} override fun onActivitySaveInstanceState(activity: android.app.Activity, outState: android.os.Bundle) {} override fun onActivityDestroyed(activity: android.app.Activity) {} } diff --git a/android/app/src/main/java/com/noop/ui/HealthScreen.kt b/android/app/src/main/java/com/noop/ui/HealthScreen.kt index 5b2a3f6947..8cebec9900 100644 --- a/android/app/src/main/java/com/noop/ui/HealthScreen.kt +++ b/android/app/src/main/java/com/noop/ui/HealthScreen.kt @@ -83,7 +83,6 @@ import com.noop.analytics.FitnessReadinessItem import com.noop.analytics.FitnessReadinessRole import com.noop.analytics.FitnessReadinessStatus import com.noop.analytics.SkinTempDisplay -import com.noop.analytics.SleepStager import com.noop.analytics.VitalBands import com.noop.ble.LiveState import com.noop.data.DailyMetric @@ -2405,15 +2404,6 @@ private data class OuraHrvSources( val oura: List, ) -/** One NOOP-side value used only in the Oura comparison card. A canonical value came from a detected - * sleep session and is already a DailyMetric; a resting-window fallback is freshly calculated from the - * durable R-R rows spanning Oura's native 0x5D buckets and deliberately does not alter DailyMetric/Charge. */ -private data class NoopHrvComparisonReading( - val day: String, - val value: Double, - val fromRestingWindow: Boolean, -) - private suspend fun loadOuraHrvSources( vm: AppViewModel, days: List, @@ -2465,37 +2455,17 @@ private suspend fun loadOuraHrvSources( // Ring 4's automatic-resting mode supplied real IBI + native RMSSD but no sleep phases in the field // capture. Fill only comparison-card gaps from the SAME resting interval; do not persist these values // as a DailyMetric, do not call it detected sleep, and do not let it affect Charge. - val canonicalDays = canonicalNoop.mapTo(HashSet()) { it.day } - val missing = oura.filter { it.day !in canonicalDays } - val fallbackNoop = if (missing.isEmpty()) { - emptyList() - } else { - val rrFrom = missing.minOf { it.windowStartTs } - val rrTo = missing.maxOf { it.windowEndTs } - val rr = runCatching { - vm.repo.rrIntervalsForDevice(sourceId, rrFrom, rrTo, OURA_HRV_RR_LIMIT) - }.getOrDefault(emptyList()) - withContext(Dispatchers.Default) { - missing.mapNotNull { native -> - SleepStager.sessionAvgHRV(native.windowStartTs, native.windowEndTs, rr)?.let { value -> - NoopHrvComparisonReading( - day = native.day, - value = value, - fromRestingWindow = true, - ) - } - } - } + val comparisons = OuraNativeHrv.loadComparisons(oura, canonicalNoop) { rrFrom, rrTo, limit -> + vm.repo.rrIntervalsForDevice(sourceId, rrFrom, rrTo, limit) } return OuraHrvSources( - noop = (canonicalNoop + fallbackNoop).sortedBy { it.day }, + noop = comparisons, oura = oura, ) } private const val OURA_NATIVE_HRV_EVENT_LIMIT = 250_000 private const val OURA_NATIVE_HRV_SLEEP_LIMIT = 5_000 -private const val OURA_HRV_RR_LIMIT = 250_000 /** Side-by-side values for the newest wake-day both sources share. If no day overlaps yet, each column * shows its own latest day and labels it explicitly, so unlike-looking dates are never presented as a diff --git a/android/app/src/main/java/com/noop/ui/MainActivity.kt b/android/app/src/main/java/com/noop/ui/MainActivity.kt index c2caedba81..a8c7f1e786 100644 --- a/android/app/src/main/java/com/noop/ui/MainActivity.kt +++ b/android/app/src/main/java/com/noop/ui/MainActivity.kt @@ -1552,13 +1552,17 @@ fun NoopRoot() { // onboarding), so this is placed above the onboarding/terms gates rather than duplicated below them. val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current androidx.compose.runtime.DisposableEffect(lifecycleOwner, appViewModel) { + val liveHrLifecycle = OuraLiveHrLifecycle(lifecycleOwner.lifecycle, appViewModel::setActivityResumed) val observer = androidx.lifecycle.LifecycleEventObserver { _, event -> if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) { appViewModel.ble.onForeground() } } lifecycleOwner.lifecycle.addObserver(observer) - onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + liveHrLifecycle.close() + } } var onboarded by remember { diff --git a/android/app/src/main/java/com/noop/ui/OuraLiveHrLifecycle.kt b/android/app/src/main/java/com/noop/ui/OuraLiveHrLifecycle.kt new file mode 100644 index 0000000000..658857cdb2 --- /dev/null +++ b/android/app/src/main/java/com/noop/ui/OuraLiveHrLifecycle.kt @@ -0,0 +1,25 @@ +package com.noop.ui + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner + +/** Follows the Activity's current resumed state, including registration after a cold-launch resume. + * Lifecycle replays the current state to a new observer; Application callbacks only report future events. */ +internal class OuraLiveHrLifecycle( + private val lifecycle: Lifecycle, + private val onResumedChanged: (Boolean) -> Unit, +) : DefaultLifecycleObserver, AutoCloseable { + init { + lifecycle.addObserver(this) + } + + override fun onResume(owner: LifecycleOwner) = onResumedChanged(true) + + override fun onPause(owner: LifecycleOwner) = onResumedChanged(false) + + override fun close() { + lifecycle.removeObserver(this) + onResumedChanged(false) + } +} diff --git a/android/app/src/main/java/com/noop/ui/OuraNativeHrv.kt b/android/app/src/main/java/com/noop/ui/OuraNativeHrv.kt index 3af5d03ea4..a7f1540f49 100644 --- a/android/app/src/main/java/com/noop/ui/OuraNativeHrv.kt +++ b/android/app/src/main/java/com/noop/ui/OuraNativeHrv.kt @@ -1,10 +1,15 @@ package com.noop.ui +import com.noop.analytics.SleepStager import com.noop.data.EventRow import com.noop.data.OuraStreamMapping +import com.noop.data.RrInterval import com.noop.data.SleepSession import java.time.Instant import java.time.ZoneId +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.json.JSONObject /** One wake-day summary of the Oura ring's own open 0x5D five-minute RMSSD buckets. */ @@ -18,6 +23,14 @@ internal data class OuraNativeHrvReading( val windowEndTs: Long, ) +/** One NOOP-side value used only in the Oura comparison card. Canonical values come from DailyMetric; + * resting-window fallbacks use raw R-R and never alter DailyMetric or Charge. */ +internal data class NoopHrvComparisonReading( + val day: String, + val value: Double, + val fromRestingWindow: Boolean, +) + /** * UI-only comparison adapter for Oura's native RMSSD stream. * @@ -30,6 +43,33 @@ internal data class OuraNativeHrvReading( * measurement visible and provides its exact interval to the separately-labelled NOOP RMSSD comparator. */ internal object OuraNativeHrv { + /** Fill comparison gaps one resting window at a time. A single oldest-first capped query over the + * whole history discards recent nights once earlier beats consume its limit. Per-window reads also + * keep only one night's raw beats in memory and preserve every canonical NOOP reading. */ + suspend fun loadComparisons( + native: List, + canonical: List, + readRr: suspend (from: Long, to: Long, limit: Int) -> List, + ): List { + val canonicalDays = canonical.mapTo(HashSet()) { it.day } + val missing = native.filter { it.day !in canonicalDays } + val comparisons = canonical.toMutableList() + for (reading in missing) { + val rr = try { + readRr(reading.windowStartTs, reading.windowEndTs, RR_LIMIT_PER_WINDOW) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + continue + } + val value = withContext(Dispatchers.Default) { + SleepStager.sessionAvgHRV(reading.windowStartTs, reading.windowEndTs, rr) + } ?: continue + comparisons.add(NoopHrvComparisonReading(reading.day, value, fromRestingWindow = true)) + } + return comparisons.sortedBy { it.day } + } + fun aggregate( events: List, sleepSessions: List, @@ -89,4 +129,5 @@ internal object OuraNativeHrv { }.getOrNull() private const val FIVE_MINUTES_SECONDS = 5L * 60L + private const val RR_LIMIT_PER_WINDOW = 250_000 } diff --git a/android/app/src/test/java/com/noop/ui/OuraHrvComparisonsTest.kt b/android/app/src/test/java/com/noop/ui/OuraHrvComparisonsTest.kt new file mode 100644 index 0000000000..8b46342a49 --- /dev/null +++ b/android/app/src/test/java/com/noop/ui/OuraHrvComparisonsTest.kt @@ -0,0 +1,92 @@ +package com.noop.ui + +import com.noop.data.RrInterval +import java.time.Instant +import java.time.ZoneOffset +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +class OuraHrvComparisonsTest { + private val start = Instant.parse("2026-09-01T23:00:00Z").epochSecond + + private fun reading(offset: Int, duration: Long = 8 * 3600L): OuraNativeHrvReading { + val from = start + offset * 86400L + val to = from + duration + return OuraNativeHrvReading( + day = Instant.ofEpochSecond(to).atZone(ZoneOffset.UTC).toLocalDate().toString(), + valueMs = 50.0, bucketCount = (duration / 300).toInt(), + windowStartTs = from, windowEndTs = to, + ) + } + + private fun beats(reading: OuraNativeHrvReading, variation: Int = 20): List = + (reading.windowStartTs until reading.windowEndTs).mapIndexed { index, ts -> + RrInterval("oura-ring", ts, if (index % 2 == 0) 1000 - variation else 1000 + variation) + } + + @Test + fun `newest comparison survives more than 250000 historical beats`() = runTest { + val native = (0 until 12).map { reading(it) } + val stored = native.flatMapIndexed { index, reading -> beats(reading, variation = 20 + index) } + assertTrue(stored.size > 250_000) + + val comparisons = OuraNativeHrv.loadComparisons(native, emptyList()) { from, to, limit -> + // Match the repository's inclusive time filter and oldest-first LIMIT behavior. + stored.asSequence().filter { it.ts in from..to }.take(limit).toList() + } + + assertEquals(native.map { it.day }, comparisons.map { it.day }) + comparisons.forEachIndexed { index, result -> + assertEquals(40.0 + 2 * index, result.value, 1e-9) + assertTrue(result.fromRestingWindow) + } + } + + @Test + fun `canonical readings are preserved without reading raw beats for those days`() = runTest { + val native = listOf(reading(0, duration = 600), reading(1, duration = 600)) + val canonical = NoopHrvComparisonReading(native.first().day, 73.0, fromRestingWindow = false) + val requested = mutableListOf>() + + val comparisons = OuraNativeHrv.loadComparisons(native, listOf(canonical)) { from, to, _ -> + requested.add(from to to) + beats(native.last()) + } + + assertEquals(listOf(native.last().windowStartTs to native.last().windowEndTs), requested) + assertEquals(canonical, comparisons.first()) + assertEquals(40.0, comparisons.last().value, 1e-9) + assertTrue(comparisons.last().fromRestingWindow) + } + + @Test + fun `a failed or empty window does not hide a later valid comparison`() = runTest { + val native = (0 until 3).map { reading(it, duration = 600) } + val comparisons = OuraNativeHrv.loadComparisons(native, emptyList()) { from, _, _ -> + when (from) { + native[0].windowStartTs -> throw IllegalStateException("read failed") + native[1].windowStartTs -> emptyList() + else -> beats(native[2]) + } + } + assertEquals(listOf(NoopHrvComparisonReading(native[2].day, 40.0, true)), comparisons) + } + + @Test + fun `leaving the screen cancels further window reads`() = runTest { + var reads = 0 + try { + OuraNativeHrv.loadComparisons(listOf(reading(0), reading(1)), emptyList()) { _, _, _ -> + reads++ + throw CancellationException("screen closed") + } + fail("cancellation must reach the caller") + } catch (_: CancellationException) { + assertEquals(1, reads) + } + } +} diff --git a/android/app/src/test/java/com/noop/ui/OuraLiveHrLifecycleTest.kt b/android/app/src/test/java/com/noop/ui/OuraLiveHrLifecycleTest.kt new file mode 100644 index 0000000000..280858c223 --- /dev/null +++ b/android/app/src/test/java/com/noop/ui/OuraLiveHrLifecycleTest.kt @@ -0,0 +1,58 @@ +package com.noop.ui + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class OuraLiveHrLifecycleTest { + private class Owner : LifecycleOwner { + val registry = LifecycleRegistry.createUnsafe(this) + override val lifecycle: Lifecycle get() = registry + } + + @Test + fun `late observer immediately sees the already resumed activity`() { + val owner = Owner() + owner.registry.currentState = Lifecycle.State.RESUMED + val resumed = mutableListOf() + + OuraLiveHrLifecycle(owner.lifecycle, resumed::add).use { + // No second ON_RESUME is sent: this is the cold-launch first-composition ordering. + assertEquals(listOf(true), resumed) + } + assertEquals(listOf(true, false), resumed) + } + + @Test + fun `background and foreground transitions update mounted screen demand`() { + val owner = Owner() + owner.registry.currentState = Lifecycle.State.STARTED + val resumed = mutableListOf() + + OuraLiveHrLifecycle(owner.lifecycle, resumed::add).use { + assertFalse(resumed.any { it }) + owner.registry.currentState = Lifecycle.State.RESUMED + owner.registry.currentState = Lifecycle.State.STARTED + owner.registry.currentState = Lifecycle.State.RESUMED + owner.registry.currentState = Lifecycle.State.DESTROYED + assertEquals(listOf(true, false, true, false), resumed) + } + } + + @Test + fun `disposed root releases demand and stops observing the old activity`() { + val owner = Owner() + owner.registry.currentState = Lifecycle.State.RESUMED + val resumed = mutableListOf() + val observer = OuraLiveHrLifecycle(owner.lifecycle, resumed::add) + + observer.close() + owner.registry.currentState = Lifecycle.State.STARTED + owner.registry.currentState = Lifecycle.State.RESUMED + + assertEquals(listOf(true, false), resumed) + } +} From b44d0a97b6a3d6f244342c7f8c21edf341baab24 Mon Sep 17 00:00:00 2001 From: llmassisted Date: Sat, 26 Sep 2026 19:36:11 -0400 Subject: [PATCH 6/6] feat(oura): store the ring's MET stream and show an activity estimate The 0x50 activity/MET stream was decoded and logged but never stored, and the ring trims its copy after each acknowledged drain. On Android: - Persist each anchored 0x50 record as an OURA_MET event row (met_x10 exact tenths, sec_per_sample, state), the same event-table path as OURA_MOTION. Never scored, never a step count. - Import the pre-existing on-device activity sidecar (oura-activity-*.jsonl, written since #676) once into the same rows; the (deviceId, ts, kind) PK dedups it against live writes. Waits for the ring's stable serial id. - Port the Swift OuraActivityEstimator; byte-identity is pinned by an oracle test against the Swift source compiled standalone over 300 inputs. - Health screen card (only when OURA_MET rows exist): today's active minutes (>= 3 MET), MET-minutes and estimated above-resting kcal from the profile weight, plus the last 7 days, labelled as an estimate. Within-record sample order is not pinned (documented); a sample's time is good to within its record's ~13 min span. Strings in all shipped locales; platform_specific parity dispositions; versionCode 538. Co-Authored-By: Claude Opus 5.5 (1M context) --- Tools/parity_dispositions.json | 104 ++++++ Tools/parity_twin_map.json | 12 +- android/app/build.gradle.kts | 2 +- .../main/java/com/noop/ble/OuraLiveSource.kt | 10 +- .../java/com/noop/data/OuraMetBackfill.kt | 79 +++++ .../java/com/noop/data/OuraStreamMapping.kt | 34 +- .../java/com/noop/data/WhoopRepository.kt | 6 + .../java/com/noop/oura/ActivityEstimate.kt | 94 ++++++ .../src/main/java/com/noop/ui/AppViewModel.kt | 18 ++ .../src/main/java/com/noop/ui/HealthScreen.kt | 101 ++++++ .../src/main/java/com/noop/ui/MainActivity.kt | 10 + .../main/java/com/noop/ui/OuraActivityDays.kt | 55 ++++ .../app/src/main/res/values-de/strings.xml | 11 + .../app/src/main/res/values-es/strings.xml | 11 + .../app/src/main/res/values-fr/strings.xml | 11 + .../app/src/main/res/values-pl/strings.xml | 11 + .../src/main/res/values-pt-rPT/strings.xml | 11 + .../app/src/main/res/values-ru/strings.xml | 11 + .../app/src/main/res/values-zh/strings.xml | 11 + android/app/src/main/res/values/strings.xml | 11 + .../com/noop/data/OuraMetPersistenceTest.kt | 89 ++++++ .../com/noop/data/OuraStreamMappingTest.kt | 8 +- .../noop/oura/OuraActivityEstimatorTest.kt | 78 +++++ .../oura_activity_estimate_swift_oracle.txt | 300 ++++++++++++++++++ docs/OURA_PROTOCOL.md | 7 +- 25 files changed, 1076 insertions(+), 19 deletions(-) create mode 100644 android/app/src/main/java/com/noop/data/OuraMetBackfill.kt create mode 100644 android/app/src/main/java/com/noop/oura/ActivityEstimate.kt create mode 100644 android/app/src/main/java/com/noop/ui/OuraActivityDays.kt create mode 100644 android/app/src/test/java/com/noop/data/OuraMetPersistenceTest.kt create mode 100644 android/app/src/test/java/com/noop/oura/OuraActivityEstimatorTest.kt create mode 100644 android/app/src/test/resources/oura_activity_estimate_swift_oracle.txt diff --git a/Tools/parity_dispositions.json b/Tools/parity_dispositions.json index 6df52bc3bb..1d3494dbda 100644 --- a/Tools/parity_dispositions.json +++ b/Tools/parity_dispositions.json @@ -320,6 +320,110 @@ "identity_sha256": "c0190e47d1c92bead02bb9ee79ff344b057dd63c7fff4a63a47afb9d767737b2", "platform": "kotlin", "rationale": "Android-only decode of the ring's 2f 03 23 feature-mode verdict (OURA_PROTOCOL.md s7.5), so a Test Centre write logs accepted / NOT_SUPPORTED / NOT_AVAILABLE instead of being routed as a live-HR ack. Diagnostic only; never scored or stored." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-constant", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/data/OuraMetBackfill.kt::DUMP_PREFIX", + "identity_sha256": "0d33b521f08ea26fcaa32bc593819f985d39504a1fbfc4a2f27e91e6abe906eb", + "platform": "kotlin", + "rationale": "Android-only one-time import of the fork's own on-device 0x50 activity sidecar (oura-activity-*.jsonl, written since #676) into the same OURA_MET rows the live path writes. The sidecar and its history exist only on this Android install." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-constant", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/data/OuraMetBackfill.kt::INSERT_CHUNK", + "identity_sha256": "cedf18f01fdd69d11dcebb8f6d29736688934c2e8c859cf46323ff45955a6b48", + "platform": "kotlin", + "rationale": "Android-only one-time import of the fork's own on-device 0x50 activity sidecar (oura-activity-*.jsonl, written since #676) into the same OURA_MET rows the live path writes. The sidecar and its history exist only on this Android install." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-constant", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/data/OuraStreamMapping.kt::EVENT_MET", + "identity_sha256": "3702073d2dab5b2c20b365dcfc773a06952a44b0d9f6ab436a7e417a5006316f", + "platform": "kotlin", + "rationale": "Android-only durable storage of the ring's 0x50 MET stream as OURA_MET estimate rows for the fork's Health activity card (OURA_PROTOCOL.md s6.13). Tier B: never scored, never a step count. The Swift app still logs 0x50 only; this fork ships Android." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-constant", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/data/OuraStreamMapping.kt::MET_SECONDS_PER_SAMPLE", + "identity_sha256": "c703e4bb358a388a6843f197aef452e14ba58b7d83f2d5c3a257c6548e1de979", + "platform": "kotlin", + "rationale": "Android-only durable storage of the ring's 0x50 MET stream as OURA_MET estimate rows for the fork's Health activity card (OURA_PROTOCOL.md s6.13). Tier B: never scored, never a step count. The Swift app still logs 0x50 only; this fork ships Android." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-file", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/data/OuraMetBackfill.kt", + "identity_sha256": "b24d18f0ca1e783afd71df69c23549a7764dd55b41d898788bb2753d22607d88", + "platform": "kotlin", + "rationale": "Android-only one-time import of the fork's own on-device 0x50 activity sidecar (oura-activity-*.jsonl, written since #676) into the same OURA_MET rows the live path writes. The sidecar and its history exist only on this Android install." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-file", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/oura/ActivityEstimate.kt", + "identity_sha256": "cc6b55c5d4f6cb4bd87710c9ffd9f47553a66487736b91d93ae3631ecbab26a2", + "platform": "kotlin", + "rationale": "Kotlin twin of the Swift OuraActivityEstimator (Packages/OuraProtocol/.../ActivityEstimate.swift). Byte-identical output is pinned by OuraActivityEstimatorTest against the verbatim stdout of the Swift source compiled standalone over 300 generated inputs; the ledger cannot pair it by name because both platforms overload `estimate`." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/data/OuraMetBackfill.kt::dumpFiles/1#1", + "identity_sha256": "94b7d9871f0ea80fc89e789c580a738ff62d3ddaee055de1b0528046ea03922f", + "platform": "kotlin", + "rationale": "Android-only one-time import of the fork's own on-device 0x50 activity sidecar (oura-activity-*.jsonl, written since #676) into the same OURA_MET rows the live path writes. The sidecar and its history exist only on this Android install." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/data/OuraMetBackfill.kt::parseLine/1#1", + "identity_sha256": "7b3d9524e61591747a961e959d8cab70dacddca4776ad230a59f19ef4d9c74a8", + "platform": "kotlin", + "rationale": "Android-only one-time import of the fork's own on-device 0x50 activity sidecar (oura-activity-*.jsonl, written since #676) into the same OURA_MET rows the live path writes. The sidecar and its history exist only on this Android install." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/data/OuraMetBackfill.kt::run/4#1", + "identity_sha256": "280ea8b630c60d20ade06884011493b7bda17a7c6b4f05dfc316f49e4929376a", + "platform": "kotlin", + "rationale": "Android-only one-time import of the fork's own on-device 0x50 activity sidecar (oura-activity-*.jsonl, written since #676) into the same OURA_MET rows the live path writes. The sidecar and its history exist only on this Android install." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/data/WhoopRepository.kt::insertEventRows/1#1", + "identity_sha256": "4560439943fdcc1876d280dda92d98ec465793b0e397cc0782846de4b8b31aca", + "platform": "kotlin", + "rationale": "Android-only durable storage of the ring's 0x50 MET stream as OURA_MET estimate rows for the fork's Health activity card (OURA_PROTOCOL.md s6.13). Tier B: never scored, never a step count. The Swift app still logs 0x50 only; this fork ships Android." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/oura/ActivityEstimate.kt::estimate/4#1", + "identity_sha256": "556604d859be90f3685dd20286061a71300d78e897c95ac41b2ba2d3c1dd1c6b", + "platform": "kotlin", + "rationale": "Kotlin twin of the Swift OuraActivityEstimator (Packages/OuraProtocol/.../ActivityEstimate.swift). Byte-identical output is pinned by OuraActivityEstimatorTest against the verbatim stdout of the Swift source compiled standalone over 300 generated inputs; the ledger cannot pair it by name because both platforms overload `estimate`." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/oura/ActivityEstimate.kt::estimate/4#2", + "identity_sha256": "6a034dd744068988a1f985c43d89a163108db3e24aad686a8fef2fa2c488ca61", + "platform": "kotlin", + "rationale": "Kotlin twin of the Swift OuraActivityEstimator (Packages/OuraProtocol/.../ActivityEstimate.swift). Byte-identical output is pinned by OuraActivityEstimatorTest against the verbatim stdout of the Swift source compiled standalone over 300 generated inputs; the ledger cannot pair it by name because both platforms overload `estimate`." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/oura/ActivityEstimate.kt::r2/1#1", + "identity_sha256": "66bff0ba4ddae17d3628a64dcd7e16e27d5f6b979b0971f36a92d5111b379bb9", + "platform": "kotlin", + "rationale": "Kotlin twin of the Swift OuraActivityEstimator (Packages/OuraProtocol/.../ActivityEstimate.swift). Byte-identical output is pinned by OuraActivityEstimatorTest against the verbatim stdout of the Swift source compiled standalone over 300 generated inputs; the ledger cannot pair it by name because both platforms overload `estimate`." } ] } diff --git a/Tools/parity_twin_map.json b/Tools/parity_twin_map.json index 822c6749f0..f7e7b3677e 100644 --- a/Tools/parity_twin_map.json +++ b/Tools/parity_twin_map.json @@ -18,17 +18,17 @@ ] }, "authority": { - "files": {"count": 504, "sha256": "437bdd89fb050111f11a2229768e5344d04115c91f5cc7670b27890f097d851f"}, - "functions": {"count": 4496, "sha256": "67673b5b2bd3fe1686693014e17eff3bf86e293e16c4a350d7f801987e197a2a"}, + "files": {"count": 506, "sha256": "7c1aa896ae06001a3869be1164233da7d6af9f909334334a917be56b4832c511"}, + "functions": {"count": 4503, "sha256": "3cd89d9b79d6228b0e44fc2c89b272eb76a54a903e805bcec142f6da416cbce5"}, "properties": {"count": 464, "sha256": "85338b70168a36af785255eb8959a41272cc02334529c2e3ef2a7d18444c7ef7"}, - "constants": {"count": 1964, "sha256": "7fcab29530e8be337e314bd046c82288adf847cc116ea6c7850c2b6ec348b10c"}, + "constants": {"count": 1968, "sha256": "2c88ab0d322028ad38a0bdf4118894231ecbb7200ab28afe095e5de336a8d7bb"}, "file_pairs": {"count": 74, "sha256": "1023813dc72dbe4122281d5bae27ac2b7a657dae5904cb5fd603032928199f94"}, "function_pairs": {"count": 189, "sha256": "11df8bf87b16b535aeddd0c00471f87431236fd0b059484c07f6d5939ace9732"}, "property_pairs": {"count": 149, "sha256": "8a62616eca6270e3d8899c47cfe8ae93691f16846ed1959e19a762827b6ec185"}, "constant_pairs": {"count": 681, "sha256": "3484c8096ac8d2832e75f18900c052a19bfb7d92cbc2fa7fdb6137d8fa46ebf6"}, - "unpaired_files": {"count": 378, "sha256": "468445b59f27e4f2b277d58c119d76d7364038f51f50b1ce9053f1b7f514246e"}, - "unpaired_functions": {"count": 4130, "sha256": "240865c1d6af56a3935869f899b236ce127951d73f09405bfbd1dd62a628d68b"}, + "unpaired_files": {"count": 380, "sha256": "6e9cd41c3ee7c16a244e5ef95ff80cb31b27838afbab860416754bc91559adfe"}, + "unpaired_functions": {"count": 4137, "sha256": "e4abeab38983324cb9716af8d6be4518ca80ce123f13785ee27d2795be306a00"}, "unpaired_properties": {"count": 166, "sha256": "280d96f0905fd623dfb66af86c99c3e82409b126a45aaf9e008eb8f6cf04671a"}, - "unpaired_constants": {"count": 602, "sha256": "ba7eeffc907b91b7a154d26b498d1ab5d9ace0ffe03ae2d6151ec5ed4e2dce13"} + "unpaired_constants": {"count": 606, "sha256": "d334a29da52e658603db294deb3ff23e9625d682b1914703fc3bb09ebc97ac9a"} } } diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 8a0ff68157..6340975375 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -26,7 +26,7 @@ android { applicationId = "com.noop.whoop" minSdk = 26 targetSdk = 34 - versionCode = 536 + versionCode = 538 versionName = "11.8.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt b/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt index 2698c88eb9..2892e082b0 100644 --- a/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt +++ b/android/app/src/main/java/com/noop/ble/OuraLiveSource.kt @@ -2629,12 +2629,12 @@ class OuraLiveSource( } } is OuraEvent.ActivityInfo -> { - // INVESTIGATION ONLY (0x50 activity/MET, Tier B - a plausible third-party formula, NOT - // ground-truth-validated; see OuraActivityInfo). Logged with the DECODED state/MET values - // every time (not once-per-kind): this is the tag under active plausibility evaluation, so - // every real capture is evidence. Never persisted, never scored, and NEVER converted into - // steps (MET is not a step count; OuraStreamMapping drops ActivityInfo unconditionally). + // 0x50 activity/MET, Tier B - a plausible third-party formula, NOT ground-truth-validated; + // see OuraActivityInfo. Logged with the DECODED state/MET values every time, and persisted + // as an OURA_MET estimate row (OuraStreamMapping) that the Health activity card aggregates. + // Never scored, and NEVER converted into steps (MET is not a step count, s6.13). log("Oura: activity (Tier-B) state=${e.value.state} met=${e.value.met}") + enqueueAnchoredOrPark(e, e.value.ringTimestamp, d) // Append the raw record to the Tier-B research corpus (anchored records only; deduped by // ring-time in the writer). Diagnostic sidecar - never persisted to the DB, never scored. d.unixSeconds(forRingTimestamp = e.value.ringTimestamp)?.let { utc -> diff --git a/android/app/src/main/java/com/noop/data/OuraMetBackfill.kt b/android/app/src/main/java/com/noop/data/OuraMetBackfill.kt new file mode 100644 index 0000000000..f45f2f2848 --- /dev/null +++ b/android/app/src/main/java/com/noop/data/OuraMetBackfill.kt @@ -0,0 +1,79 @@ +package com.noop.data + +import java.io.File +import org.json.JSONObject + +/** + * One-time import of the Oura 0x50 MET history that predates durable OURA_MET rows. Since #676 every + * anchored 0x50 record was appended to the diagnostic sidecar `/diagnostics/oura-activity-*.jsonl` + * (schema 1, [com.noop.oura.OuraActivityDumpLine]) but never stored; the ring trims its own copy once a drain + * is acknowledged, so that sidecar is the only remaining record. Each line becomes the SAME row the live path + * now writes ([OuraStreamMapping.EVENT_MET], identical canonical payload), so a record present in both lands + * once: the event PK is (deviceId, ts, kind) with insert-or-ignore, and both paths stamp the record with the + * same ring-time anchor. Tier-B estimate input only — never scored. + */ +object OuraMetBackfill { + private const val DUMP_PREFIX = "oura-activity-" + private const val INSERT_CHUNK = 2_000 + + /** Parse one sidecar line into its row `(utc, payloadJSON)`, or null for a malformed / unknown-schema / + * empty record. Pure (JVM-testable); the payload is encoded by the live path's canonical encoder. */ + fun parseLine(line: String): Pair? { + val o = runCatching { JSONObject(line) }.getOrNull() ?: return null + if (o.optInt("schema", -1) != 1) return null + val utc = o.optLong("utc", -1L) + if (utc <= 0L) return null + val arr = o.optJSONArray("met") ?: return null + if (arr.length() == 0) return null + val metX10 = (0 until arr.length()).map { Math.round(arr.getDouble(it) * 10).toInt() } + val payload = StreamPersistence.encodePayload( + linkedMapOf( + "state" to o.optInt("state", 0), + "sec_per_sample" to o.optInt("secPerSample", OuraStreamMapping.MET_SECONDS_PER_SAMPLE), + "met_x10" to metX10, + ), + ) + return utc to payload + } + + /** The sidecar files to import: every per-device corpus plus its single rotated generation. */ + fun dumpFiles(filesDir: File): List = + File(filesDir, "diagnostics").listFiles() + ?.filter { it.isFile && it.name.startsWith(DUMP_PREFIX) && (it.name.endsWith(".jsonl") || it.name.endsWith(".jsonl.1")) } + ?.sortedBy { it.name } + .orEmpty() + + /** + * Import every sidecar record under [deviceId] (the active ring's stable id — the ring the Health card + * reads). Returns the number of parsed records offered to the store (duplicates are ignored there). + */ + suspend fun run( + filesDir: File, + deviceId: String, + insert: suspend (List) -> Unit, + log: (String) -> Unit, + ): Int { + var offered = 0 + var skipped = 0 + val batch = ArrayList(INSERT_CHUNK) + for (file in dumpFiles(filesDir)) { + file.bufferedReader().useLines { lines -> + for (line in lines) { + if (line.isBlank()) continue + val parsed = parseLine(line) + if (parsed == null) { + skipped += 1 + continue + } + batch.add(EventRow(deviceId, parsed.first, OuraStreamMapping.EVENT_MET, parsed.second)) + offered += 1 + } + } + // Flush per file (and in chunks) so one corrupt file cannot hold the whole import in memory. + for (chunk in batch.chunked(INSERT_CHUNK)) insert(chunk) + batch.clear() + } + log("Oura: MET backfill imported $offered record(s) from the activity sidecar ($skipped unparseable) under $deviceId") + return offered + } +} diff --git a/android/app/src/main/java/com/noop/data/OuraStreamMapping.kt b/android/app/src/main/java/com/noop/data/OuraStreamMapping.kt index ab72610f8d..7339dba0ad 100644 --- a/android/app/src/main/java/com/noop/data/OuraStreamMapping.kt +++ b/android/app/src/main/java/com/noop/data/OuraStreamMapping.kt @@ -43,6 +43,13 @@ object OuraStreamMapping { /** The event `kind` for a decoded 0x47 motion window (activity instrumentation). Must match Swift. */ const val EVENT_MOTION = "OURA_MOTION" + /** The event `kind` for a decoded 0x50 activity/MET record (Tier-B activity ESTIMATE, never scored). */ + const val EVENT_MET = "OURA_MET" + + /** Assumed per-sample MET cadence (OURA_PROTOCOL.md s6.13: one sample per minute, re-confirmed by an + * 86-sample 86-minute walk). Stored on every row so a reader never has to assume it. */ + const val MET_SECONDS_PER_SAMPLE = 60 + /** * Fold a batch of decoded [events] into a protocol [Streams] for one flush. [anchor] maps a * ring-clock timestamp to wall-clock unix seconds (null => drop the sample). Pure: no BLE, no DB, @@ -216,9 +223,30 @@ object OuraStreamMapping { out.events.add(WhoopEvent(ts = ts, kind = EVENT_MOTION, payload = payload)) } - // Motion / state / time-sync / rtc / debug / TierB / ActivityInfo / RealStepsFields never - // map onto a scored stream. In particular the 0x50 activity/MET decode (PR #960) NEVER - // mints a `steps` row: the formula is third-party and unvalidated (Tier B, OURA_PROTOCOL.md + is OuraEvent.ActivityInfo -> { + // 0x50 activity record → one OURA_MET event at the record's anchored ts, carrying its + // MET series VERBATIM as integer tenths (every decoded value is a multiple of 0.1, so + // `met_x10` is exact where a Double list would carry binary noise). Tier B: the decode + // formula is third-party, so this is an activity ESTIMATE input — surfaced labelled as + // one, never scored, never a step count (s6.13). Which end of the record is the oldest + // minute is not pinned, so a reader must treat within-record timing as ±the record span. + val ts = anchor(ev.value.ringTimestamp) ?: continue + out.events.add( + WhoopEvent( + ts = ts, + kind = EVENT_MET, + payload = linkedMapOf( + "state" to ev.value.state, + "sec_per_sample" to MET_SECONDS_PER_SAMPLE, + "met_x10" to ev.value.met.map { Math.round(it * 10).toInt() }, + ), + ), + ) + } + + // Motion / state / time-sync / rtc / debug / TierB / RealStepsFields never map onto a + // scored stream (ActivityInfo is stored above as an OURA_MET estimate row, nothing more). + // In particular the 0x50 activity/MET decode (PR #960) NEVER mints a `steps` row: the formula is third-party and unvalidated (Tier B, OURA_PROTOCOL.md // s6.13), and MET is not a step count - fabricating one would break the honest-data // invariant and the per-source day-owner rules. Same discipline for 0x7E/0x7F real_steps // (s6.13) - decoded, logged, never scored: ground truth showed no field is a step count, diff --git a/android/app/src/main/java/com/noop/data/WhoopRepository.kt b/android/app/src/main/java/com/noop/data/WhoopRepository.kt index 994b3891a7..a78679efda 100644 --- a/android/app/src/main/java/com/noop/data/WhoopRepository.kt +++ b/android/app/src/main/java/com/noop/data/WhoopRepository.kt @@ -1132,6 +1132,12 @@ class WhoopRepository( pushCount = row.pushCount, easeCount = row.easeCount, hrSource = row.hrSource, ) + /** Batch-insert event rows (insert-or-ignore on the (deviceId, ts, kind) PK). Used by the one-time + * Oura MET sidecar import, [OuraMetBackfill]. */ + suspend fun insertEventRows(rows: List) { + if (rows.isNotEmpty()) dao.insertEvents(rows) + } + /** #1410: append one app-level event (e.g. APP_VERSION_CHANGED) onto the event table. */ suspend fun recordEvent(deviceId: String, ts: Long, kind: String, payloadJSON: String) { dao.insertEvents(listOf(EventRow(deviceId, ts, kind, payloadJSON))) diff --git a/android/app/src/main/java/com/noop/oura/ActivityEstimate.kt b/android/app/src/main/java/com/noop/oura/ActivityEstimate.kt new file mode 100644 index 0000000000..c7c34f7f52 --- /dev/null +++ b/android/app/src/main/java/com/noop/oura/ActivityEstimate.kt @@ -0,0 +1,94 @@ +package com.noop.oura + +// ActivityEstimate: aggregate the ring's 0x50 activity_info MET stream into a clearly-labeled activity +// estimate (active minutes / MET-minutes / active energy). Kotlin twin of the Swift ActivityEstimate.swift — +// the arithmetic and the 2 dp rounding must stay byte-identical (pinned by OuraActivityEstimatorTest's +// swiftc oracle). Derived only from already-decoded MET samples, never from a minted step count. The MET +// decode formula is third-party and NOT ground-truth-validated, so everything here stays Tier B: surfaced as +// an estimate, never folded into scoring. Facts per OURA_PROTOCOL.md s6.13. + +/** + * A MET-derived activity estimate for a set of 0x50 samples (one window, one day, whatever the caller + * buckets). Every field is an ESTIMATE; the two energy fields are null when no body mass is supplied. + */ +data class OuraActivityEstimate( + /** Number of MET samples aggregated (each 0x50 record contributes `met.size` of them). */ + val sampleCount: Int, + /** The per-sample epoch length assumed for the minute/energy totals (the ring's cadence, 60 s). */ + val epochSeconds: Double, + /** Mean MET across all samples (cadence-independent). */ + val meanMET: Double, + /** Peak MET across all samples (cadence-independent). */ + val maxMET: Double, + /** Σ metᵢ × epochMinutes — standard MET-minutes of activity. */ + val metMinutes: Double, + /** Minutes whose MET ≥ the moderate threshold (default 3.0), = qualifying samples × epochMinutes. */ + val activeMinutes: Double, + /** Estimated ABOVE-RESTING energy Σ max(metᵢ − 1, 0) × massKg × epochHours (kcal); null without mass. */ + val estActiveKcal: Double?, + /** Estimated GROSS energy Σ metᵢ × massKg × epochHours (kcal), basal included; null without mass. */ + val estTotalKcal: Double?, +) + +/** Pure MET-stream aggregation. No database, no BLE, no clock — the caller decides which samples belong to + * the window/day and passes them in. */ +object OuraActivityEstimator { + /** + * Aggregate raw MET samples into an estimate. An empty input yields an all-zero estimate (never null — + * "no activity" is a real answer). Scalars are rounded to 2 dp, half away from zero like Swift's + * `rounded()` (all inputs here are non-negative, where that equals [Math.round]). + */ + fun estimate( + metSamples: List, + epochSeconds: Double, + bodyMassKg: Double? = null, + moderateThresholdMET: Double = 3.0, + ): OuraActivityEstimate { + val epochMinutes = epochSeconds / 60.0 + val epochHours = epochSeconds / 3600.0 + val count = metSamples.size + + val sum = metSamples.fold(0.0) { acc, m -> acc + m } + val mean = if (count > 0) sum / count else 0.0 + val peak = metSamples.maxOrNull() ?: 0.0 + + val metMinutes = sum * epochMinutes + val activeCount = metSamples.count { it >= moderateThresholdMET } + val activeMinutes = activeCount * epochMinutes + + val activeKcal: Double? + val totalKcal: Double? + if (bodyMassKg != null) { + // Active = above-resting (1-MET basal floor removed, clamped at 0); total = gross incl. basal. + val aboveResting = metSamples.fold(0.0) { acc, m -> acc + maxOf(m - 1.0, 0.0) } + activeKcal = aboveResting * bodyMassKg * epochHours + totalKcal = sum * bodyMassKg * epochHours + } else { + activeKcal = null + totalKcal = null + } + + fun r2(x: Double): Double = Math.round(x * 100) / 100.0 + return OuraActivityEstimate( + sampleCount = count, + epochSeconds = epochSeconds, + meanMET = r2(mean), + maxMET = r2(peak), + metMinutes = r2(metMinutes), + activeMinutes = r2(activeMinutes), + estActiveKcal = activeKcal?.let(::r2), + estTotalKcal = totalKcal?.let(::r2), + ) + } + + /** Convenience over decoded 0x50 records: flattens every record's `met` series and aggregates. The JVM + * name only avoids the List-erasure clash with the sample overload; Kotlin callers see `estimate`. */ + @JvmName("estimateFromRecords") + fun estimate( + records: List, + epochSeconds: Double, + bodyMassKg: Double? = null, + moderateThresholdMET: Double = 3.0, + ): OuraActivityEstimate = + estimate(records.flatMap { it.met }, epochSeconds, bodyMassKg, moderateThresholdMET) +} diff --git a/android/app/src/main/java/com/noop/ui/AppViewModel.kt b/android/app/src/main/java/com/noop/ui/AppViewModel.kt index 8ef94dd62e..eea0b24053 100644 --- a/android/app/src/main/java/com/noop/ui/AppViewModel.kt +++ b/android/app/src/main/java/com/noop/ui/AppViewModel.kt @@ -53,6 +53,7 @@ import com.noop.protocol.CommandNumber import com.noop.widget.WidgetSnapshot import com.noop.widget.WidgetSnapshotStore import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay @@ -929,6 +930,23 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { // export can answer "what ran when". Idempotent — the stored last-seen version only advances // once the transition is recorded, so a background-only launch is caught on the next UI open. viewModelScope.launch { recordAppVersionChange() } + // One-time import of the Oura MET history that only ever reached the diagnostic activity sidecar + // (OuraMetBackfill). Waits for the ring's STABLE serial id — an address id (contains ':') is + // re-keyed away on serial adoption, and the Health card reads the active id — then marks itself done. + if (!NoopPrefs.ouraMetBackfillDone(appContext)) { + viewModelScope.launch(Dispatchers.IO) { + val id = activeReadDeviceId.first { it.startsWith("oura-") && !it.contains(':') } + val result = runCatching { + com.noop.data.OuraMetBackfill.run( + filesDir = appContext.filesDir, + deviceId = id, + insert = { repository.insertEventRows(it) }, + log = { StrapLogBuffer.append(it) }, + ) + } + if (result.isSuccess) NoopPrefs.setOuraMetBackfillDone(appContext) + } + } // #1121: re-arm the opt-in detailed-capture rolling log on launch, so a capture the user started // keeps going across the process being killed (this phone class is not battery-exempt and Android // kills the background BLE overnight — the very window a battery capture needs to span). diff --git a/android/app/src/main/java/com/noop/ui/HealthScreen.kt b/android/app/src/main/java/com/noop/ui/HealthScreen.kt index 8cebec9900..fc3c16d307 100644 --- a/android/app/src/main/java/com/noop/ui/HealthScreen.kt +++ b/android/app/src/main/java/com/noop/ui/HealthScreen.kt @@ -211,6 +211,10 @@ fun HealthScreen( item { Spacer(Modifier.height(Metrics.selectorTopUp)) } item { FitnessAgeSection(vm = vm, days = days, profile = profile, onOpenSettings = onOpenSettings) } item { VitalitySection(vm = vm, days = days, profile = profile) } + // OURA ACTIVITY — a Tier-B ESTIMATE from the ring's own MET stream (0x50): active minutes, + // MET-minutes and estimated active kcal. Hidden unless the active device has OURA_MET rows, so a + // WHOOP-only install never sees it. Never scored, never a step count. + item { OuraActivitySection(vm = vm, days = days, profile = profile) } // SKIN TEMPERATURE (v5 pillar) — Cycle awareness (opt-in), Body clock + an illness heads-up, // each from a pure engine RESULT the ViewModel publishes. A section of Health, never its own // destination (umbrella §2.4). Non-clinical observations about your own numbers. @@ -834,6 +838,103 @@ private fun VitalitySection(vm: AppViewModel, days: List, profile: } } +/** + * Oura activity ESTIMATE from the ring's MET stream (0x50, [com.noop.data.OuraStreamMapping.EVENT_MET]): + * today's active minutes, MET-minutes and estimated active kcal, plus the last 7 days. Renders nothing + * until the active device has OURA_MET rows. Labelled as an estimate throughout — the MET decode is + * third-party (Tier B), the ring's logging gaps undercount, and none of it is scored or a step count. + */ +@Composable +private fun OuraActivitySection(vm: AppViewModel, days: List, profile: ProfileStore) { + var activity by remember(vm.activeStrapId) { mutableStateOf>(emptyList()) } + LaunchedEffect(days, vm.activeStrapId) { + val zone = ZoneId.systemDefault() + val today = LocalDate.now(zone) + val firstDay = today.minusDays(OURA_ACTIVITY_DAYS - 1L) + // Samples lay BACKWARD from each row's time, so read one record span past tomorrow's midnight. + val from = firstDay.atStartOfDay(zone).toEpochSecond() + val to = today.plusDays(1).atStartOfDay(zone).toEpochSecond() + OURA_MET_RECORD_SPAN_S + val rows = runCatching { + vm.repo.eventsByKind( + deviceId = vm.activeStrapId, + kind = com.noop.data.OuraStreamMapping.EVENT_MET, + from = from, + to = to, + limit = OURA_MET_EVENT_LIMIT, + ) + }.getOrDefault(emptyList()) + activity = withContext(Dispatchers.Default) { + OuraActivityDays.aggregate(rows, zone, profile.weightKg) + .filter { it.day >= firstDay.toString() && it.day <= today.toString() } + } + } + if (activity.isEmpty()) return + val todayKey = LocalDate.now().toString() + val todayEstimate = activity.firstOrNull { it.day == todayKey }?.estimate + Column(verticalArrangement = Arrangement.spacedBy(Metrics.gap)) { + SectionHeader(uiString(R.string.oura_activity_title), overline = uiString(R.string.oura_activity_overline)) + NoopCard { + Column(verticalArrangement = Arrangement.spacedBy(Metrics.space12)) { + Row(horizontalArrangement = Arrangement.spacedBy(Metrics.space12)) { + OuraActivityStat( + modifier = Modifier.weight(1f), + label = uiString(R.string.oura_activity_active_min), + value = todayEstimate?.activeMinutes?.roundToInt()?.toString() ?: "—", + tint = Palette.metricCyan, + ) + OuraActivityStat( + modifier = Modifier.weight(1f), + label = uiString(R.string.oura_activity_met_min), + value = todayEstimate?.metMinutes?.roundToInt()?.toString() ?: "—", + tint = Palette.metricPurple, + ) + OuraActivityStat( + modifier = Modifier.weight(1f), + label = uiString(R.string.oura_activity_kcal), + value = todayEstimate?.estActiveKcal?.roundToInt()?.toString() ?: "—", + tint = Palette.accent, + ) + } + Text( + if (todayEstimate != null) { + uiString(R.string.oura_activity_coverage, todayEstimate.sampleCount) + } else { + uiString(R.string.oura_activity_none_today) + }, + style = NoopType.footnote, + color = Palette.textSecondary, + ) + Overline(uiString(R.string.oura_activity_week)) + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(uiString(R.string.l10n_health_screen_date_eb9a4bc1), style = NoopType.footnote, color = Palette.textSecondary, modifier = Modifier.weight(1f)) + Text(uiString(R.string.oura_activity_active_min), style = NoopType.footnote, color = Palette.textSecondary, textAlign = TextAlign.End, modifier = Modifier.weight(0.8f)) + Text(uiString(R.string.oura_activity_kcal), style = NoopType.footnote, color = Palette.textSecondary, textAlign = TextAlign.End, modifier = Modifier.weight(0.8f)) + } + for (entry in activity.sortedByDescending { it.day }) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(vitalReadingDateLabel(entry.day), style = NoopType.bodyNumber, color = Palette.textPrimary, modifier = Modifier.weight(1f)) + Text(entry.estimate.activeMinutes.roundToInt().toString(), style = NoopType.bodyNumber, color = Palette.metricCyan, textAlign = TextAlign.End, modifier = Modifier.weight(0.8f)) + Text(entry.estimate.estActiveKcal?.roundToInt()?.toString() ?: "—", style = NoopType.bodyNumber, color = Palette.textPrimary, textAlign = TextAlign.End, modifier = Modifier.weight(0.8f)) + } + } + Text(uiString(R.string.oura_activity_note), style = NoopType.footnote, color = Palette.textTertiary) + } + } + } +} + +@Composable +private fun OuraActivityStat(modifier: Modifier, label: String, value: String, tint: androidx.compose.ui.graphics.Color) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(Metrics.space4)) { + Overline(label, color = Palette.textTertiary) + Text(value, style = NoopType.metricInline, color = if (value != "—") tint else Palette.textTertiary) + } +} + +private const val OURA_ACTIVITY_DAYS = 7 +private const val OURA_MET_RECORD_SPAN_S = 30L * 60L +private const val OURA_MET_EVENT_LIMIT = 50_000 + @Composable private fun VitalityHero( vitality: Double, bodyAge: Double, chronoAge: Int, diff --git a/android/app/src/main/java/com/noop/ui/MainActivity.kt b/android/app/src/main/java/com/noop/ui/MainActivity.kt index a8c7f1e786..2edb62c868 100644 --- a/android/app/src/main/java/com/noop/ui/MainActivity.kt +++ b/android/app/src/main/java/com/noop/ui/MainActivity.kt @@ -718,6 +718,16 @@ object NoopPrefs { of(context).edit().putBoolean(KEY_OURA_NOTIFY_MASK_FULL, enabled).apply() } + /** Whether the one-time Oura MET sidecar import ([com.noop.data.OuraMetBackfill]) has completed. */ + const val KEY_OURA_MET_BACKFILL_DONE = "noop.ouraMetBackfillDone.v1" + + fun ouraMetBackfillDone(context: Context): Boolean = + of(context).getBoolean(KEY_OURA_MET_BACKFILL_DONE, false) + + fun setOuraMetBackfillDone(context: Context) { + of(context).edit().putBoolean(KEY_OURA_MET_BACKFILL_DONE, true).apply() + } + /** #1121: whether the opt-in "detailed capture" rolling strap-log file is on. Persisted so capture * RESUMES after the process is killed (AppViewModel re-arms the BLE client from this on launch). */ const val KEY_DETAILED_CAPTURE = "noop.detailedCapture" diff --git a/android/app/src/main/java/com/noop/ui/OuraActivityDays.kt b/android/app/src/main/java/com/noop/ui/OuraActivityDays.kt new file mode 100644 index 0000000000..0de4f152d9 --- /dev/null +++ b/android/app/src/main/java/com/noop/ui/OuraActivityDays.kt @@ -0,0 +1,55 @@ +package com.noop.ui + +import com.noop.data.EventRow +import com.noop.data.OuraStreamMapping +import com.noop.oura.OuraActivityEstimate +import com.noop.oura.OuraActivityEstimator +import java.time.Instant +import java.time.ZoneId +import org.json.JSONObject + +/** One local calendar day of the Oura ring's MET stream, aggregated into a Tier-B activity estimate. */ +internal data class OuraActivityDay(val day: String, val estimate: OuraActivityEstimate) + +/** + * UI-only adapter from durable `OURA_MET` rows (0x50, [OuraStreamMapping.EVENT_MET]) to per-day activity + * estimates for the Health card. Never scored and never a step count (OURA_PROTOCOL.md s6.13). + * + * Each row's samples are laid BACKWARD from the row's anchored time at the stored per-sample cadence, the + * convention of the sibling per-record series (0x6F SpO2, 0x5D HRV). Which end of a 0x50 record is the + * oldest minute is not pinned, so a sample's time is only good to within its record's span (~13 min): it + * can move a few minutes across midnight, never more. Gaps between records stay gaps: the ring stops logging + * when still, so nothing is interpolated and an uncovered minute simply is not counted. + */ +internal object OuraActivityDays { + fun aggregate( + rows: List, + zone: ZoneId = ZoneId.systemDefault(), + bodyMassKg: Double? = null, + ): List { + val byDay = sortedMapOf>() + for (row in rows) { + if (row.kind != OuraStreamMapping.EVENT_MET) continue + val o = runCatching { JSONObject(row.payloadJSON) }.getOrNull() ?: continue + val arr = o.optJSONArray("met_x10") ?: continue + val sps = o.optInt("sec_per_sample", OuraStreamMapping.MET_SECONDS_PER_SAMPLE).toLong() + if (sps != OuraStreamMapping.MET_SECONDS_PER_SAMPLE.toLong()) continue // one epoch per estimate + val n = arr.length() + for (i in 0 until n) { + val ts = row.ts - (n - 1 - i) * sps + val day = Instant.ofEpochSecond(ts).atZone(zone).toLocalDate().toString() + byDay.getOrPut(day) { mutableListOf() }.add(arr.getInt(i) / 10.0) + } + } + return byDay.map { (day, met) -> + OuraActivityDay( + day = day, + estimate = OuraActivityEstimator.estimate( + met, + epochSeconds = OuraStreamMapping.MET_SECONDS_PER_SAMPLE.toDouble(), + bodyMassKg = bodyMassKg, + ), + ) + } + } +} diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index e1c9065686..7c09a570e7 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -2860,4 +2860,15 @@ Senden Gesendet. Die Antwort des Rings steht im Strap-Log. Ring nicht verbunden. Warte, bis er verbunden ist, und versuche es erneut. + + + Aktivität + Oura-Schätzung + Aktive Min. + MET-Min. + Aktive kcal + Heute, aus %1$d Min. Ringdaten. + Heute noch keine Aktivitätsdaten vom Ring. + Letzte 7 Tage + Geschätzt aus der Bewegungsintensität des Rings (MET). Minuten ab 3 MET zählen als aktiv; Minuten ohne Ringaufzeichnung zählen als inaktiv. Keine Schrittzahl und nicht Teil der Scores. diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index f2f01dbc9e..b9f3d9f4cc 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -2847,4 +2847,15 @@ Enviar Enviado. Consulta el registro de la banda para ver la respuesta del anillo. Anillo no conectado. Espera a que se conecte e inténtalo de nuevo. + + + Actividad + Estimación de Oura + Min activos + min MET + kcal activas + Hoy, a partir de %1$d min de datos del anillo. + Aún no hay datos de actividad del anillo hoy. + Últimos 7 días + Estimado a partir de la intensidad de movimiento del anillo (MET). Los minutos con 3+ MET cuentan como activos; los minutos que el anillo no registró cuentan como inactivos. No es un recuento de pasos ni se usa en las puntuaciones. diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index afcd494a5d..943f745ece 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -2846,4 +2846,15 @@ Envoyer Envoyé. Consultez le journal du bracelet pour la réponse de la bague. Bague non connectée. Attendez la connexion, puis réessayez. + + + Activité + Estimation Oura + Min actives + min MET + kcal actives + Aujourd\'hui, d\'après %1$d min de données de la bague. + Pas encore de données d\'activité de la bague aujourd\'hui. + 7 derniers jours + Estimé à partir de l\'intensité de mouvement de la bague (MET). Les minutes à 3 MET ou plus comptent comme actives ; les minutes non enregistrées par la bague comptent comme inactives. Ce n\'est pas un nombre de pas et ce n\'est pas utilisé dans les scores. diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml index 876a785e97..d31b416cf8 100644 --- a/android/app/src/main/res/values-pl/strings.xml +++ b/android/app/src/main/res/values-pl/strings.xml @@ -2861,4 +2861,15 @@ Wyślij Wysłano. Odpowiedź pierścienia znajdziesz w logu opaski. Pierścień nie jest połączony. Poczekaj na połączenie i spróbuj ponownie. + + + Aktywność + Szacunek Oura + Aktywne min + min MET + Aktywne kcal + Dziś, na podstawie %1$d min danych z pierścienia. + Brak dziś danych o aktywności z pierścienia. + Ostatnie 7 dni + Szacowane na podstawie intensywności ruchu z pierścienia (MET). Minuty z 3+ MET liczą się jako aktywne; minuty niezarejestrowane przez pierścień liczą się jako nieaktywne. To nie jest liczba kroków i nie wpływa na wyniki. diff --git a/android/app/src/main/res/values-pt-rPT/strings.xml b/android/app/src/main/res/values-pt-rPT/strings.xml index 5eacc2dbf1..f5d20e60b2 100644 --- a/android/app/src/main/res/values-pt-rPT/strings.xml +++ b/android/app/src/main/res/values-pt-rPT/strings.xml @@ -2839,4 +2839,15 @@ Enviar Enviado. Consulte o registo da pulseira para ver a resposta do anel. Anel não ligado. Aguarde a ligação e tente novamente. + + + Atividade + Estimativa Oura + Min ativos + min MET + kcal ativas + Hoje, a partir de %1$d min de dados do anel. + Ainda não há dados de atividade do anel hoje. + Últimos 7 dias + Estimado a partir da intensidade de movimento do anel (MET). Minutos com 3+ MET contam como ativos; minutos que o anel não registou contam como inativos. Não é uma contagem de passos nem é usado nas pontuações. diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index 1b0eb50e1b..c5035656a5 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -2740,4 +2740,15 @@ Отправить Отправлено. Ответ кольца смотрите в журнале браслета. Кольцо не подключено. Дождитесь подключения и повторите попытку. + + + Активность + Оценка Oura + Активные мин + MET-мин + Активные ккал + Сегодня, по %1$d мин данных кольца. + Сегодня пока нет данных об активности с кольца. + Последние 7 дней + Оценка по интенсивности движения с кольца (MET). Минуты с 3+ MET считаются активными; минуты, не записанные кольцом, считаются неактивными. Это не количество шагов, и в оценках не используется. diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml index d2aa94b77b..efd11ab4c5 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -2818,4 +2818,15 @@ 发送 已发送。请在手环日志中查看戒指的回复。 戒指未连接。请等待连接后重试。 + + + 活动 + Oura 估算 + 活动分钟 + MET 分钟 + 活动千卡 + 今天,基于 %1$d 分钟的戒指数据。 + 今天还没有来自戒指的活动数据。 + 最近 7 天 + 根据戒指的运动强度(MET)估算。3 MET 及以上的分钟计为活动;戒指未记录的分钟计为不活动。这不是步数,也不用于评分。 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index ccad04caa8..b88aa1f76b 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2876,4 +2876,15 @@ Send Sent. Check the strap log for the ring\'s reply. Ring not connected. Wait for it to connect, then try again. + + + Activity + Oura estimate + Active min + MET-min + Active kcal + Today, from %1$d min of ring data. + No ring activity data yet today. + Last 7 days + Estimated from the ring\'s movement intensity (MET). Minutes at 3+ MET count as active; minutes the ring did not log count as inactive. Not a step count and not used in scores. diff --git a/android/app/src/test/java/com/noop/data/OuraMetPersistenceTest.kt b/android/app/src/test/java/com/noop/data/OuraMetPersistenceTest.kt new file mode 100644 index 0000000000..45ac835398 --- /dev/null +++ b/android/app/src/test/java/com/noop/data/OuraMetPersistenceTest.kt @@ -0,0 +1,89 @@ +package com.noop.data + +import com.noop.oura.OuraActivityInfo +import com.noop.oura.OuraEvent +import com.noop.ui.OuraActivityDays +import java.time.ZoneOffset +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The durable Oura MET path: the live mapping (0x50 → OURA_MET), the one-time sidecar import that must + * write the IDENTICAL row so the (deviceId, ts, kind) PK dedups the two, and the Health card's per-day + * aggregation. + */ +class OuraMetPersistenceTest { + + @Test + fun liveMappingStoresMetAsExactTenths() { + val info = OuraActivityInfo(ringTimestamp = 500L, state = 7, met = listOf(1.6, 0.9, 12.8, 3.0)) + val streams = OuraStreamMapping.streams(listOf(OuraEvent.ActivityInfo(info))) { 1_790_000_000 } + val ev = streams.events.single() + assertEquals(OuraStreamMapping.EVENT_MET, ev.kind) + assertEquals(1_790_000_000, ev.ts) + assertEquals( + """{"met_x10":[16,9,128,30],"sec_per_sample":60,"state":7}""", + StreamPersistence.encodePayload(ev.payload), + ) + // Never a scored stream value. + assertTrue(streams.hr.isEmpty()) + } + + @Test + fun unanchoredRecordIsDroppedNotGuessed() { + val info = OuraActivityInfo(ringTimestamp = 500L, state = 0, met = listOf(1.0)) + assertTrue(OuraStreamMapping.streams(listOf(OuraEvent.ActivityInfo(info))) { null }.events.isEmpty()) + } + + @Test + fun sidecarLineImportsTheSameRowTheLivePathWrites() { + // A real schema-1 line shape (OuraActivityDumpLine.encode). + val line = """{"schema":1,"deviceId":"oura-x","ringTs":43273389,"utc":1790444000,""" + + """"iso":"2026-09-26T17:33:20Z","state":7,"secPerSample":60,"met":[1.6,1.2,0.9,12.8]}""" + val (utc, payload) = OuraMetBackfill.parseLine(line)!! + assertEquals(1_790_444_000L, utc) + val live = OuraStreamMapping.streams( + listOf(OuraEvent.ActivityInfo(OuraActivityInfo(43273389L, 7, listOf(1.6, 1.2, 0.9, 12.8)))), + ) { 1_790_444_000 }.events.single() + assertEquals(StreamPersistence.encodePayload(live.payload), payload) + } + + @Test + fun malformedOrUnknownSidecarLinesAreSkipped() { + assertNull(OuraMetBackfill.parseLine("not json")) + assertNull(OuraMetBackfill.parseLine("""{"schema":2,"utc":1790444000,"met":[1.0]}""")) + assertNull(OuraMetBackfill.parseLine("""{"schema":1,"utc":1790444000,"met":[]}""")) + assertNull(OuraMetBackfill.parseLine("""{"schema":1,"met":[1.0]}""")) + } + + @Test + fun dailyAggregationLaysSamplesBackwardAndSplitsAtMidnight() { + // A 4-sample record stamped 00:02 UTC on the 26th: samples at 23:59, 00:00, 00:01, 00:02. + val midnight = 1_790_380_800L // 2026-09-26T00:00:00Z + val row = EventRow( + deviceId = "oura-x", + ts = midnight + 120, + kind = OuraStreamMapping.EVENT_MET, + payloadJSON = """{"met_x10":[40,40,10,50],"sec_per_sample":60,"state":0}""", + ) + val days = OuraActivityDays.aggregate(listOf(row), ZoneOffset.UTC, bodyMassKg = 60.0) + assertEquals(listOf("2026-09-25", "2026-09-26"), days.map { it.day }) + assertEquals(1, days[0].estimate.sampleCount) + assertEquals(1.0, days[0].estimate.activeMinutes, 0.0) // the 4.0 MET minute + assertEquals(3, days[1].estimate.sampleCount) + assertEquals(2.0, days[1].estimate.activeMinutes, 0.0) // 4.0 and 5.0 clear 3.0 + assertEquals(3.0, days[0].estimate.estActiveKcal!!, 0.0) // (4-1) MET × 60 kg × 1/60 h + } + + @Test + fun otherKindsAndUnexpectedCadencesAreIgnored() { + val motion = EventRow("oura-x", 1_790_380_800L, OuraStreamMapping.EVENT_MOTION, """{"motion_seconds":5}""") + val oddCadence = EventRow( + "oura-x", 1_790_380_900L, OuraStreamMapping.EVENT_MET, + """{"met_x10":[40],"sec_per_sample":30,"state":0}""", + ) + assertTrue(OuraActivityDays.aggregate(listOf(motion, oddCadence), ZoneOffset.UTC).isEmpty()) + } +} diff --git a/android/app/src/test/java/com/noop/data/OuraStreamMappingTest.kt b/android/app/src/test/java/com/noop/data/OuraStreamMappingTest.kt index 35eb33ba0f..3ed9f4eb85 100644 --- a/android/app/src/test/java/com/noop/data/OuraStreamMappingTest.kt +++ b/android/app/src/test/java/com/noop/data/OuraStreamMappingTest.kt @@ -379,8 +379,10 @@ class OuraStreamMappingTest { @Test fun tierBAndActivityInfoNeverMapToAStream() { // HONEST-DATA INVARIANT (PR #960): Tier-B raw summaries AND the decoded-but-unvalidated 0x50 - // activity/MET events must never produce a durable stream row (in particular no step count is - // ever minted from MET - it is not one), exactly like the Swift twin's drop test. + // activity/MET events must never produce a scored stream row (in particular no step count is + // ever minted from MET - it is not one). Android-only since the fork's activity card: 0x50 is + // kept as ONE OURA_MET estimate row in the event table (never a stream value); the Swift twin + // still drops it. val s = OuraStreamMapping.streams( listOf( OuraEvent.TierB( @@ -402,7 +404,7 @@ class OuraStreamMappingTest { ) assertTrue(s.hr.isEmpty()) assertTrue(s.rr.isEmpty()) - assertTrue(s.events.isEmpty()) + assertEquals(listOf(OuraStreamMapping.EVENT_MET), s.events.map { it.kind }) assertTrue(s.battery.isEmpty()) assertTrue(s.spo2.isEmpty()) assertTrue(s.skinTemp.isEmpty()) diff --git a/android/app/src/test/java/com/noop/oura/OuraActivityEstimatorTest.kt b/android/app/src/test/java/com/noop/oura/OuraActivityEstimatorTest.kt new file mode 100644 index 0000000000..42fdf6edfc --- /dev/null +++ b/android/app/src/test/java/com/noop/oura/OuraActivityEstimatorTest.kt @@ -0,0 +1,78 @@ +package com.noop.oura + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Locale + +/** + * The MET-stream activity estimate — Kotlin twin of Swift's ActivityEstimateTests, plus a byte-identical + * oracle: `oura_activity_estimate_swift_oracle.txt` is the verbatim stdout of the Swift + * `ActivityEstimate.swift` compiled standalone (`swiftc -O ActivityEstimate.swift main.swift`) over 300 + * generated inputs from the LCG below. Regenerate it from the Swift source whenever either twin changes. + */ +class OuraActivityEstimatorTest { + + @Test + fun emptyInputYieldsZeroEstimateNotNull() { + val e = OuraActivityEstimator.estimate(emptyList(), epochSeconds = 60.0) + assertEquals(0, e.sampleCount) + assertEquals(0.0, e.meanMET, 0.0) + assertEquals(0.0, e.activeMinutes, 0.0) + assertNull(e.estActiveKcal) + } + + @Test + fun meanMaxAndMetMinutesAt60s() { + val e = OuraActivityEstimator.estimate(listOf(1.0, 1.0, 3.0, 3.0), epochSeconds = 60.0) + assertEquals(2.0, e.meanMET, 0.0) + assertEquals(3.0, e.maxMET, 0.0) + assertEquals(8.0, e.metMinutes, 0.0) + assertEquals(2.0, e.activeMinutes, 0.0) + } + + @Test + fun subRestingSampleNeverContributesNegativeActiveEnergy() { + val e = OuraActivityEstimator.estimate(listOf(0.5), epochSeconds = 60.0, bodyMassKg = 80.0) + assertEquals(0.0, e.estActiveKcal!!, 0.0) + assertTrue(e.estTotalKcal!! > 0.0) + } + + @Test + fun goldenEndToEndFromRealisticSample() { + val met = listOf(1.2, 0.9, 0.9, 0.9, 1.0, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 0.9, 1.2) + val e = OuraActivityEstimator.estimate(met, epochSeconds = 60.0, bodyMassKg = 70.0) + assertEquals(0.0, e.activeMinutes, 0.0) + assertEquals(0.95, e.meanMET, 0.0) + assertEquals(0.47, e.estActiveKcal!!, 0.01) + } + + @Test + fun matchesTheSwiftTwinByteForByte() { + val expected = javaClass.classLoader!!.getResourceAsStream(ORACLE_RESOURCE)!! + .bufferedReader().readLines().filter { it.isNotBlank() } + var s = 42UL + fun next(): ULong { + s = s * 6364136223846793005UL + 1442695040888963407UL + return s shr 33 + } + fun f(x: Double?): String = x?.let { String.format(Locale.ROOT, "%.10f", it) } ?: "nil" + val actual = (0 until 300).map { i -> + val n = (next() % 40UL).toInt() + val met = ArrayList(n) + repeat(n) { met.add((next() % 160UL).toDouble() / 10.0 + (if (next() % 4UL == 0UL) 0.05 else 0.0)) } + val epoch = listOf(30.0, 60.0, 90.0)[(next() % 3UL).toInt()] + val mass: Double? = if (next() % 3UL == 0UL) null else (45UL + next() % 80UL).toDouble() + 0.5 + val e = OuraActivityEstimator.estimate(met, epochSeconds = epoch, bodyMassKg = mass) + "$i ${e.sampleCount} ${f(e.meanMET)} ${f(e.maxMET)} ${f(e.metMinutes)} ${f(e.activeMinutes)} " + + "${f(e.estActiveKcal)} ${f(e.estTotalKcal)}" + } + assertEquals(expected.size, actual.size) + for (i in expected.indices) assertEquals("case $i", expected[i], actual[i]) + } + + private companion object { + const val ORACLE_RESOURCE = "oura_activity_estimate_swift_oracle.txt" + } +} diff --git a/android/app/src/test/resources/oura_activity_estimate_swift_oracle.txt b/android/app/src/test/resources/oura_activity_estimate_swift_oracle.txt new file mode 100644 index 0000000000..9617c29151 --- /dev/null +++ b/android/app/src/test/resources/oura_activity_estimate_swift_oracle.txt @@ -0,0 +1,300 @@ +0 14 10.5700000000 15.7000000000 74.0000000000 6.5000000000 nil nil +1 26 8.4500000000 15.1000000000 109.8000000000 10.5000000000 172.0000000000 194.9000000000 +2 13 8.2200000000 13.7000000000 160.3500000000 18.0000000000 144.3700000000 164.3600000000 +3 20 7.7200000000 15.3000000000 231.5200000000 22.5000000000 nil nil +4 33 7.1700000000 15.6500000000 355.0500000000 40.5000000000 313.3400000000 363.9300000000 +5 20 9.1500000000 15.1000000000 183.0500000000 19.0000000000 nil nil +6 22 8.1700000000 15.4000000000 179.7500000000 18.0000000000 170.1700000000 193.2300000000 +7 9 8.8200000000 14.8000000000 39.7000000000 4.0000000000 43.1200000000 48.6300000000 +8 6 8.2300000000 13.4000000000 24.6700000000 3.0000000000 26.5500000000 30.2300000000 +9 3 8.0000000000 15.8000000000 24.0000000000 2.0000000000 31.2100000000 35.0000000000 +10 29 7.6600000000 15.9500000000 111.1000000000 10.0000000000 95.8400000000 110.1700000000 +11 5 9.4300000000 13.0500000000 23.5800000000 2.5000000000 nil nil +12 11 5.3700000000 10.1500000000 88.6500000000 12.0000000000 147.3100000000 180.9900000000 +13 18 8.0300000000 15.9000000000 216.7500000000 24.0000000000 240.0900000000 272.7400000000 +14 16 7.9500000000 11.4000000000 127.2500000000 15.0000000000 138.1400000000 158.0000000000 +15 23 10.0400000000 15.9500000000 346.2800000000 28.5000000000 nil nil +16 21 8.1000000000 15.5500000000 170.1500000000 18.0000000000 nil nil +17 19 7.9100000000 14.8000000000 75.1300000000 8.5000000000 129.1700000000 145.8700000000 +18 10 11.3100000000 14.8000000000 113.0500000000 9.0000000000 212.1100000000 232.6900000000 +19 10 7.0200000000 15.5000000000 35.1200000000 3.5000000000 24.3500000000 28.3900000000 +20 36 8.4400000000 15.9000000000 455.5500000000 43.5000000000 648.2400000000 732.6800000000 +21 7 3.8400000000 11.7000000000 13.4300000000 1.5000000000 nil nil +22 25 7.7100000000 14.2500000000 96.3500000000 11.5000000000 71.9700000000 82.7000000000 +23 27 7.7400000000 15.6000000000 104.5300000000 11.5000000000 nil nil +24 5 7.3200000000 12.8000000000 18.3000000000 2.5000000000 16.7200000000 19.3700000000 +25 33 7.4400000000 14.9000000000 122.7000000000 14.0000000000 218.5600000000 250.5100000000 +26 36 7.5800000000 15.1000000000 409.1300000000 45.0000000000 547.4800000000 630.7300000000 +27 14 8.0200000000 15.5000000000 112.3000000000 11.0000000000 138.7500000000 156.2800000000 +28 24 8.1600000000 15.7500000000 195.8000000000 21.0000000000 nil nil +29 9 7.0200000000 13.0000000000 31.5700000000 3.5000000000 29.1800000000 33.4200000000 +30 15 9.5400000000 15.9500000000 71.5800000000 6.0000000000 62.9800000000 69.7900000000 +31 39 7.9200000000 15.7000000000 463.3500000000 48.0000000000 nil nil +32 25 5.4500000000 14.3000000000 204.4500000000 21.0000000000 158.6200000000 192.5200000000 +33 12 8.4100000000 14.2000000000 151.3500000000 16.5000000000 267.8100000000 303.9600000000 +34 6 10.1100000000 15.9000000000 30.3300000000 2.5000000000 nil nil +35 12 8.6000000000 15.9000000000 51.5800000000 5.5000000000 38.3600000000 43.4100000000 +36 24 8.5700000000 15.7500000000 102.8000000000 9.5000000000 85.7900000000 96.8000000000 +37 39 8.2400000000 15.8000000000 482.1000000000 48.0000000000 498.9600000000 566.4700000000 +38 18 8.4000000000 15.1000000000 151.1500000000 15.0000000000 154.2300000000 175.0800000000 +39 17 6.2700000000 13.8000000000 53.3000000000 5.5000000000 nil nil +40 0 0.0000000000 0.0000000000 0.0000000000 0.0000000000 nil nil +41 18 5.7900000000 13.9000000000 52.0800000000 6.0000000000 87.4600000000 104.5800000000 +42 7 7.0000000000 13.7500000000 49.0000000000 6.0000000000 38.1600000000 43.6900000000 +43 13 10.5200000000 15.3000000000 68.3500000000 6.0000000000 nil nil +44 0 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 +45 34 8.2100000000 15.9000000000 418.5000000000 43.5000000000 558.2700000000 631.2400000000 +46 38 7.7100000000 14.6000000000 439.5700000000 46.5000000000 753.7600000000 860.8300000000 +47 29 7.5800000000 14.8000000000 329.9300000000 37.5000000000 412.9300000000 475.6400000000 +48 37 9.1200000000 15.7000000000 337.6000000000 32.0000000000 258.9200000000 289.7700000000 +49 20 9.0800000000 15.7000000000 181.7000000000 18.0000000000 nil nil +50 13 7.8000000000 15.8500000000 152.1800000000 16.5000000000 129.3600000000 148.3700000000 +51 9 9.8600000000 15.7000000000 88.7500000000 9.0000000000 124.2800000000 138.3000000000 +52 13 8.5800000000 15.3000000000 167.3300000000 19.5000000000 225.4300000000 255.1700000000 +53 16 7.2800000000 15.3500000000 58.2000000000 6.5000000000 97.6700000000 113.0100000000 +54 13 7.3000000000 13.9500000000 94.8500000000 11.0000000000 nil nil +55 10 5.1600000000 13.4000000000 25.7800000000 3.5000000000 nil nil +56 4 10.6500000000 14.9000000000 42.6000000000 3.0000000000 33.1300000000 36.5700000000 +57 4 8.7900000000 15.4000000000 35.1500000000 4.0000000000 49.5800000000 55.9500000000 +58 17 9.0800000000 15.9000000000 231.6000000000 21.0000000000 163.5200000000 183.3500000000 +59 17 7.8600000000 15.9500000000 66.8500000000 7.0000000000 58.2100000000 66.2900000000 +60 24 5.7500000000 10.9000000000 206.9300000000 25.5000000000 185.8400000000 222.4400000000 +61 14 10.5300000000 15.6000000000 147.3500000000 13.0000000000 116.6800000000 128.9300000000 +62 30 8.5100000000 15.9000000000 383.1700000000 37.5000000000 256.7300000000 290.5700000000 +63 2 10.2000000000 14.3000000000 10.2000000000 1.0000000000 13.1100000000 14.5300000000 +64 13 8.3900000000 15.8000000000 54.5300000000 5.5000000000 nil nil +65 34 8.7700000000 15.6000000000 298.0500000000 28.0000000000 266.4500000000 300.5300000000 +66 15 7.6500000000 14.7000000000 57.3500000000 6.0000000000 95.1300000000 109.4400000000 +67 20 7.7100000000 15.6500000000 231.3000000000 22.5000000000 414.0500000000 472.2400000000 +68 7 9.6600000000 15.7000000000 33.8300000000 2.5000000000 24.8000000000 27.3400000000 +69 6 8.7800000000 13.1000000000 52.6500000000 5.0000000000 nil nil +70 4 8.9800000000 12.6000000000 17.9500000000 2.0000000000 31.7700000000 35.7500000000 +71 39 8.7500000000 15.9000000000 511.8000000000 49.5000000000 589.2900000000 661.0800000000 +72 9 7.4900000000 14.9000000000 101.1000000000 12.0000000000 69.3500000000 80.0400000000 +73 6 7.1700000000 13.9500000000 43.0000000000 4.0000000000 54.0800000000 60.5600000000 +74 35 7.5700000000 15.8000000000 397.5000000000 42.0000000000 616.6400000000 705.5600000000 +75 36 10.1500000000 15.9500000000 182.6500000000 16.5000000000 319.7000000000 354.6500000000 +76 18 8.3100000000 15.7000000000 224.4000000000 21.0000000000 223.7600000000 252.4500000000 +77 21 8.2500000000 15.3000000000 259.8800000000 25.5000000000 310.2100000000 353.0000000000 +78 17 8.6200000000 15.5000000000 219.9000000000 21.0000000000 232.3800000000 262.0500000000 +79 9 7.8200000000 15.2000000000 35.1800000000 3.0000000000 58.0300000000 66.5400000000 +80 38 9.3000000000 15.8000000000 530.0300000000 51.0000000000 684.9700000000 764.1200000000 +81 24 8.5500000000 15.0000000000 307.9500000000 31.5000000000 364.8700000000 413.1700000000 +82 32 7.8800000000 15.7000000000 378.3000000000 36.0000000000 549.6100000000 627.3500000000 +83 29 7.3600000000 15.1000000000 106.6800000000 12.0000000000 nil nil +84 5 9.7700000000 13.2500000000 48.8500000000 5.0000000000 nil nil +85 7 6.6500000000 11.8500000000 46.5500000000 6.0000000000 nil nil +86 25 6.8600000000 15.8000000000 171.4500000000 17.0000000000 nil nil +87 24 9.7300000000 15.5000000000 116.7800000000 10.5000000000 84.6900000000 94.3900000000 +88 8 7.7300000000 15.3000000000 30.9300000000 2.5000000000 25.0000000000 28.6100000000 +89 14 6.5200000000 13.9000000000 136.8800000000 18.0000000000 214.9200000000 252.0800000000 +90 5 10.5100000000 14.4000000000 52.5500000000 5.0000000000 78.0600000000 86.2700000000 +91 3 8.0700000000 12.9500000000 36.3000000000 4.5000000000 65.4600000000 74.7200000000 +92 6 7.5700000000 10.3000000000 45.4000000000 5.0000000000 53.5200000000 61.6700000000 +93 27 7.2900000000 15.7500000000 98.3700000000 10.5000000000 138.8000000000 158.2200000000 +94 32 9.2600000000 15.6000000000 296.2500000000 28.0000000000 227.3300000000 254.2800000000 +95 34 7.9800000000 15.5000000000 271.2500000000 28.0000000000 424.5800000000 481.4700000000 +96 21 9.1200000000 15.9000000000 95.7500000000 8.0000000000 109.2700000000 122.0800000000 +97 30 7.9900000000 15.4000000000 119.9000000000 12.5000000000 117.3700000000 132.8900000000 +98 1 0.4500000000 0.4500000000 0.4500000000 0.0000000000 0.0000000000 0.3700000000 +99 4 5.4900000000 6.4000000000 32.9200000000 6.0000000000 32.0900000000 39.2400000000 +100 0 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 +101 12 6.8000000000 15.7000000000 122.4800000000 13.5000000000 91.4200000000 107.1700000000 +102 20 6.0000000000 13.6000000000 60.0500000000 6.0000000000 47.9100000000 56.5500000000 +103 16 9.4000000000 15.9000000000 225.6800000000 22.5000000000 328.0900000000 366.7200000000 +104 25 7.4600000000 15.9000000000 279.6000000000 27.0000000000 411.8400000000 472.9900000000 +105 2 10.6800000000 11.4000000000 10.6800000000 1.0000000000 7.5000000000 8.2700000000 +106 18 7.9600000000 15.2500000000 214.9500000000 24.0000000000 277.2300000000 317.0500000000 +107 7 6.9400000000 14.5500000000 48.6000000000 5.0000000000 nil nil +108 22 8.9200000000 15.9500000000 294.3800000000 30.0000000000 536.7000000000 601.0200000000 +109 25 7.6000000000 15.9500000000 95.0200000000 9.0000000000 nil nil +110 24 9.2900000000 15.6000000000 334.2800000000 33.0000000000 nil nil +111 2 9.2300000000 14.0500000000 18.4500000000 2.0000000000 nil nil +112 5 2.9900000000 8.1000000000 22.4200000000 1.5000000000 17.5500000000 25.6000000000 +113 21 8.0200000000 15.2500000000 252.5200000000 27.0000000000 309.4700000000 351.4300000000 +114 3 6.5700000000 13.8000000000 29.5500000000 3.0000000000 34.5400000000 38.6600000000 +115 33 8.0900000000 14.8000000000 267.0000000000 28.0000000000 333.8800000000 380.4800000000 +116 31 6.7400000000 15.1500000000 104.4000000000 12.5000000000 133.4700000000 155.7300000000 +117 15 6.6500000000 15.6000000000 99.7000000000 10.0000000000 169.0600000000 196.9100000000 +118 24 9.1300000000 15.8500000000 109.5000000000 11.5000000000 86.9400000000 97.6400000000 +119 34 5.8800000000 15.8500000000 199.9500000000 23.0000000000 165.3100000000 194.9500000000 +120 24 7.7500000000 15.9000000000 93.0000000000 9.0000000000 140.6300000000 160.4300000000 +121 6 8.4800000000 15.3500000000 25.4300000000 2.5000000000 28.9700000000 32.8400000000 +122 39 7.5800000000 15.6000000000 147.8500000000 15.5000000000 174.8200000000 200.8300000000 +123 24 8.4200000000 15.0000000000 101.0800000000 10.5000000000 104.6600000000 118.7600000000 +124 23 7.8100000000 15.9500000000 89.8500000000 9.0000000000 nil nil +125 22 8.5600000000 15.5500000000 188.4000000000 18.0000000000 171.4300000000 193.1100000000 +126 15 7.6500000000 15.3500000000 172.2000000000 16.5000000000 137.8900000000 156.4200000000 +127 3 9.4800000000 14.6500000000 28.4500000000 3.0000000000 36.2700000000 40.5400000000 +128 25 8.0800000000 15.2000000000 101.0300000000 10.5000000000 nil nil +129 26 8.1500000000 15.7000000000 317.8500000000 31.5000000000 541.4300000000 617.1600000000 +130 25 7.0300000000 14.8500000000 175.8000000000 19.0000000000 230.0200000000 265.1600000000 +131 20 6.7200000000 15.1500000000 134.4500000000 14.0000000000 239.1400000000 278.9800000000 +132 0 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 +133 34 9.6100000000 15.8500000000 326.9000000000 30.0000000000 451.5500000000 503.9700000000 +134 7 9.3800000000 15.8000000000 65.6500000000 6.0000000000 85.8200000000 95.7400000000 +135 19 6.8800000000 14.6000000000 65.3800000000 7.5000000000 60.5200000000 70.2800000000 +136 28 7.4700000000 15.8000000000 104.5500000000 12.0000000000 nil nil +137 38 8.1200000000 15.2500000000 308.7000000000 31.0000000000 413.5800000000 470.7700000000 +138 4 5.6100000000 9.5500000000 33.6700000000 3.0000000000 56.5000000000 68.7500000000 +139 32 6.1800000000 15.6500000000 197.7000000000 21.0000000000 255.4400000000 301.4900000000 +140 0 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 +141 38 7.4500000000 14.5000000000 141.5000000000 15.5000000000 105.5300000000 121.4500000000 +142 29 7.6900000000 14.9000000000 222.9500000000 22.0000000000 183.7700000000 209.9400000000 +143 29 7.7700000000 15.3500000000 225.3500000000 23.0000000000 nil nil +144 12 4.7700000000 14.0000000000 28.6500000000 4.5000000000 34.5400000000 43.6900000000 +145 34 7.0300000000 15.7000000000 238.9000000000 27.0000000000 395.0200000000 455.9000000000 +146 33 8.5100000000 14.9000000000 140.4000000000 15.0000000000 94.1500000000 106.4700000000 +147 28 9.3100000000 15.8000000000 130.3000000000 11.5000000000 183.4900000000 205.2200000000 +148 13 8.2700000000 14.4000000000 53.7500000000 6.5000000000 35.8300000000 40.7600000000 +149 37 7.6500000000 15.3000000000 424.8000000000 49.5000000000 596.6100000000 683.2200000000 +150 22 9.8300000000 15.0000000000 216.1500000000 21.0000000000 347.8500000000 387.2700000000 +151 38 6.9800000000 15.5000000000 265.4000000000 29.0000000000 nil nil +152 5 7.9300000000 13.6000000000 59.4700000000 6.0000000000 nil nil +153 11 7.6700000000 14.6000000000 126.6000000000 13.5000000000 114.6900000000 131.8700000000 +154 10 8.6500000000 12.3500000000 86.5000000000 10.0000000000 106.4600000000 120.3800000000 +155 31 7.5300000000 15.9500000000 350.3300000000 36.0000000000 nil nil +156 31 7.3100000000 14.8000000000 113.3000000000 13.0000000000 74.6000000000 85.9200000000 +157 23 8.1200000000 15.6000000000 186.8500000000 20.0000000000 285.8900000000 325.4300000000 +158 28 8.6500000000 15.9000000000 363.4500000000 33.0000000000 502.2100000000 566.3800000000 +159 20 8.1100000000 15.5500000000 81.1000000000 7.5000000000 86.1800000000 98.0000000000 +160 0 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 0.0000000000 +161 16 7.8400000000 15.7000000000 125.5000000000 14.0000000000 92.4600000000 105.6300000000 +162 22 8.4200000000 15.8000000000 185.2000000000 18.0000000000 187.3500000000 211.4400000000 +163 9 9.1800000000 14.7000000000 82.6000000000 9.0000000000 nil nil +164 29 7.0800000000 14.6000000000 307.8000000000 34.5000000000 352.8800000000 407.8400000000 +165 10 6.8000000000 13.0000000000 68.0000000000 7.0000000000 74.5200000000 86.7000000000 +166 30 8.6700000000 15.5500000000 129.9800000000 12.5000000000 127.5400000000 144.0600000000 +167 35 7.3900000000 15.5500000000 388.2000000000 43.5000000000 468.8500000000 540.2500000000 +168 33 7.7600000000 15.6500000000 384.0000000000 39.0000000000 366.7200000000 419.2000000000 +169 3 11.2300000000 15.5000000000 33.7000000000 3.0000000000 62.6800000000 68.8000000000 +170 37 7.3500000000 15.9500000000 408.0000000000 45.0000000000 480.6500000000 554.2000000000 +171 19 7.6600000000 15.7000000000 218.3300000000 22.5000000000 378.9000000000 431.1900000000 +172 8 10.2200000000 14.9000000000 40.8800000000 4.0000000000 nil nil +173 13 7.8100000000 15.6000000000 152.2500000000 18.0000000000 nil nil +174 38 7.9200000000 15.9000000000 150.4800000000 16.0000000000 265.5500000000 302.2000000000 +175 34 8.4300000000 15.8500000000 430.0500000000 45.0000000000 735.9900000000 835.0100000000 +176 29 7.3600000000 15.8500000000 213.4500000000 24.0000000000 226.9900000000 261.4800000000 +177 36 6.7300000000 15.9000000000 121.1800000000 13.5000000000 nil nil +178 7 8.7700000000 15.3000000000 61.4000000000 6.0000000000 72.0800000000 81.3600000000 +179 7 8.4100000000 11.3000000000 88.2800000000 9.0000000000 nil nil +180 22 7.6800000000 15.4000000000 84.5000000000 7.5000000000 110.0300000000 124.6400000000 +181 2 12.4800000000 14.9000000000 12.4800000000 1.0000000000 19.2200000000 20.9000000000 +182 23 6.6500000000 14.4000000000 76.5000000000 7.5000000000 73.8000000000 86.0600000000 +183 33 7.4000000000 15.8000000000 366.4500000000 39.0000000000 246.3300000000 284.0000000000 +184 31 6.3900000000 14.4000000000 198.0000000000 23.0000000000 nil nil +185 24 7.4100000000 15.6000000000 177.9000000000 21.0000000000 186.2000000000 214.9600000000 +186 14 9.4800000000 15.8000000000 132.6500000000 12.0000000000 246.2000000000 275.2500000000 +187 4 7.6300000000 12.5000000000 45.7500000000 6.0000000000 40.7400000000 46.8900000000 +188 19 6.6600000000 14.2000000000 126.5500000000 16.0000000000 177.6100000000 205.6400000000 +189 16 8.3500000000 14.8000000000 200.3300000000 22.5000000000 177.7900000000 201.9900000000 +190 24 7.6300000000 15.6500000000 91.6000000000 9.0000000000 71.4200000000 81.6800000000 +191 15 6.2800000000 15.2000000000 94.2500000000 11.0000000000 90.1700000000 106.0300000000 +192 16 8.9100000000 15.7000000000 142.6000000000 16.0000000000 nil nil +193 39 7.9900000000 15.6000000000 155.8800000000 16.0000000000 235.4200000000 268.8800000000 +194 38 10.7400000000 15.8500000000 612.3700000000 55.5000000000 1143.1500000000 1260.4700000000 +195 9 5.6300000000 15.8000000000 50.6500000000 5.0000000000 nil nil +196 17 8.6200000000 15.7500000000 219.7500000000 19.5000000000 198.5900000000 221.5800000000 +197 27 7.7200000000 15.0000000000 312.8300000000 31.5000000000 529.2800000000 602.1900000000 +198 19 8.5700000000 15.8000000000 244.3500000000 22.5000000000 nil nil +199 33 8.0900000000 15.2500000000 133.5200000000 13.5000000000 167.4400000000 190.2700000000 +200 35 6.5600000000 14.8000000000 344.4800000000 37.5000000000 378.8800000000 444.9500000000 +201 5 9.7400000000 15.3000000000 24.3500000000 2.5000000000 nil nil +202 17 6.5900000000 13.9000000000 55.9800000000 7.0000000000 53.2000000000 62.0400000000 +203 30 7.1200000000 15.9500000000 320.5500000000 33.0000000000 nil nil +204 37 7.2500000000 14.9000000000 268.2000000000 30.0000000000 395.6000000000 453.7100000000 +205 14 7.0200000000 13.3000000000 49.1300000000 5.5000000000 62.8400000000 73.2800000000 +206 21 8.3300000000 15.8000000000 262.2800000000 22.5000000000 nil nil +207 34 7.9700000000 15.4000000000 271.0000000000 29.0000000000 468.0800000000 535.2300000000 +208 8 6.9300000000 11.3000000000 83.1000000000 9.0000000000 nil nil +209 29 8.7100000000 15.5000000000 126.3200000000 14.0000000000 nil nil +210 28 6.8900000000 15.0000000000 96.4800000000 10.0000000000 161.6400000000 187.3200000000 +211 12 10.2700000000 15.5000000000 61.6000000000 6.0000000000 114.4400000000 126.7900000000 +212 28 8.5500000000 15.2000000000 359.2500000000 36.0000000000 409.9800000000 464.0300000000 +213 16 7.7100000000 13.2000000000 61.6500000000 6.0000000000 63.7700000000 72.4400000000 +214 5 9.2600000000 15.3500000000 46.3000000000 4.0000000000 67.8000000000 76.0100000000 +215 13 6.1100000000 12.4000000000 79.4500000000 8.0000000000 127.6700000000 147.6400000000 +216 24 7.8500000000 15.4500000000 94.2500000000 9.0000000000 121.9900000000 137.4500000000 +217 18 10.0500000000 15.9000000000 90.4300000000 8.0000000000 92.9600000000 103.2400000000 +218 3 4.5800000000 9.2000000000 20.6300000000 3.0000000000 20.0200000000 25.6100000000 +219 12 9.2000000000 15.6000000000 110.4000000000 9.0000000000 202.1300000000 225.4000000000 +220 22 9.1300000000 15.7500000000 200.9500000000 20.0000000000 295.3400000000 329.8900000000 +221 11 8.6200000000 15.5500000000 47.4000000000 5.0000000000 40.8500000000 46.2200000000 +222 39 8.4700000000 15.8000000000 330.3500000000 32.0000000000 442.0900000000 498.2800000000 +223 39 8.5800000000 15.9500000000 167.3300000000 16.0000000000 127.3600000000 143.6200000000 +224 20 6.0000000000 13.4000000000 120.0000000000 15.0000000000 91.6100000000 109.0000000000 +225 2 7.7000000000 9.3000000000 23.1000000000 3.0000000000 26.6300000000 30.6100000000 +226 21 7.8800000000 15.1000000000 248.1000000000 25.5000000000 421.4400000000 481.7300000000 +227 3 3.9500000000 5.3000000000 11.8500000000 2.0000000000 9.0700000000 12.1500000000 +228 36 8.7900000000 15.8500000000 474.6000000000 46.5000000000 594.5600000000 668.4000000000 +229 13 6.6200000000 13.7000000000 86.0500000000 10.0000000000 139.4000000000 164.2100000000 +230 39 7.5500000000 15.9000000000 441.6800000000 46.5000000000 485.6500000000 555.7700000000 +231 32 6.9900000000 14.1000000000 223.8000000000 24.0000000000 nil nil +232 25 9.0200000000 15.8000000000 112.7000000000 11.5000000000 203.7200000000 228.2200000000 +233 19 9.1900000000 14.3000000000 87.3000000000 9.5000000000 138.1000000000 154.9600000000 +234 16 6.8800000000 14.1500000000 165.2300000000 16.5000000000 nil nil +235 37 8.0500000000 15.8000000000 149.0000000000 16.0000000000 160.7200000000 182.5300000000 +236 22 6.5300000000 15.8000000000 71.8300000000 8.5000000000 74.3100000000 86.7900000000 +237 32 6.4800000000 15.8000000000 311.0200000000 36.0000000000 445.3300000000 526.1500000000 +238 24 10.3400000000 15.8500000000 124.0200000000 11.0000000000 nil nil +239 38 8.1000000000 15.1500000000 461.6300000000 43.5000000000 747.1200000000 850.1600000000 +240 28 7.7400000000 15.1000000000 216.7500000000 25.0000000000 203.2300000000 233.0100000000 +241 10 7.2300000000 13.0000000000 72.3000000000 10.0000000000 62.8200000000 72.9000000000 +242 14 8.2700000000 14.5000000000 173.6300000000 16.5000000000 281.0800000000 319.7600000000 +243 8 3.8900000000 10.7500000000 31.1500000000 3.0000000000 40.2100000000 51.6600000000 +244 12 8.0000000000 15.2500000000 96.0500000000 11.0000000000 nil nil +245 19 6.8400000000 15.0000000000 64.9700000000 7.0000000000 51.0300000000 59.0200000000 +246 18 7.6400000000 15.4000000000 137.5000000000 14.0000000000 152.6400000000 173.0200000000 +247 18 6.8600000000 13.8000000000 185.3300000000 24.0000000000 nil nil +248 25 8.6500000000 15.9000000000 108.1200000000 11.5000000000 nil nil +249 21 7.6000000000 15.1000000000 79.7800000000 7.5000000000 79.2300000000 91.0800000000 +250 26 7.5400000000 15.7000000000 293.9300000000 34.5000000000 nil nil +251 5 7.3900000000 14.2000000000 55.4300000000 4.5000000000 nil nil +252 32 6.7500000000 14.7000000000 216.0000000000 22.0000000000 229.5800000000 268.2000000000 +253 12 8.7900000000 15.6500000000 158.1800000000 16.5000000000 232.4600000000 262.3100000000 +254 19 8.2800000000 15.2000000000 78.7000000000 8.0000000000 69.4200000000 78.0400000000 +255 19 8.0100000000 15.3000000000 76.1300000000 7.5000000000 132.0800000000 150.3500000000 +256 7 5.7300000000 6.9000000000 60.1500000000 10.5000000000 54.2000000000 65.6600000000 +257 23 7.9600000000 15.5000000000 183.0000000000 18.0000000000 303.3300000000 346.1800000000 +258 18 8.3800000000 14.9000000000 226.2000000000 21.0000000000 165.2100000000 186.6200000000 +259 5 10.7100000000 15.3000000000 26.7800000000 2.5000000000 nil nil +260 2 6.3800000000 11.8000000000 6.3800000000 0.5000000000 nil nil +261 27 6.5300000000 15.5000000000 176.2000000000 20.0000000000 265.5100000000 309.8200000000 +262 5 7.9700000000 10.2000000000 59.7800000000 7.5000000000 nil nil +263 14 10.3700000000 15.3000000000 217.8000000000 19.5000000000 nil nil +264 1 7.0000000000 7.0000000000 10.5000000000 1.5000000000 nil nil +265 34 6.9100000000 15.3000000000 352.5000000000 40.5000000000 603.0300000000 702.0600000000 +266 11 6.9100000000 14.7000000000 38.0000000000 4.0000000000 nil nil +267 29 8.4500000000 15.8000000000 244.9500000000 23.0000000000 239.4000000000 271.4900000000 +268 37 7.1700000000 15.2500000000 132.6700000000 14.0000000000 nil nil +269 11 9.6500000000 14.0000000000 159.2300000000 13.5000000000 224.7900000000 250.7800000000 +270 15 11.0800000000 15.8000000000 166.2000000000 14.0000000000 296.1000000000 325.4800000000 +271 11 7.3900000000 15.1000000000 121.9500000000 13.5000000000 112.5500000000 129.0600000000 +272 30 6.6800000000 15.5500000000 100.1300000000 11.0000000000 nil nil +273 2 10.9300000000 13.5000000000 21.8500000000 2.0000000000 28.6200000000 31.5000000000 +274 38 8.3500000000 15.3000000000 158.6000000000 17.5000000000 208.2400000000 236.5800000000 +275 21 7.0200000000 15.0500000000 73.6700000000 7.0000000000 nil nil +276 26 7.6600000000 15.8000000000 199.1000000000 19.0000000000 nil nil +277 34 8.8500000000 15.6000000000 451.2800000000 45.0000000000 743.8400000000 838.6200000000 +278 18 7.6700000000 15.9500000000 138.0500000000 14.0000000000 93.5000000000 106.9900000000 +279 9 7.1200000000 13.7000000000 96.1500000000 10.5000000000 nil nil +280 3 12.0200000000 14.8000000000 54.0800000000 4.5000000000 81.3900000000 88.7700000000 +281 17 7.7400000000 14.7500000000 65.8300000000 7.5000000000 97.1400000000 111.3500000000 +282 9 8.6600000000 13.7500000000 38.9800000000 3.5000000000 65.8400000000 74.3800000000 +283 35 8.9800000000 14.7000000000 157.1300000000 15.5000000000 171.4100000000 192.4800000000 +284 34 7.2600000000 14.3000000000 123.3500000000 14.0000000000 nil nil +285 12 7.9800000000 14.3000000000 95.8000000000 9.0000000000 95.1800000000 107.7800000000 +286 0 0.0000000000 0.0000000000 0.0000000000 0.0000000000 nil nil +287 20 7.7600000000 15.8000000000 77.5500000000 8.0000000000 95.9800000000 109.2200000000 +288 10 8.4900000000 15.0000000000 42.4500000000 4.0000000000 68.9700000000 78.1800000000 +289 33 7.4400000000 15.6500000000 368.3200000000 36.0000000000 544.8000000000 623.0800000000 +290 16 11.0000000000 15.4000000000 87.9800000000 8.0000000000 109.9700000000 120.9700000000 +291 22 9.1700000000 15.9000000000 201.8500000000 19.0000000000 nil nil +292 16 6.4600000000 11.3000000000 51.6500000000 6.0000000000 nil nil +293 32 8.1000000000 15.5000000000 388.7300000000 37.5000000000 nil nil +294 9 7.3100000000 13.5500000000 98.6300000000 12.0000000000 113.7800000000 130.6800000000 +295 29 7.7500000000 14.5000000000 112.3200000000 11.5000000000 nil nil +296 8 7.6600000000 14.2500000000 91.9500000000 9.0000000000 67.6700000000 77.3900000000 +297 3 9.7300000000 12.9000000000 29.2000000000 3.0000000000 41.7000000000 46.4800000000 +298 31 5.4900000000 14.9000000000 170.1500000000 19.0000000000 295.9000000000 353.0600000000 +299 16 6.0100000000 13.0000000000 48.0500000000 6.0000000000 49.2700000000 58.0600000000 diff --git a/docs/OURA_PROTOCOL.md b/docs/OURA_PROTOCOL.md index 57e3d4c95b..600f6c7212 100644 --- a/docs/OURA_PROTOCOL.md +++ b/docs/OURA_PROTOCOL.md @@ -984,7 +984,12 @@ edit of the ring's tag. cadence, not a decode drop) - so any daily active-minute total derived from it UNDERCOUNTS. This corroborates the "plausible, tracks activity" read while keeping it Tier B: still not a step count, still not ground-truth-validated against Oura's own numbers, still never scored. NOOP has no MET field in its - HR/strain data model, so `0x50` remains a diagnostic JSONL corpus (`oura-activity-.jsonl`); the ring's + HR/strain data model, so `0x50` remains a diagnostic JSONL corpus (`oura-activity-.jsonl`) on Swift; + **Android (fork)** additionally keeps each anchored record as an `OURA_MET` event row (`met_x10` exact tenths, + `sec_per_sample`, `state`), imports the pre-existing sidecar once into the same rows, and shows a Health-card + ESTIMATE (active minutes at ≥ 3 MET, MET-minutes, above-resting kcal via `OuraActivityEstimator`) — samples laid + backward from the record time (within-record order unpinned: an inter-record continuity test on 95 live record + pairs could not separate the two orders, because records are usually not contiguous), never scored; the ring's path into NOOP activity is HR (live push + banked IBI, §6.4), never MET. open_oura consumes this same `0x50` `met` as one input to its activity classifier (`activity_model.rs`). [open_oura-act] - **✅ `0x50` MET TRACKS A VARYING INPUT — the strongest validation to date (NOOP, 2026-08-02, live Gen 3).**