Repository navigation
Add compact Android widget - #77
Merged
Merged
Conversation
1 of 8 tasks
kavemang
force-pushed
the
compact-android-widget-icons
branch
from
July 8, 2026 07:07
5e36752 to
1199b9f
Compare
kavemang
force-pushed
the
compact-android-widget-icons
branch
from
July 9, 2026 17:57
1199b9f to
67b5409
Compare
Author
ryanbr
pushed a commit
that referenced
this pull request
Jul 12, 2026
GermanLocalizationTest.germanResourcesResolveForEveryLocalizedKey fails on main since the compact widget landed (#77): widget_compact_name and widget_compact_description exist in values/ but not values-de/. Adds both, reusing the app's established German terms (Ruhe/Ladung/Anstrengung, Live-Herzfrequenz, Band-Akku) from widget_description.
ryanbr
added a commit
that referenced
this pull request
Jul 12, 2026
…ecovers (#312) (#317) Reported on Android / WHOOP 5.0 / 8.6.2-staging: HRV & Autonomic shows no live data while the Live section streams HR fine, after a remove+reconnect. The strap log shows `→ TOGGLE_REALTIME_HR` then `writeCharacteristic busy; retry 1/12`. Root cause (Android-only): plain HR rides the standard unbonded 0x2A37 profile, but live R-R (→ HRV / Autonomic) needs the TOGGLE_REALTIME_HR write to land. Every path that arms it (reconcileRealtime + the direct arm-on-connect / keep-alive paths) latches realtimeArmed = the value it SENT *before* the write is confirmed. Android GATT allows one write in flight, so under contention the toggle can exhaust MAX_WRITE_RETRIES (12) and be dropped — but realtimeArmed is already latched, so reconcileRealtime and the 30s keep-alive tick see no edge (want == armed) and never re-send. R-R stays dark forever while HR keeps flowing. The drop path even documents that a dropped TOGGLE_REALTIME_HR "silently breaks live HR" (#77) but only mitigates with the 12 retries, no re-arm. Fix: when the write queue drops a frame, if it was TOGGLE_REALTIME_HR, flip realtimeArmed. The write never reached the strap, so its true state is the OPPOSITE of the optimistically latched value; flipping makes the next keep-alive reconcile detect the edge and re-send the CURRENT want — recovering a dropped ARM *or* disarm within ~30s. Bounded: the re-send rides the keep-alive cadence (not the drop path), so a persistently-busy stack retries once per tick, never in a loop. PendingWrite now carries its CommandNumber so the drop site can tell it was the toggle; a pure predicate shouldReArmRealtimeAfterDrop gates it. Android-only: iOS CoreBluetooth serialises writes internally and has no "drop after N GATT retries" path (its realtimeArmedAt is the separate marginal-radio detector), so no Swift twin. Tests: RealtimeReArmOnDropTest pins the predicate (toggle re-arms; every other command + null does not) — the full drop→re-arm needs a live GATT stack the JVM harness can't fake (no Robolectric; same limit + pattern as GattCrashSafetyTest). Needs on-strap confirmation by the reporter (5/MG live is hardware-only-testable). compileFullDebugKotlin + tests green.
ryanbr
added a commit
that referenced
this pull request
Jul 16, 2026
…bond (#490) (#495) A WHOOP 5/MG's 0x2A19 battery read fires once in didDiscoverCharacteristicsFor — BEFORE the link is encrypted — which an MG rejects ("Authentication is insufficient"). The rejection is logged and dropped, and the read is never retried, so LiveState.batteryPct stays nil from connect. The 5/MG sends no unsolicited battery notification, so the notify subscription never fills it on its own; the only re-read path (refreshBattery) is reachable only from the Live screen. A user on any other screen never sees a battery %. Re-issue the read in enableLiveNotifications, after the notify loop. Every caller is post-bond (CLIENT_HELLO-ack, post-bond, keep-alive), so the link is encrypted and the read succeeds; the keep-alive caller also re-polls ~every 30s so the reading stays current and BatteryEstimator gets its discharge samples on any screen. This is the iOS parity for Android's post-handshake read + ~60s keep-alive poll (WhoopBleClient BATTERY_ON_CONNECT_DELAY_MS / refreshBattery). 4.0 excluded (its 0x2A19 is a stub 100; #77). Estimator guarded by LiveState's 600s same-% throttle. Android already correct — no Kotlin change. Shared Strand BLEManager, so this fixes macOS too. Reported and diagnosed by vishk23.
ryanbr
added a commit
that referenced
this pull request
Jul 16, 2026
…ault fillWorkoutHrFromStrap read every workout's HR window under a hardcoded "my-whoop". A strap-native workout recorded on a SECOND WHOOP (id "whoop-<mac>", its hrSample rows banked under that id) therefore read an empty window, so `stats.n < minSamples` returned the row UNCHANGED: its Avg HR / Max HR were never reconciled from the strap trace and a null Effort (strain) was never recomputed. Single-WHOOP installs keep the canonical "my-whoop" id, so they matched the default and were unaffected — this only bit multi-WHOOP users. (Calories/energyKcal are NOT touched by this fill — they're computed by the detector under the day owner, which already resolves multi-device correctly. The fill only sets avgHr / maxHr / strain.) Resolve the HR read key per row from the recording device instead: - STRAP-NATIVE rows (source "manual" / detected "<id>-noop") were charted and zoned from their OWN strap's trace, so key on that strap — strip the computed "-noop" suffix to reach the raw hrSample id. - IMPORTED rows (Apple Health / Health Connect / WHOOP CSV / activity file) carry no strap HR; #77 still fills them from the strapDeviceId default, so their behaviour is byte-identical (which strap was worn during an imported session on a multi-strap install is undetermined and left as-is). Single-device behaviour is unchanged (detected "my-whoop-noop" strips to "my-whoop", same as the old default). New JVM test pins the classification and the per-row key resolution. Surfaced by #510 (a single-WHOOP report whose root cause is still under investigation); this fixes the multi-WHOOP class. Kotlin only; the Swift twin (reconcileWorkoutHrWithTrace) reads under the Repository's single active id and its read-model WorkoutRow carries no deviceId — a parity follow-up will thread the source device through the workout fetch.
ryanbr
added a commit
that referenced
this pull request
Jul 16, 2026
…ault fillWorkoutHrFromStrap read every workout's HR window under a hardcoded "my-whoop". A strap-native workout recorded on a SECOND WHOOP (id "whoop-<mac>", its hrSample rows banked under that id) therefore read an empty window, so `stats.n < minSamples` returned the row UNCHANGED: its Avg HR / Max HR were never reconciled from the strap trace and a null Effort (strain) was never recomputed. Single-WHOOP installs keep the canonical "my-whoop" id, so they matched the default and were unaffected — this only bit multi-WHOOP users. (Calories/energyKcal are NOT touched by this fill — they're computed by the detector under the day owner, which already resolves multi-device correctly. The fill only sets avgHr / maxHr / strain.) Resolve the HR read key per row from the recording device instead: - STRAP-NATIVE rows (source "manual" / detected "<id>-noop") were charted and zoned from their OWN strap's trace, so key on that strap — strip the computed "-noop" suffix to reach the raw hrSample id. - IMPORTED rows (Apple Health / Health Connect / WHOOP CSV / activity file) carry no strap HR; #77 still fills them from the strapDeviceId default, so their behaviour is byte-identical (which strap was worn during an imported session on a multi-strap install is undetermined and left as-is). Single-device behaviour is unchanged (detected "my-whoop-noop" strips to "my-whoop", same as the old default). New JVM test pins the classification and the per-row key resolution. Surfaced by #510 (a single-WHOOP report whose root cause is still under investigation); this fixes the multi-WHOOP class. Kotlin only; the Swift twin (reconcileWorkoutHrWithTrace) reads under the Repository's single active id and its read-model WorkoutRow carries no deviceId — a parity follow-up will thread the source device through the workout fetch.
ryanbr
added a commit
that referenced
this pull request
Jul 16, 2026
…ault (#512) fillWorkoutHrFromStrap read every workout's HR window under a hardcoded "my-whoop". A strap-native workout recorded on a SECOND WHOOP (id "whoop-<mac>", its hrSample rows banked under that id) therefore read an empty window, so `stats.n < minSamples` returned the row UNCHANGED: its Avg HR / Max HR were never reconciled from the strap trace and a null Effort (strain) was never recomputed. Single-WHOOP installs keep the canonical "my-whoop" id, so they matched the default and were unaffected — this only bit multi-WHOOP users. (Calories/energyKcal are NOT touched by this fill — they're computed by the detector under the day owner, which already resolves multi-device correctly. The fill only sets avgHr / maxHr / strain.) Resolve the HR read key per row from the recording device instead: - STRAP-NATIVE rows (source "manual" / detected "<id>-noop") were charted and zoned from their OWN strap's trace, so key on that strap — strip the computed "-noop" suffix to reach the raw hrSample id. - IMPORTED rows (Apple Health / Health Connect / WHOOP CSV / activity file) carry no strap HR; #77 still fills them from the strapDeviceId default, so their behaviour is byte-identical (which strap was worn during an imported session on a multi-strap install is undetermined and left as-is). Single-device behaviour is unchanged (detected "my-whoop-noop" strips to "my-whoop", same as the old default). New JVM test pins the classification and the per-row key resolution. Surfaced by #510 (a single-WHOOP report whose root cause is still under investigation); this fixes the multi-WHOOP class. Kotlin only; the Swift twin (reconcileWorkoutHrWithTrace) reads under the Repository's single active id and its read-model WorkoutRow carries no deviceId — a parity follow-up will thread the source device through the workout fetch.
DX23876
pushed a commit
to DX23876/noop
that referenced
this pull request
Jul 22, 2026
Co-authored-by: kavemang <kavemang@users.noreply.github.com>
DX23876
pushed a commit
to DX23876/noop
that referenced
this pull request
Jul 22, 2026
GermanLocalizationTest.germanResourcesResolveForEveryLocalizedKey fails on main since the compact widget landed (ryanbr#77): widget_compact_name and widget_compact_description exist in values/ but not values-de/. Adds both, reusing the app's established German terms (Ruhe/Ladung/Anstrengung, Live-Herzfrequenz, Band-Akku) from widget_description.
DX23876
pushed a commit
to DX23876/noop
that referenced
this pull request
Jul 22, 2026
…ecovers (ryanbr#312) (ryanbr#317) Reported on Android / WHOOP 5.0 / 8.6.2-staging: HRV & Autonomic shows no live data while the Live section streams HR fine, after a remove+reconnect. The strap log shows `→ TOGGLE_REALTIME_HR` then `writeCharacteristic busy; retry 1/12`. Root cause (Android-only): plain HR rides the standard unbonded 0x2A37 profile, but live R-R (→ HRV / Autonomic) needs the TOGGLE_REALTIME_HR write to land. Every path that arms it (reconcileRealtime + the direct arm-on-connect / keep-alive paths) latches realtimeArmed = the value it SENT *before* the write is confirmed. Android GATT allows one write in flight, so under contention the toggle can exhaust MAX_WRITE_RETRIES (12) and be dropped — but realtimeArmed is already latched, so reconcileRealtime and the 30s keep-alive tick see no edge (want == armed) and never re-send. R-R stays dark forever while HR keeps flowing. The drop path even documents that a dropped TOGGLE_REALTIME_HR "silently breaks live HR" (ryanbr#77) but only mitigates with the 12 retries, no re-arm. Fix: when the write queue drops a frame, if it was TOGGLE_REALTIME_HR, flip realtimeArmed. The write never reached the strap, so its true state is the OPPOSITE of the optimistically latched value; flipping makes the next keep-alive reconcile detect the edge and re-send the CURRENT want — recovering a dropped ARM *or* disarm within ~30s. Bounded: the re-send rides the keep-alive cadence (not the drop path), so a persistently-busy stack retries once per tick, never in a loop. PendingWrite now carries its CommandNumber so the drop site can tell it was the toggle; a pure predicate shouldReArmRealtimeAfterDrop gates it. Android-only: iOS CoreBluetooth serialises writes internally and has no "drop after N GATT retries" path (its realtimeArmedAt is the separate marginal-radio detector), so no Swift twin. Tests: RealtimeReArmOnDropTest pins the predicate (toggle re-arms; every other command + null does not) — the full drop→re-arm needs a live GATT stack the JVM harness can't fake (no Robolectric; same limit + pattern as GattCrashSafetyTest). Needs on-strap confirmation by the reporter (5/MG live is hardware-only-testable). compileFullDebugKotlin + tests green.
DX23876
pushed a commit
to DX23876/noop
that referenced
this pull request
Jul 22, 2026
…bond (ryanbr#490) (ryanbr#495) A WHOOP 5/MG's 0x2A19 battery read fires once in didDiscoverCharacteristicsFor — BEFORE the link is encrypted — which an MG rejects ("Authentication is insufficient"). The rejection is logged and dropped, and the read is never retried, so LiveState.batteryPct stays nil from connect. The 5/MG sends no unsolicited battery notification, so the notify subscription never fills it on its own; the only re-read path (refreshBattery) is reachable only from the Live screen. A user on any other screen never sees a battery %. Re-issue the read in enableLiveNotifications, after the notify loop. Every caller is post-bond (CLIENT_HELLO-ack, post-bond, keep-alive), so the link is encrypted and the read succeeds; the keep-alive caller also re-polls ~every 30s so the reading stays current and BatteryEstimator gets its discharge samples on any screen. This is the iOS parity for Android's post-handshake read + ~60s keep-alive poll (WhoopBleClient BATTERY_ON_CONNECT_DELAY_MS / refreshBattery). 4.0 excluded (its 0x2A19 is a stub 100; ryanbr#77). Estimator guarded by LiveState's 600s same-% throttle. Android already correct — no Kotlin change. Shared Strand BLEManager, so this fixes macOS too. Reported and diagnosed by vishk23.
DX23876
pushed a commit
to DX23876/noop
that referenced
this pull request
Jul 22, 2026
…ault (ryanbr#512) fillWorkoutHrFromStrap read every workout's HR window under a hardcoded "my-whoop". A strap-native workout recorded on a SECOND WHOOP (id "whoop-<mac>", its hrSample rows banked under that id) therefore read an empty window, so `stats.n < minSamples` returned the row UNCHANGED: its Avg HR / Max HR were never reconciled from the strap trace and a null Effort (strain) was never recomputed. Single-WHOOP installs keep the canonical "my-whoop" id, so they matched the default and were unaffected — this only bit multi-WHOOP users. (Calories/energyKcal are NOT touched by this fill — they're computed by the detector under the day owner, which already resolves multi-device correctly. The fill only sets avgHr / maxHr / strain.) Resolve the HR read key per row from the recording device instead: - STRAP-NATIVE rows (source "manual" / detected "<id>-noop") were charted and zoned from their OWN strap's trace, so key on that strap — strip the computed "-noop" suffix to reach the raw hrSample id. - IMPORTED rows (Apple Health / Health Connect / WHOOP CSV / activity file) carry no strap HR; ryanbr#77 still fills them from the strapDeviceId default, so their behaviour is byte-identical (which strap was worn during an imported session on a multi-strap install is undetermined and left as-is). Single-device behaviour is unchanged (detected "my-whoop-noop" strips to "my-whoop", same as the old default). New JVM test pins the classification and the per-row key resolution. Surfaced by ryanbr#510 (a single-WHOOP report whose root cause is still under investigation); this fixes the multi-WHOOP class. Kotlin only; the Swift twin (reconcileWorkoutHrWithTrace) reads under the Repository's single active id and its read-model WorkoutRow carries no deviceId — a parity follow-up will thread the source device through the workout fetch.
ryanbr
added a commit
that referenced
this pull request
Jul 25, 2026
…#797) drainWriteQueue treats writeCharacteristicCompat returning false as proof the frame was not delivered, holds it in pendingRetry and re-writes it. On a Galaxy S24 the write was delivered anyway: one GET_DATA_RANGE drew three CRC-valid responses whose strap-side sequence advanced while the origin-seq echo stayed unchanged, so the strap received the command three times. Only ever after a burst of busy-retries, never on quiet ticks. The reason the retry was never called off is narrow: pendingRetry was cleared only when the drain consumed it, or on reset. Nothing cleared it on a write-completion callback — which is precisely the evidence that the "refused" write landed. So the completion released the in-flight slot, left the retry armed, and the retry sent the frame a second time. A completion for a frame still held for retry now cancels it. This cannot swallow a legitimate retry: pendingRetry is non-null only when the most recent attempt returned BUSY, and the drain never starts a write while one is in flight, so no other outstanding command-channel write could own the completion. A frame the stack truly refused produces no completion at all, so its retry still fires — the #77/#312 protection against a silently dropped TOGGLE_REALTIME_HR / SET_CLOCK / offload-ack is untouched, which is why the retry itself was left in place rather than removed. Not airtight by construction: a completion arriving after the retry has already fired (12 ms base delay) is too late, so this removes the observed case rather than making duplication impossible. The decision is a pure companion predicate because the instance path cannot be unit-tested here — the constructor needs a real Looper and Context, and there is no Robolectric. That is the constraint GattCrashSafetyTest documents and the pattern it follows. Cancellation runs on the main looper, since pendingRetry is main-looper-only state, and is posted without delay so it precedes the retry it cancels.
ryanbr
added a commit
that referenced
this pull request
Jul 25, 2026
…#797) Re-review of the previous commit found the safety argument overstated. It claimed no other outstanding command-channel write could own a completion, because the drain never starts a write while one is in flight. writeBondFrame is exactly such a write: it writes the same characteristic DELIBERATELY outside the queue, sets writeInFlight itself, and never consumes pendingRetry. Its ack is therefore not evidence that a held frame went out, and acting on it would cancel the retry for a frame that never left — turning the duplicate this fixes into the silent loss #77/#312 is about, which is strictly worse than a duplicate. The bond write is now tracked and its completion excluded, with the flag consumed on the callback so a later completion cannot inherit it, and cleared in reset() so a stale flag cannot suppress a legitimate cancellation in the next session. Also corrects the scope claim. Only WITH-response writes reach this path at all, because a without-response write gets no completion callback — the drain frees its own slot after a pacing gap. That happens to cover the reported command and the ones where a duplicate actually harms: GET_DATA_RANGE, SET_CLOCK, the historical acks, haptics and RUN_ALARM are all sent withResponse = true. But it is a real limit rather than a general guarantee, and the previous commit read as though the fix applied to every write.
ryanbr
added a commit
that referenced
this pull request
Jul 25, 2026
…agnostics that pinned it (#797) (#795) Fixes #797, reported as "finding 1" inside the battery report in issue 791. THE BUG. One GET_DATA_RANGE produced three CRC-valid responses whose strap-side sequence advanced (0x7e/0x7f/0x80) while the origin-seq echo stayed 0x0a, so the strap received the command three times. Only ever after a burst of busy-retries, never on quiet ticks; reproduced three times in one session on a Galaxy S24 Ultra. drainWriteQueue treated writeCharacteristicCompat returning false as proof of non-delivery. The reason the retry was never called off is narrow: pendingRetry was cleared only when the drain consumed it, or on reset. Nothing cleared it on a write-completion callback — which is precisely the evidence that the "refused" write landed. The completion released the in-flight slot, left the retry armed, and the retry sent the frame again. THE FIX. A completion for a frame still held for retry cancels that retry. A frame the stack truly refused produces no completion at all, so its retry still fires and the #77/#312 protection against a silently dropped TOGGLE_REALTIME_HR / SET_CLOCK / offload-ack is untouched — which is why the retry was left in place rather than removed. Trading a duplicate for a silent loss would be worse. Two limits, both found by re-review rather than assumed away: The out-of-queue bond write is excluded. writeBondFrame writes the same characteristic deliberately outside the queue and never consumes pendingRetry, so its ack is not evidence about a held frame; acting on it would cancel a retry for a frame that never left. The flag is consumed on the callback so a later completion cannot inherit it, and cleared in reset(). Only WITH-response writes reach this path, because a without-response write gets no completion callback. That covers the reported command and every case where a duplicate actually harms — GET_DATA_RANGE, SET_CLOCK, the historical acks, haptics and RUN_ALARM are all sent withResponse = true — but it is a limit, not a general guarantee. A completion arriving after the retry has already fired is also too late. THE DIAGNOSTICS that made this provable. The Android 13+ write status code is now logged instead of being collapsed into a Boolean, since ERROR_GATT_WRITE_REQUEST_BUSY is documented as "not initiated" and knowing which code came back is the difference between a safe retry and a duplicate. And the 4.0 COMMAND_RESPONSE now exposes resp_seq and result, which the 5/MG path always did: a 4.0 log previously could not say whether a command succeeded, and the reporter had to hand-decode result=0x00 to establish that their strap answers GET_EXTENDED_BATTERY_INFO with FAILURE rather than an empty success. Offsets verified against their real frames. Merged without a compile or a device run: no JDK on the authoring machine and android.yml is disabled. The decision logic is pinned by pure predicates in the repo's established pattern for BLE code, and the decode offsets were verified against real capture frames, but the write-path behaviour has not been exercised on hardware.
ryanbr
pushed a commit
that referenced
this pull request
Aug 6, 2026
…ords evicting real ones (#1101) * fix(archive): evict all-zero-payload records before informative ones The reject archive (#77 / #91) is a ~5 MB rolling JSONL and its eviction was purely age-ordered below the per-version floor (#344). Measured on a WHOOP 5/MG offload: 1,275 consecutive type-47 records of layout version 16, 1584 bytes each, with every byte from offset 21 to the 4-byte CRC32 trailer zero in every one of them. Their own frame clock advanced by 1 s across 933 of the gaps and 0 s across the other 339 — a contiguous 1 Hz producer, not a burst. At ~3.2 KB per hex JSONL line the archive holds about 1,500 of them, i.e. ~21 minutes of retention: under an age-ordered policy every informative record older than that is evicted by frames that carry nothing, while the UI reports a clean sync. Eviction is now weakest-value-first. Everything in a band goes before anything in the next: malformed lines, then surplus lines whose payload region is ENTIRELY zero, then surplus informative lines. The floor splits with it — the newest 64 informative lines per layout version stay protected, all-zero lines get a floor of 8, enough to date the artefact without owning the archive. A version whose records are only ever empty therefore keeps dated proof it exists instead of being erased, and the full 64 stays reserved for records that actually contain something. The emptiness test is strict: one non-zero byte anywhere makes the frame informative, because a small populated block inside an otherwise-empty buffer is exactly what this archive is for. The region starts AFTER the record header (21 on 5/MG, 17 on WHOOP 4 — the same +4 that shifts the type byte 4→8 and the version byte 5→9) and ends before the CRC trailer; a region that included the non-zero seq/timestamp bytes would never test as all-zero and the rule would never fire. A payload region under 32 bytes is not judged at all, so a truncated frame does not lose priority on the strength of a few zero bytes. The 5 MB cap is deliberately NOT raised — at ~3.2 KB a line a 1 Hz stream fills any cap in hours, so a bigger number buys a linear factor of time and costs every user disk. Value, not volume, was the missing thing. An over-cap rewrite now evicts to 7/8 of the cap instead of exactly the cap, so a 1 Hz stream no longer read-parse-rewrites the whole file on the main actor once per record; the bytes given up are the ones eviction would have taken next anyway, in the same priority order. readAll and retention now share one parseArchiveLine, so retention can never protect a line the read-back would then skip. Kotlin twin included: hasZeroPayload/payloadStart/LOW_WATER_DIVISOR and the banded evictLines match byte-for-byte in behaviour. zeroFloor is defaulted on both sides so no existing call site churns. * fix(protocol): archive 5/MG records on layout version, not decode outcome rejectedHistoricalRecords archives a type-47 record only when the DECODE came up short — parse failed, CRC mismatched, or the result carried no unix, or neither heart_rate nor gravity_x. That is the wrong question to ask about a layout NOOP has no field map for. A record whose hist_version is unmapped but which happens to yield a plausible unix plus one of those fields passes the screen and is then kept NOWHERE at all: not as rows, because nothing mapped it, and not as bytes, because it was not archived. The very next trim ack frees it on the strap and it is gone while the UI reports a clean sync (#77 / #91). Today that cannot fire, but only by accident: the unmapped branch of decodeWhoop5Historical reads no offsets, so nothing but hist_version ever reaches `parsed` for such a record. One added static schema field for type 47, or one partially-mapped new version, silently reopens it. The decision is now structural. mappedWhoop5HistoricalVersions ([18, 20, 21, 26]) is the single source of truth behind both the decoder's dispatch and the archive filter, and any 5/MG record outside it is archived whatever it decoded. The if-chain in decodeWhoop5Historical became a switch so the mapped versions read in one place. v18/v20/v21/v26 are unaffected, so nothing already understood starts being archived wholesale. WHOOP 4 is untouched: the rule keys off frame[9], which on a WHOOP 4 frame is a payload byte rather than a version, and its unmapped versions go through the schema's v24 fallback — which keeps a record only when it validates physiologically and otherwise drops the biometrics — so those records already reach the archive by the decode-outcome route. UnmappedHistoricalLayoutTests pins both directions, including a lockstep check that for EVERY version outside the mapped set the decoder populates nothing but hist_version, so the constant and the dispatch switch cannot drift apart without a test failing. The headline test takes a real v18 record, changes only the version byte and re-stamps the CRC32 trailer so the frame stays valid: read at the v18 offsets those bytes still decode a valid unix, a plausible heart rate and a ~1 g gravity vector, which is precisely the record that used to pass. Kotlin twin included, with one deliberate divergence documented at the declaration and asserted in the test: MAPPED_WHOOP5_HISTORICAL_VERSIONS is {18, 26} on Android, not {18, 20, 21, 26}. Swift dispatches v20 (raw optical) and v21 (raw IMU) through decodeWhoop5HistoricalV2021; the Kotlin historical path has no such branch — Whoop5RawOptical and Whoop5RawImu exist there but are wired to the live deep-buffer route. Claiming 20/21 on Android would assert a field map that platform does not have, which is the exact class of bug this constant closes. The archive outcome is identical today either way.
brendankowitz
pushed a commit
to brendankowitz/noop
that referenced
this pull request
Aug 7, 2026
The 7b line: one slim status line for the Today sky with exactly four states — syncing (1.1s-rotating spinner + breathing "Reading the strap · N chunks"), live (breathing sage dot + "Live · NN bpm", the 5/MG experimental-history case), a TRANSIENT "Up to date" that collapses a few seconds after a sync settles, and hidden (zero pixels). All states resolve from the existing SyncChipState/LiveState seam (ryanbr#245); the per-chunk backfilling strobe rides the same 3s debounce the old refresh vessel used. Chunk counts only, never a percent (the strap never says how many remain, so "N chunks left" cannot be said truthfully — the line counts chunks pulled); motion is opacity-only breathing + the spinner rotation, both gated still under reduce-motion. The 7c page (TabRoute.syncStatus → SyncPageView): flat ink, because it is a record of a device, not a moment. The mockup's per-category "Coming across" rows (sleep stages / HRV / respiratory) are illustrative fiction the pipeline cannot back — the offload knows THAT a drain runs and how many chunks landed, not which physiology each chunk carries — so the page shows the single honest "Strap history" row (plus the real preserved-raw-frames count when nonzero, ryanbr#77/ryanbr#91), the Device facts LiveState actually holds (connection label ryanbr#266, battery, last full sync), an "All devices" handoff to DevicesView rather than a parallel device surface, and the "you can leave, it keeps going" reassurance while a sync runs. `--demo-sync` (DEBUG-only) pins the states for screenshots, since no simulator run ever backfills a real strap. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GPcFU4xvYzxovzd4aQ7uGB
ryanbr
added a commit
that referenced
this pull request
Aug 10, 2026
WHOOP 4.0 users report the strap battery blanking to "—" on Today while the charging bolt keeps working. Root cause: the two signals had different sources. Battery % came ONLY from the polled GET_BATTERY_LEVEL command-response (keep-alive, ~60s), so a stalled/late poll — or a fresh LiveState after relaunch — leaves batteryPct nil. Charging, meanwhile, comes from the UNSOLICITED BATTERY_LEVEL event (~8 min) + CHARGING_ON/OFF that the strap pushes, so it keeps updating. The same pushed BATTERY_LEVEL event already carries the real SoC (soc@17/10, range-gated raw<=1100 in the parser — the exact value history banks). FrameRouter / WhoopBleClient already read charging + mV from it but not the percent. Read battery_pct from that event too, so the live % is as robust as charging and no longer depends solely on the polled command. Live-only: Swift's FrameRouter never sees replayed offload frames (backfill skips it); the Kotlin twin gates on the same shouldApplyChargingFromBattery- Event(replayedOffload) guard charging uses. The #77 family caveat does not apply — that was the 0x2A19 characteristic STUB (constant 100 on 4.0), a different source; the BATTERY_LEVEL event's SoC is real for both families. BLE path — needs on-strap validation (a 4.0 where the % had blanked should now refill within ~8 min without a reconnect). Kotlin compiles; Swift (FrameRouter, app-target) compile-gated via a testing build.
simoncad7
pushed a commit
to simoncad7/noop
that referenced
this pull request
Aug 17, 2026
Co-authored-by: kavemang <kavemang@users.noreply.github.com>
simoncad7
pushed a commit
to simoncad7/noop
that referenced
this pull request
Aug 17, 2026
GermanLocalizationTest.germanResourcesResolveForEveryLocalizedKey fails on main since the compact widget landed (ryanbr#77): widget_compact_name and widget_compact_description exist in values/ but not values-de/. Adds both, reusing the app's established German terms (Ruhe/Ladung/Anstrengung, Live-Herzfrequenz, Band-Akku) from widget_description.
simoncad7
pushed a commit
to simoncad7/noop
that referenced
this pull request
Aug 17, 2026
…ecovers (ryanbr#312) (ryanbr#317) Reported on Android / WHOOP 5.0 / 8.6.2-staging: HRV & Autonomic shows no live data while the Live section streams HR fine, after a remove+reconnect. The strap log shows `→ TOGGLE_REALTIME_HR` then `writeCharacteristic busy; retry 1/12`. Root cause (Android-only): plain HR rides the standard unbonded 0x2A37 profile, but live R-R (→ HRV / Autonomic) needs the TOGGLE_REALTIME_HR write to land. Every path that arms it (reconcileRealtime + the direct arm-on-connect / keep-alive paths) latches realtimeArmed = the value it SENT *before* the write is confirmed. Android GATT allows one write in flight, so under contention the toggle can exhaust MAX_WRITE_RETRIES (12) and be dropped — but realtimeArmed is already latched, so reconcileRealtime and the 30s keep-alive tick see no edge (want == armed) and never re-send. R-R stays dark forever while HR keeps flowing. The drop path even documents that a dropped TOGGLE_REALTIME_HR "silently breaks live HR" (ryanbr#77) but only mitigates with the 12 retries, no re-arm. Fix: when the write queue drops a frame, if it was TOGGLE_REALTIME_HR, flip realtimeArmed. The write never reached the strap, so its true state is the OPPOSITE of the optimistically latched value; flipping makes the next keep-alive reconcile detect the edge and re-send the CURRENT want — recovering a dropped ARM *or* disarm within ~30s. Bounded: the re-send rides the keep-alive cadence (not the drop path), so a persistently-busy stack retries once per tick, never in a loop. PendingWrite now carries its CommandNumber so the drop site can tell it was the toggle; a pure predicate shouldReArmRealtimeAfterDrop gates it. Android-only: iOS CoreBluetooth serialises writes internally and has no "drop after N GATT retries" path (its realtimeArmedAt is the separate marginal-radio detector), so no Swift twin. Tests: RealtimeReArmOnDropTest pins the predicate (toggle re-arms; every other command + null does not) — the full drop→re-arm needs a live GATT stack the JVM harness can't fake (no Robolectric; same limit + pattern as GattCrashSafetyTest). Needs on-strap confirmation by the reporter (5/MG live is hardware-only-testable). compileFullDebugKotlin + tests green.
simoncad7
pushed a commit
to simoncad7/noop
that referenced
this pull request
Aug 17, 2026
…bond (ryanbr#490) (ryanbr#495) A WHOOP 5/MG's 0x2A19 battery read fires once in didDiscoverCharacteristicsFor — BEFORE the link is encrypted — which an MG rejects ("Authentication is insufficient"). The rejection is logged and dropped, and the read is never retried, so LiveState.batteryPct stays nil from connect. The 5/MG sends no unsolicited battery notification, so the notify subscription never fills it on its own; the only re-read path (refreshBattery) is reachable only from the Live screen. A user on any other screen never sees a battery %. Re-issue the read in enableLiveNotifications, after the notify loop. Every caller is post-bond (CLIENT_HELLO-ack, post-bond, keep-alive), so the link is encrypted and the read succeeds; the keep-alive caller also re-polls ~every 30s so the reading stays current and BatteryEstimator gets its discharge samples on any screen. This is the iOS parity for Android's post-handshake read + ~60s keep-alive poll (WhoopBleClient BATTERY_ON_CONNECT_DELAY_MS / refreshBattery). 4.0 excluded (its 0x2A19 is a stub 100; ryanbr#77). Estimator guarded by LiveState's 600s same-% throttle. Android already correct — no Kotlin change. Shared Strand BLEManager, so this fixes macOS too. Reported and diagnosed by vishk23.
simoncad7
pushed a commit
to simoncad7/noop
that referenced
this pull request
Aug 17, 2026
…ault (ryanbr#512) fillWorkoutHrFromStrap read every workout's HR window under a hardcoded "my-whoop". A strap-native workout recorded on a SECOND WHOOP (id "whoop-<mac>", its hrSample rows banked under that id) therefore read an empty window, so `stats.n < minSamples` returned the row UNCHANGED: its Avg HR / Max HR were never reconciled from the strap trace and a null Effort (strain) was never recomputed. Single-WHOOP installs keep the canonical "my-whoop" id, so they matched the default and were unaffected — this only bit multi-WHOOP users. (Calories/energyKcal are NOT touched by this fill — they're computed by the detector under the day owner, which already resolves multi-device correctly. The fill only sets avgHr / maxHr / strain.) Resolve the HR read key per row from the recording device instead: - STRAP-NATIVE rows (source "manual" / detected "<id>-noop") were charted and zoned from their OWN strap's trace, so key on that strap — strip the computed "-noop" suffix to reach the raw hrSample id. - IMPORTED rows (Apple Health / Health Connect / WHOOP CSV / activity file) carry no strap HR; ryanbr#77 still fills them from the strapDeviceId default, so their behaviour is byte-identical (which strap was worn during an imported session on a multi-strap install is undetermined and left as-is). Single-device behaviour is unchanged (detected "my-whoop-noop" strips to "my-whoop", same as the old default). New JVM test pins the classification and the per-row key resolution. Surfaced by ryanbr#510 (a single-WHOOP report whose root cause is still under investigation); this fixes the multi-WHOOP class. Kotlin only; the Swift twin (reconcileWorkoutHrWithTrace) reads under the Repository's single active id and its read-model WorkoutRow carries no deviceId — a parity follow-up will thread the source device through the workout fetch.
simoncad7
pushed a commit
to simoncad7/noop
that referenced
this pull request
Aug 17, 2026
…agnostics that pinned it (ryanbr#797) (ryanbr#795) Fixes ryanbr#797, reported as "finding 1" inside the battery report in issue 791. THE BUG. One GET_DATA_RANGE produced three CRC-valid responses whose strap-side sequence advanced (0x7e/0x7f/0x80) while the origin-seq echo stayed 0x0a, so the strap received the command three times. Only ever after a burst of busy-retries, never on quiet ticks; reproduced three times in one session on a Galaxy S24 Ultra. drainWriteQueue treated writeCharacteristicCompat returning false as proof of non-delivery. The reason the retry was never called off is narrow: pendingRetry was cleared only when the drain consumed it, or on reset. Nothing cleared it on a write-completion callback — which is precisely the evidence that the "refused" write landed. The completion released the in-flight slot, left the retry armed, and the retry sent the frame again. THE FIX. A completion for a frame still held for retry cancels that retry. A frame the stack truly refused produces no completion at all, so its retry still fires and the ryanbr#77/ryanbr#312 protection against a silently dropped TOGGLE_REALTIME_HR / SET_CLOCK / offload-ack is untouched — which is why the retry was left in place rather than removed. Trading a duplicate for a silent loss would be worse. Two limits, both found by re-review rather than assumed away: The out-of-queue bond write is excluded. writeBondFrame writes the same characteristic deliberately outside the queue and never consumes pendingRetry, so its ack is not evidence about a held frame; acting on it would cancel a retry for a frame that never left. The flag is consumed on the callback so a later completion cannot inherit it, and cleared in reset(). Only WITH-response writes reach this path, because a without-response write gets no completion callback. That covers the reported command and every case where a duplicate actually harms — GET_DATA_RANGE, SET_CLOCK, the historical acks, haptics and RUN_ALARM are all sent withResponse = true — but it is a limit, not a general guarantee. A completion arriving after the retry has already fired is also too late. THE DIAGNOSTICS that made this provable. The Android 13+ write status code is now logged instead of being collapsed into a Boolean, since ERROR_GATT_WRITE_REQUEST_BUSY is documented as "not initiated" and knowing which code came back is the difference between a safe retry and a duplicate. And the 4.0 COMMAND_RESPONSE now exposes resp_seq and result, which the 5/MG path always did: a 4.0 log previously could not say whether a command succeeded, and the reporter had to hand-decode result=0x00 to establish that their strap answers GET_EXTENDED_BATTERY_INFO with FAILURE rather than an empty success. Offsets verified against their real frames. Merged without a compile or a device run: no JDK on the authoring machine and android.yml is disabled. The decision logic is pinned by pure predicates in the repo's established pattern for BLE code, and the decode offsets were verified against real capture frames, but the write-path behaviour has not been exercised on hardware.
simoncad7
pushed a commit
to simoncad7/noop
that referenced
this pull request
Aug 17, 2026
…ords evicting real ones (ryanbr#1101) * fix(archive): evict all-zero-payload records before informative ones The reject archive (ryanbr#77 / ryanbr#91) is a ~5 MB rolling JSONL and its eviction was purely age-ordered below the per-version floor (ryanbr#344). Measured on a WHOOP 5/MG offload: 1,275 consecutive type-47 records of layout version 16, 1584 bytes each, with every byte from offset 21 to the 4-byte CRC32 trailer zero in every one of them. Their own frame clock advanced by 1 s across 933 of the gaps and 0 s across the other 339 — a contiguous 1 Hz producer, not a burst. At ~3.2 KB per hex JSONL line the archive holds about 1,500 of them, i.e. ~21 minutes of retention: under an age-ordered policy every informative record older than that is evicted by frames that carry nothing, while the UI reports a clean sync. Eviction is now weakest-value-first. Everything in a band goes before anything in the next: malformed lines, then surplus lines whose payload region is ENTIRELY zero, then surplus informative lines. The floor splits with it — the newest 64 informative lines per layout version stay protected, all-zero lines get a floor of 8, enough to date the artefact without owning the archive. A version whose records are only ever empty therefore keeps dated proof it exists instead of being erased, and the full 64 stays reserved for records that actually contain something. The emptiness test is strict: one non-zero byte anywhere makes the frame informative, because a small populated block inside an otherwise-empty buffer is exactly what this archive is for. The region starts AFTER the record header (21 on 5/MG, 17 on WHOOP 4 — the same +4 that shifts the type byte 4→8 and the version byte 5→9) and ends before the CRC trailer; a region that included the non-zero seq/timestamp bytes would never test as all-zero and the rule would never fire. A payload region under 32 bytes is not judged at all, so a truncated frame does not lose priority on the strength of a few zero bytes. The 5 MB cap is deliberately NOT raised — at ~3.2 KB a line a 1 Hz stream fills any cap in hours, so a bigger number buys a linear factor of time and costs every user disk. Value, not volume, was the missing thing. An over-cap rewrite now evicts to 7/8 of the cap instead of exactly the cap, so a 1 Hz stream no longer read-parse-rewrites the whole file on the main actor once per record; the bytes given up are the ones eviction would have taken next anyway, in the same priority order. readAll and retention now share one parseArchiveLine, so retention can never protect a line the read-back would then skip. Kotlin twin included: hasZeroPayload/payloadStart/LOW_WATER_DIVISOR and the banded evictLines match byte-for-byte in behaviour. zeroFloor is defaulted on both sides so no existing call site churns. * fix(protocol): archive 5/MG records on layout version, not decode outcome rejectedHistoricalRecords archives a type-47 record only when the DECODE came up short — parse failed, CRC mismatched, or the result carried no unix, or neither heart_rate nor gravity_x. That is the wrong question to ask about a layout NOOP has no field map for. A record whose hist_version is unmapped but which happens to yield a plausible unix plus one of those fields passes the screen and is then kept NOWHERE at all: not as rows, because nothing mapped it, and not as bytes, because it was not archived. The very next trim ack frees it on the strap and it is gone while the UI reports a clean sync (ryanbr#77 / ryanbr#91). Today that cannot fire, but only by accident: the unmapped branch of decodeWhoop5Historical reads no offsets, so nothing but hist_version ever reaches `parsed` for such a record. One added static schema field for type 47, or one partially-mapped new version, silently reopens it. The decision is now structural. mappedWhoop5HistoricalVersions ([18, 20, 21, 26]) is the single source of truth behind both the decoder's dispatch and the archive filter, and any 5/MG record outside it is archived whatever it decoded. The if-chain in decodeWhoop5Historical became a switch so the mapped versions read in one place. v18/v20/v21/v26 are unaffected, so nothing already understood starts being archived wholesale. WHOOP 4 is untouched: the rule keys off frame[9], which on a WHOOP 4 frame is a payload byte rather than a version, and its unmapped versions go through the schema's v24 fallback — which keeps a record only when it validates physiologically and otherwise drops the biometrics — so those records already reach the archive by the decode-outcome route. UnmappedHistoricalLayoutTests pins both directions, including a lockstep check that for EVERY version outside the mapped set the decoder populates nothing but hist_version, so the constant and the dispatch switch cannot drift apart without a test failing. The headline test takes a real v18 record, changes only the version byte and re-stamps the CRC32 trailer so the frame stays valid: read at the v18 offsets those bytes still decode a valid unix, a plausible heart rate and a ~1 g gravity vector, which is precisely the record that used to pass. Kotlin twin included, with one deliberate divergence documented at the declaration and asserted in the test: MAPPED_WHOOP5_HISTORICAL_VERSIONS is {18, 26} on Android, not {18, 20, 21, 26}. Swift dispatches v20 (raw optical) and v21 (raw IMU) through decodeWhoop5HistoricalV2021; the Kotlin historical path has no such branch — Whoop5RawOptical and Whoop5RawImu exist there but are wired to the live deep-buffer route. Claiming 20/21 on Android would assert a field map that platform does not have, which is the exact class of bug this constant closes. The archive outcome is identical today either way.
simoncad7
pushed a commit
to simoncad7/noop
that referenced
this pull request
Aug 17, 2026
…r#1226) WHOOP 4.0 users report the strap battery blanking to "—" on Today while the charging bolt keeps working. Root cause: the two signals had different sources. Battery % came ONLY from the polled GET_BATTERY_LEVEL command-response (keep-alive, ~60s), so a stalled/late poll — or a fresh LiveState after relaunch — leaves batteryPct nil. Charging, meanwhile, comes from the UNSOLICITED BATTERY_LEVEL event (~8 min) + CHARGING_ON/OFF that the strap pushes, so it keeps updating. The same pushed BATTERY_LEVEL event already carries the real SoC (soc@17/10, range-gated raw<=1100 in the parser — the exact value history banks). FrameRouter / WhoopBleClient already read charging + mV from it but not the percent. Read battery_pct from that event too, so the live % is as robust as charging and no longer depends solely on the polled command. Live-only: Swift's FrameRouter never sees replayed offload frames (backfill skips it); the Kotlin twin gates on the same shouldApplyChargingFromBattery- Event(replayedOffload) guard charging uses. The ryanbr#77 family caveat does not apply — that was the 0x2A19 characteristic STUB (constant 100 on 4.0), a different source; the BATTERY_LEVEL event's SoC is real for both families. BLE path — needs on-strap validation (a 4.0 where the % had blanked should now refill within ~8 min without a reconnect). Kotlin compiles; Swift (FrameRouter, app-target) compile-gated via a testing build.
ryanbr
added a commit
that referenced
this pull request
Aug 25, 2026
Re-review, and the history sharpens what this was. `strapDeviceId` and its canonical default came from #77, the original imported-HR feature. The union resolver came much later, in #857 — "one HR device-id rule for the chart, zones, Avg HR and HRR". That commit wired the fill to the new resolver but left it passing this #77-era parameter, so the one rule ran on two different inputs, and Avg HR was the single surface that did not get it. The reported page is that gap: chart, zones and HRR from the union, the average from the canonical id alone. The parameter is now REQUIRED. Once both call sites were corrected nothing depended on the default, and a caller that cannot name the strap it means should not be quietly handed the canonical one — that silence is the whole defect. Swift has no such parameter at all, so this also narrows the shapes rather than widening them. Also worth recording, since a tester will try it: `AppViewModel.deviceId` snapshots `NoopApplication.activeDeviceId`, which is `by lazy` — so it is fixed for the process. Switching straps needs an app restart before HR reads follow. That is pre-existing and applies identically to the chart, which reads the same value; this change does not introduce it and does not widen it, but the fill now shares the limitation rather than sidestepping it with a constant. Suite 4308/0, assembleFullDebug, doc lint.
ryanbr
added a commit
that referenced
this pull request
Aug 25, 2026
Reported by @bartmuskala: three Health Connect cycling sessions rendering three different pages. Two showed "AVG –" beside a populated HR curve, a PEAK, a LOW, a full zone split labelled "From strap HR", and — on one — a complete HR-recovery block. Every one of those is derived from the samples the average claimed not to have. The chart, zones and recovery resolve their read key through `workoutHrDeviceIds(source, rowDeviceId, activeStrapId = vm.deviceId)`, which for an imported row is `importedSourceIdsFor(active)` = active ∪ canonical. `fillWorkoutHrFromStrap` took a `strapDeviceId` defaulting to the canonical id and BOTH UI call sites left it defaulted, so the fill resolved the canonical id ALONE. On an install whose strap banks under a non-canonical id — a re-add, or a 5/MG's transient CB-UUID pairing (#1193) — the graph found the trace and the fill did not. The history names the regression precisely. `strapDeviceId` and its canonical default came from #77, the original imported-HR feature. The union resolver came later, in #857 — "one HR device-id rule for the chart, zones, Avg HR and HRR". That commit wired the fill to the new resolver but kept passing it the #77-era parameter, so the one rule ran on two inputs and Avg HR was the one surface that never got it. It is also #836/#928 for the same reporter: that fix made the resolver return the active union precisely so a re-added strap stops leaving Avg HR and Effort blank, and this call site handed it a constant instead. Both call sites now pass the active strap, and the parameter is REQUIRED — nothing depended on the default once they were corrected, and a caller that cannot name the strap it means should not be quietly handed the canonical one. Swift has no such parameter at all (`reconcileWorkoutHrWithTrace` reads the Repository's active deviceId directly), so this brings Android to the reference implementation rather than changing shared behaviour. The parameter doc claimed the canonical default was deliberate, on the grounds that which strap was worn during an imported session is undetermined on a MULTI- strap install. True, and unresolved by this — but it made the common SINGLE-strap case wrong, and the union with its first-wins merge is exactly what the chart already applies to the same window. Rewritten to say that rather than the opposite. Test pins the asymmetry rather than the call site, which is a ViewModel hop and unreachable from a Robolectric-free suite: the canonical constant resolves to a STRICT subset that cannot see a non-canonical strap's samples. Verified: Android suite 4308 green, assembleFullDebug, doc lint. Two limits worth recording. The reporter attached no strap log, so their active id being non-canonical is likely (a 5.0, given #1193) but unconfirmed — the fix restores the documented display == graph == zones == effort invariant regardless and can only widen the id set, never narrow it. And `AppViewModel.deviceId` snapshots a `by lazy` value, so a strap switch needs an app restart before reads follow; that is pre-existing, applies identically to the chart, and diverges from Swift, whose read id is re-pointed on every registry change (#814).
ryanbr
added a commit
that referenced
this pull request
Aug 25, 2026
A WHOOP 4.0 field log banks two battery samples of 81% thirty seconds before the strap's own report says 39.2, then stays near 39 for the rest of the session. Those two are the only samples following a "Reading standard Battery Level (0x2A19)" line, which appears exactly twice and never again once the strap is identified. The 4.0's 0x2A19 is a stub rather than a state of charge (#77), but both readers asked the family question the wrong way round — `!= WHOOP4` — against a connectedFamily that defaults to WHOOP4, is written only at service discovery, and was never cleared per-connection, so between links it held the PREVIOUS strap's family. Asking that of a guess answers "use the standard characteristic" for a 4.0. It is the #171 shape the family resolver's own docs warn about: a family question answered by a fall-through rather than by evidence. Not cosmetic — banked samples feed the discharge-slope estimate (#713), so a phantom 81% at session start reaches the "time left" readout and lands in the history the open 4.0 drain work reads. Clear the establishment state per-connection alongside the other flags in reset(), and route both readers through one pure decision requiring positive evidence: defer while unknown, custom command for a 4.0, standard characteristic only for a link positively established as non-4.0. Deferring is safe: the battery is polled about every 60s, and the post-connect refresh sits inside an already-established 5/MG branch behind a 1.5s delay. reset() runs only in connectToDevice() and teardown, never between discovery and use. Also corrected the comment claiming that characteristic is a "constant 100". It returned 81 here — plausible enough to pass any range check, which is why the source and not the value has to be the gate. Android only. Swift has the identical gate shape but feeds it selectedModel, which has no generic case, defaults to .whoop4 (the safe branch), and is set by the service-filtered scan or adopt that reached the strap. The one residual — willRestoreState with a stale persisted model — is unevidenced and left for its own issue. Verified: app-build green both legs; Android build-and-test green; 4 new JVM tests, one sweeping every (established, family) pair to assert exactly one reaches the standard characteristic; doc_comment_lint and i18n_audit clean. Not strap-verified — the 81/39.2 pair above is the reproduction.
DX23876
added a commit
to DX23876/noop
that referenced
this pull request
Oct 16, 2026
…from the strap trace A workout recorded while the strap link was down is saved without average, peak or Effort, though the strap hands its own trace over at the next sync. Average and peak already followed that trace at read time (ryanbr#77/ryanbr#499); the Effort stayed empty. The analysis pass now stores all three for recorded or entered workouts saved without heart rate, through the same resolver and fill table as the Apple Health fill, in their own namespace, laid over the row at read time. Recorded in docs/fork/decisions.md. Analysis migration required: no. New fills for rows that had no heart rate; nothing already scored changes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


What this PR does
Adds a separate
NOOP CompactAndroid home-screen widget for Rest, Charge, and Effort.The existing widget stays unchanged. The compact widget uses moon, lightning, and dumbbell icons for the three daily scores, with live heart rate and strap battery underneath.
Type of change
How it was tested
./gradlew --no-daemon :app:compileFullDebugKotlinNOOP Compactwidget in the launcher picker.Checklist
swift testinPackages/<name>)android/(./gradlew testFullDebugUnitTest)StrandDesigntokens - no hardcoded colors, fonts, or spacingdocs/CONTRIBUTING.mdStrand.xcodeproj/) or any secrets/keystoresRelated issues