Skip to content

Android auto-detect: read HR over the active-strap union, not hardcoded my-whoop (#767) - #777

Merged
ryanbr merged 1 commit into
mainfrom
fix/android-autodetect-hr-active-strap
Jul 24, 2026
Merged

ryanbr merged 1 commit into
mainfrom
fix/android-autodetect-hr-active-strap

Conversation

@ryanbr

@ryanbr ryanbr commented Jul 24, 2026

Copy link
Copy Markdown
Owner

Fixes #767. Very likely also fixes #717 (same symptom, WHOOP 4.0 Android).

Symptom

Workout auto-detection silently stopped for a user — no Today suggestion card, no post-workout popup — while sleep, charge/recovery and everything else kept syncing and updating normally. On the latest version.

Root cause

AutoWorkoutNudge.maybeDetect read the detector's heart rate from a hardcoded device id:

val hr = repo.hrSamples(AUTO_DETECT_DEVICE, fromSec, nowSec, …)   // AUTO_DETECT_DEVICE = "my-whoop"

But a live-BLE strap banks its raw HR under its own fresh id ("whoop-<uuid>", see #908), not "my-whoop". So the read returned an empty series and detect() bailed immediately at if (hr.size < 2) return null — no windows, no card. Meanwhile sleep/charge/steps all read the active-strap union (importedSourceIds(activeDeviceId)), so they kept working. That asymmetry is exactly the reported symptom: everything syncs, only auto-detect is dead.

This is a leftover from the mid-July "thread the active strap id" migration (#175, #178/#191, #359, #512/#513) that moved sleep/charge/health and the iOS workout reads off hardcoded ids — but missed this one Android read.

Fix

Read the same union everything else uses:

val hr = repo.hrSamplesUnion(viewModel.deviceId, fromSec, nowSec, …)

hrSamplesUnion reads importedSourceIds(active) ∪ "my-whoop", deduped — the Kotlin twin of iOS Repository.hrSamples(from:to:), which iOS Repository.autoDetectCandidate() already uses.

Parity

Android-only. iOS was never affected — autoDetectCandidate() already reads the union. This brings Android to parity, per the "read the active strap id, not a hardcoded default" rule.

For single-WHOOP installs there's no behaviour change: activeDeviceId resolves to "my-whoop", so the union is one id — a byte-identical read.

Verification

  • One-line data-plumbing fix; the pure AutoWorkoutDetector logic is unchanged (its tests still pass).
  • App-target Android UI, so it isn't covered by default CI and I could not compile it on this host (aapt2 is x86-only here). Please run ./gradlew compileFullDebugKotlin before merge.
  • Best confirmed on a live-BLE strap: with auto-detect on, a sustained workout should surface the Today suggestion card again.

…ed my-whoop (#767, #717)

Workout auto-detection silently stopped for live-BLE users while sleep,
charge and everything else kept syncing. Root cause: AutoWorkoutNudge read
HR via repo.hrSamples("my-whoop", ...) — a hardcoded id — but a live strap
banks its raw under its OWN fresh id ("whoop-<uuid>", #908), so the read
returned nothing and detect() bailed at 'hr.size < 2'. The mid-July
active-strap-id migration (#175/#512/#513) moved sleep/charge/health/iOS
reads to the active-strap union but missed this one.

Fix: read the hrSamplesUnion(activeDeviceId) — importedSourceIds(active) ∪
my-whoop, deduped — exactly what every other Today read and iOS
Repository.autoDetectCandidate() already use. Android-only; iOS was never
affected (it already reads the union). No behaviour change for single-WHOOP
installs, whose active id resolves to my-whoop (one id, identical read).

App-target Android; not compiled here (aapt2 x86-only on this host) - run
./gradlew compileFullDebugKotlin before merge.
@ryanbr ryanbr mentioned this pull request Jul 24, 2026
@ryanbr
ryanbr merged commit bae0e23 into main Jul 24, 2026
1 check passed
@ryanbr
ryanbr deleted the fix/android-autodetect-hr-active-strap branch July 24, 2026 14:16
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…ed my-whoop (ryanbr#767, ryanbr#717) (ryanbr#777)

Workout auto-detection silently stopped for live-BLE users while sleep,
charge and everything else kept syncing. Root cause: AutoWorkoutNudge read
HR via repo.hrSamples("my-whoop", ...) — a hardcoded id — but a live strap
banks its raw under its OWN fresh id ("whoop-<uuid>", ryanbr#908), so the read
returned nothing and detect() bailed at 'hr.size < 2'. The mid-July
active-strap-id migration (ryanbr#175/ryanbr#512/ryanbr#513) moved sleep/charge/health/iOS
reads to the active-strap union but missed this one.

Fix: read the hrSamplesUnion(activeDeviceId) — importedSourceIds(active) ∪
my-whoop, deduped — exactly what every other Today read and iOS
Repository.autoDetectCandidate() already use. Android-only; iOS was never
affected (it already reads the union). No behaviour change for single-WHOOP
installs, whose active id resolves to my-whoop (one id, identical read).

App-target Android; not compiled here (aapt2 x86-only on this host) - run
./gradlew compileFullDebugKotlin before merge.
pipiche38 added a commit to pipiche38/noop that referenced this pull request Aug 29, 2026
A session's stage timeline is supposed to TILE its span — SleepStageTotals says so
explicitly, and every consumer is written as though it does. One producer breaks that:
a device-PROVIDED hypnogram assembled from records that arrived INCOMPLETE.
OuraSleepSessionMapping merges only CONTIGUOUS codes, so a sleep-phase page that never
arrived leaves a hole in stagesJSON while startTs/endTs still span the whole night.

Measured over 31 consecutive ring nights against a WHOOP strap worn the same nights,
8 came in under 95% coverage and one covered 23% of its own span — 601 minutes claimed,
140 minutes of segments. That night was stored as 70 minutes of sleep at 50% efficiency
with 3 minutes of REM; the strap recorded 494 minutes at 85%. Mean stored-TST error on
the affected nights was -328 min, against -18..-54 on the healthy ones (the ordinary
two-device offset).

Nothing caught it. The merge's richness rule tests only that stages are PRESENT, and the
holed row has 21 segments, so it passed and won its day. Rest's two confidence guards
both structurally miss this shape: gravitySparse is inert for Oura outright (the ring
banks no gravity at all, so isGravitySparse returns false for every ring night), and the
H9 restorative floor needs efficiency >= 0.85 against this night's 0.50. It reported
SOLID.

The missing quantity is not a new measurement — it is a ratio of two numbers already
stored. HypnogramCoverage derives it on read, so nothing is persisted, there is no
migration, and it applies retroactively to nights already in the database.

  * SleepMerge richness becomes a RANK: 2 = stages covering the span they claim,
    1 = present but holed, 0 = none; the computed day wins only when it OUT-RANKS the
    import. Collapsing 1 and 0 gives back the original presence rule exactly, so this is
    a strict generalisation — every case ryanbr#240/ryanbr#241 decided it still decides the same way.
    What changes is the pair the old rule could not express: a holed import against a
    COMPLETE computed night. A holed import still beats no stages at all.
  * ScoreConfidence.rest gains a third downgrade clause on coverage, beside gravitySparse
    and the H9 floor. A new clause in the existing mechanism, not a new mechanism.
  * AnalyticsEngine sums coverage straight off the already-decoded segments and passes it,
    accumulated separately from inBedS because that one later absorbs the inter-fragment
    gap (ryanbr#777/ryanbr#705) — a bridged out-of-bed gap is known-awake time, whereas a hole INSIDE
    a fragment is time we never observed.

Both guards fail OPEN: coverage is nil for a payload it cannot measure, and nil is "not
measured", never "measured badly". That is what keeps this away from WHOOP/Apple/
Health-Connect imports, whose minute-dict shape carries no timestamps — they are judged
on presence exactly as before.

This never fills a hole in, and deliberately does NOT count uncovered time as awake: we
do not know what happened there, and asserting wake would be the same overreach in the
other direction. It reports only how much of the night was observed.

Confidence-only — no score changes, no invented stages, no recovered sleep. The missing
pages never arrived; that is the overnight link problem, tracked separately.

Verification (no strap needed — all pure, both platforms):
  swift test  WhoopStore 441 pass, StrandAnalytics 1636 pass
  ./gradlew testFullDebugUnitTest  4739 pass, 2 pre-existing failures
    (RecoveryDriversTest float rounding, -0.5 vs -0.4999999999999929) confirmed
    failing identically on a clean tree at this base before these changes
  macOS app target builds (app-build.yml is disabled, so built locally)
  doc_comment_lint OK, i18n_audit --ci OK
Byte-parity is pinned BY ORACLE, not by eye: HypnogramCoverageTest embeds the verbatim
stdout of the Swift twin compiled standalone over the whole input space that matters —
tiling, an interior hole, the measured night, the gate boundary from both sides and
exactly on it, overlap/overhang, every unmeasurable shape, degenerate spans. The
gate-exact row (950/1000 prints 0.94999999999999996, the same double the 0.95 literal
denotes, so it is NOT holed) is pinned precisely because reading the two sides could
not settle it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CwDCKs1RPprtsc97nZFTaC
pipiche38 added a commit to pipiche38/noop that referenced this pull request Aug 31, 2026
ryanbr#1716's coverage guard measures the right thing and nothing shows it. The
gate downgrades Rest to `.building` on a holed night, but that verdict
lands in `DayResult.restConfidence`, which — like `chargeConfidence` and
`effortConfidence` — has no reader outside the analytics package on either
platform. On the 2026-08-28/29 ring night (470.5 min claimed, 440.0 min of
segments, 93.5% covered) the guard fired correctly and the Sleep screen was
indistinguishable from a complete night.

Give it a consumer: a third caveat under the stage breakdown, beside the
existing #H9 and ryanbr#345 notes.

It is a separate note rather than a coverage argument threaded into the
"Low confidence" badge. That badge is a different gate — it calls
`ScoreConfidence.rest(...)` itself and then re-tests `restorativeShare <
0.10 && efficiency >= 0.85`, so a coverage-only downgrade would be filtered
straight back out — and its wording claims the deep/REM SPLIT is doubted,
when the claim here is that part of the night is MISSING. Different fact,
different sentence.

The copy names NO cause and offers NO remedy, and that is the whole of what
it can attribute. Three captures say so. On 08-29/30 and 08-30/31 the final
persist logged `hypnogram trimmed 136 code(s)` and `24 code(s) - unwritten
(0xFF) and/or pre-onset`, the second matching the 12.0 min hole code for
code, so the epochs DID reach the phone and the ring marked them unwritten;
re-persisting those nights five and eight times left every hole intact; and
part of a shortfall is not a hole at all but the 0x49 sleep-onset anchor
landing before the first written code (13.6 min on 08-29/30, 366.8 min on
08-17), which nothing can ever fill. Nor does the note point at the totals
by direction — both hosts render it BELOW the stage-breakdown card that
carries them. It says only that this share of the window has stage data and
that the totals cover only that part of the night.

Underneath, one shared accumulation on both platforms:
`HypnogramCoverage.coveredSeconds` / `groupFraction` / `isHoledGroup`.

  - It is asked of the bridged main-night GROUP, never of one row. Two
    fragments individually 90% and 99% covered are one night at neither
    figure, and the group total is what `analyzeDay` gates on; a per-row
    reading in the UI would have been a quietly different rule.
  - It reproduces `analyzeDay`'s decoded-segment accumulation term for
    term: every fragment contributes its full span to the denominator
    whether or not its payload is measurable, and contributes cover only
    from timestamped segments — so the mixed-source case still reads as
    holed and an all-timestamp-free group still comes back nil (unknown,
    not holed). The inter-fragment gap is NOT in the denominator: it is
    known-awake out-of-bed time (ryanbr#777/ryanbr#705), not time we failed to observe.
  - `coveredSeconds` returns 0, not nil, for an unmeasurable payload,
    because it is the summable half; "is this measurable at all" stays in
    `fraction`, which turns a zero total back into nil. `fraction(stagesJSON:
    spanSeconds:)` now delegates to it on both sides, which removes a
    duplicated segment loop and is behaviour-preserving.
  - The Kotlin twin takes entity-free `(payload, span)` fragments so
    `com.noop.analytics` need not reach into `com.noop.data` for a row
    type; the Swift `CachedSleepSession` overload is a thin adapter over
    the same arithmetic.

The printed percentage is floored, never rounded, so 94.8% cannot render as
"95%" beside a badge raised for falling below 95%. Pinned by test, since
that is a property of the gate rather than of one screen, and both hosts
print it.

Verification. Swift WhoopStore 455, StrandAnalytics 1647, StrandTests 1403,
macOS app target builds; Android 4778 with the two pre-existing
`RecoveryDriversTest` float-rounding failures, confirmed failing identically
on a clean tree at this base (the six further reds under a fr_FR default
locale are the known locale-dependent set and are green under
`-Duser.language=en`, on this tree and without it). Kotlin
`HypnogramCoverageTest` 10/10, including three new cases pinned against
stdout from the shipped Swift implementation linked as a package dependency
— 16 group rows covering the mixed-source, all-unmeasurable, gap, overlap
and degenerate-span cases, and the knife-edge row where 1900/2000 prints as
0.94999999999999996, the same double the threshold literal denotes, and is
therefore NOT holed. Reading the two accumulations side by side could not
have settled that row. doc-lint and the i18n gate pass; the new strings ship
in en/de/es/fr/pt-PT plus pl/zh so the non-focus ratchet is not worsened.

Confirmed on hardware data, both ways: the 2026-08-28/29 and 2026-08-29/30
databases loaded into the macOS build render the amber "Partly recorded"
badge and a floored percentage above the raw-stages note, and the clean
2026-08-30/31 night (96.8%) shows no badge at all. No stored number changes.

This wires the guard, not the field: `DayResult.restConfidence` and its two
siblings still have no reader, and since none of the three is persisted, any
future consumer faces the same choice between a migration and recomputing at
the render site as this does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0153E6spximZQJUii9gn6Kwc
pipiche38 added a commit to pipiche38/noop that referenced this pull request Aug 31, 2026
ryanbr#1716's coverage guard measures the right thing and nothing shows it. The
gate downgrades Rest to `.building` on a holed night, but that verdict
lands in `DayResult.restConfidence`, which — like `chargeConfidence` and
`effortConfidence` — has no reader outside the analytics package on either
platform. On the 2026-08-28/29 ring night (470.5 min claimed, 440.0 min of
segments, 93.5% covered) the guard fired correctly and the Sleep screen was
indistinguishable from a complete night.

Give it a consumer: a third caveat under the stage breakdown, beside the
existing #H9 and ryanbr#345 notes.

It is a separate note rather than a coverage argument threaded into the
"Low confidence" badge. That badge is a different gate — it calls
`ScoreConfidence.rest(...)` itself and then re-tests `restorativeShare <
0.10 && efficiency >= 0.85`, so a coverage-only downgrade would be filtered
straight back out — and its wording claims the deep/REM SPLIT is doubted,
when the claim here is that part of the night is MISSING. Different fact,
different sentence.

Underneath, one shared accumulation on both platforms:
`HypnogramCoverage.coveredSeconds` / `groupFraction` / `isHoledGroup`.

  - It is asked of the bridged main-night GROUP, never of one row. Two
    fragments individually 90% and 99% covered are one night at neither
    figure, and the group total is what `analyzeDay` gates on; a per-row
    reading in the UI would have been a quietly different rule.
  - It reproduces `analyzeDay`'s decoded-segment accumulation term for
    term: every fragment contributes its full span to the denominator
    whether or not its payload is measurable, and contributes cover only
    from timestamped segments — so the mixed-source case still reads as
    holed and an all-timestamp-free group still comes back nil (unknown,
    not holed). The inter-fragment gap is NOT in the denominator: it is
    known-awake out-of-bed time (ryanbr#777/ryanbr#705), not time we failed to observe.
  - `coveredSeconds` returns 0, not nil, for an unmeasurable payload,
    because it is the summable half; "is this measurable at all" stays in
    `fraction`, which turns a zero total back into nil. `fraction(stagesJSON:
    spanSeconds:)` now delegates to it on both sides, which removes a
    duplicated segment loop and is behaviour-preserving.
  - The Kotlin twin takes entity-free `(payload, span)` fragments so
    `com.noop.analytics` need not reach into `com.noop.data` for a row
    type; the Swift `CachedSleepSession` overload is a thin adapter over
    the same arithmetic.

The printed percentage is floored, never rounded, so 94.8% cannot render as
"95%" beside a badge raised for falling below 95%. Pinned by test, since
that is a property of the gate rather than of one screen, and both hosts
print it.

Verification. Swift WhoopStore 455, StrandAnalytics 1647, StrandTests 1403,
macOS app target builds; Android 4798 with the two pre-existing
`RecoveryDriversTest` float-rounding failures, confirmed failing identically
on a clean tree at this base. Kotlin `HypnogramCoverageTest` 10/10,
including three new cases pinned against stdout from the shipped Swift
implementation linked as a package dependency — 16 group rows covering the
mixed-source, all-unmeasurable, gap, overlap and degenerate-span cases, and
the knife-edge row where 1900/2000 prints as 0.94999999999999996, the same
double the threshold literal denotes, and is therefore NOT holed. Reading
the two accumulations side by side could not have settled that row.
doc-lint and the i18n gate pass; the new strings ship in en/de/es/fr/pt-PT
plus pl/zh so the non-focus ratchet is not worsened.

Confirmed on hardware data: the 2026-08-28/29 database loaded into the
macOS build renders "Partly recorded — Only 93% of this night's window has
stage data" above the raw-stages note. No stored number changes.

This wires the guard, not the field: `DayResult.restConfidence` and its two
siblings still have no reader, and since none of the three is persisted, any
future consumer faces the same choice between a migration and recomputing at
the render site as this does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CwDCKs1RPprtsc97nZFTaC
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[workouts] Workouts & GPS [workouts] Workouts & GPS whoop 4.0 Android

1 participant