Skip to content

Show Android the strap-clock readout it was already computing - #2089

Closed
ryanbr wants to merge 5 commits into
mainfrom
fix/android-clock-latched-readout
Closed

ryanbr wants to merge 5 commits into
mainfrom
fix/android-clock-latched-readout

Conversation

@ryanbr

@ryanbr ryanbr commented Sep 11, 2026 •

Copy link
Copy Markdown
Owner

Parity gap found while reviewing #2085 (iOS, WHOOP 5.0: live HR and steps fine, no sleep or HRV ever, Test Centre showing Clock latched: no, records dated 1970/71).

That reporter is on iOS, where the diagnosis is visible. On Android it would not have been.

Two readouts, computed and never shown

ConnectionReadout.clockLatchedLabel and ConnectionReadout.rtcWarning are both implemented in the Kotlin twin and covered by ConnectionReadoutTest, and neither had a production caller. Android's entire use of ConnectionReadout was uptimeLabel, reconnectCount, lastOffloadResult, linkEpitaph and linkBankedSummary.

So an Android user whose strap clock was never set, which is the most common cause of "live HR works but no history ever arrives", could learn that only by exporting a strap log and reading the verdict token themselves. iOS has shown the row and the plain-words warning on Test Centre and Devices throughout.

What made it unshowable

A missing value, not a missing view.

A WHOOP 5/MG does not serve GET_CLOCK (the Interpreter's capture note is explicit that it is intentionally left undecoded on that family), so the correlated device clock the WHOOP4 path derives is always nil there. The only evidence of a never-set RTC is how the strap dated its own banked records.

iOS reads that from LiveState.strapRange.newestUnix. Android's LiveState had no equivalent field, so even a wired-up row could only ever have said "waiting for the strap clock" forever, which is the shape #827 describes.

LiveState now carries strapNewestUnix, published where the GET_DATA_RANGE reply is already parsed. It is set unconditionally, not inside the Test Centre gate sitting beside it: the readout that needs it is the one a reporter is asked to quote, and gating the value on a diagnostic mode would leave it null for exactly the user who has not turned one on.

Rendered the way iOS renders it

Through the connection panel, not the registry's liveReadout ids.

My first attempt added a clockLatched id to the registry and broke TestModeRegistryParityTest, which was right to fail: that list must match the Swift TestModeRegistry byte-for-byte, and iOS does not bind those ids row-by-row anyway. Its ConnectionReadoutPanel hard-codes seven rows while the list names three. So the id list stays untouched and the row is rendered beside the others, with the logic in a pure helper so the wiring stays testable without a Composable.

It also restores the #1818 nuance

The log-only clock verdict Android does emit says "charge to 100% and reconnect" unconditionally. rtcWarning tells an already-charged strap that charging again will not change anything and to export a log instead. Android users at 100% were being sent round exactly the loop that copy was written to end.

Found by re-review

  • The verdict could outlive the link that produced it. strapNewestUnix was published and never cleared, so after a disconnect the row kept answering from a dead link, and after a device switch it would have answered for the previous strap. macOS clears its strapRange on exactly this path, noting that "a stale clock-drift window must not outlive the link either"; clearedBiometrics is that path here, and the field is cleared there now. A test reads the row either side of the clear.
  • The publish line landed between the CAPTURE-B comment and the block it explains, so that comment read as documentation for my line while its own block lost it. Plain // comments, which doc_comment_lint does not inspect, so it had to be caught by reading.

Pass two, both in the call itself

  • The scan ran for every mode, not just Connection. I wrote the call followed by takeIf, and takeIf runs on the result, so the log walk happened for every Test Centre panel and was then discarded. Worse than a wasted call: this panel recomposes once a second off the connection clock, and clockCorrelatedDevice walks the whole log looking for a line a 5/MG never emits; the miss is therefore a full scan, once a second, on the family this readout exists for. Gated before the call now, and remembered on its inputs so a clock tick alone cannot re-run it.
  • The charge could come from a link that had already ended. rtcWarning requires callers to pass a reading from the CURRENT link and never a last-known one, because a stale 100% withdraws the "charge it" remedy from the very strap that earned it by running flat and resetting its RTC. Android never clears batteryPct on disconnect (macOS clears its battery samples), so the stale value is exactly what my call passed. Gated on connected; null keeps the charge advice, which is the safe direction to be wrong in.

A residual stays, deliberately: a reconnect that has not yet read battery on the new link still carries the previous one's value. Clearing batteryPct on disconnect is the real fix and belongs with the battery display it would also change.

Also on the Devices card

Test Centre is a diagnostic mode a user has to switch on; Devices is the screen they already have open, and macOS has shown the clock state on the active card since the #987 work. Surfacing it only in Test Centre left the person most likely to need it still unable to see it. The card takes the same two values macOS passes it, liveClockLine and liveClockWarning, active card only.

Two deliberate differences from macOS, both about where Android keeps things:

  • The line is the clock half only. macOS pairs it with a last-frame freshness Android cannot show: it has no lastFrameAtUnix to read. That belongs with the other unsurfaced ConnectionReadout functions, not smuggled in here.
  • The device clock is not resolved on that screen. It comes from the strap log, which lives in the BLE client here rather than in LiveState as on macOS, so reading it would subscribe Devices to the log revision and rebuild the line list several times a second through an offload burst. It is a WHOOP4-only signal anyway, and the strap's own record dating is what matters and is the only evidence a 5/MG gives. The cost is wording on a 4.0 with a dead RTC, not the verdict.

So the helper takes the device clock resolved rather than the log to resolve it from, since the two callers legitimately differ, and a test pins that it honours whichever it is handed.

Pass three

  • The clock line went to every card, not just the active one. The Devices call site runs per device, and I passed liveClockLine / liveClockWarning ungated while liveHistoryLayout two lines above does exactly that gate. On a two-strap install the inactive strap's card would have printed the active strap's verdict: a diagnostic answering for a device it never observed. macOS gates both on device.status == .active; so does this now.

Verification

  • Full Android suite 5987 tests, 0 failures; 7 new cases pin the wiring (epoch-dated strap warns, sane strap does not, no evidence yet says "waiting" and raises nothing, and an already-charged strap is not told to charge again)
  • The rules themselves were already pinned in ConnectionReadoutTest; these pin that they reach a screen
  • doc_comment_lint.py and i18n_audit.py --ci green
  • Android-only change; no Swift touched, and the registry parity test is untouched and still passing
  • Not exercised on hardware: no strap with a broken RTC was connected. The value is published where GET_DATA_RANGE is already parsed and the readout functions were already tested, but the end-to-end path from a real epoch-clock strap to the rendered warning has not been seen on a device.

#2085 itself stays open: it is an iOS report and still needs that reporter's strap log.

Reviewing #2085 (iOS, WHOOP 5.0, live HR fine and no sleep ever) turned up that the two readouts that
diagnose it have no Android caller at all. ConnectionReadout.clockLatchedLabel and
ConnectionReadout.rtcWarning are both implemented in the Kotlin twin AND covered by
ConnectionReadoutTest, and nothing in production calls either. iOS has shown both on Test Centre and
Devices since the #987 work. An Android user with the same strap could only find out by exporting a log and
reading it themselves.

The gap that kept it unshowable was a missing value, not a missing view. A WHOOP 5/MG does not serve
GET_CLOCK, so the correlated device clock the WHOOP4 path derives is always null there, and the only
evidence of a never-set RTC is how the strap DATED its own banked records. iOS reads that from
LiveState.strapRange.newestUnix; Android had no equivalent field, so the readout could only ever have
said "waiting for the strap clock" forever, which is the shape #827 describes and #1823 changed on iOS.

LiveState now carries strapNewestUnix, published where the GET_DATA_RANGE reply is already parsed. It
is set unconditionally rather than inside the Test Centre gate beside it: the readout that needs it is
the one a reporter is asked to quote, and gating the value on a diagnostic mode would leave it null
for exactly the user who has not turned one on.

Rendered the way iOS renders it, in the connection panel rather than through the registry's
liveReadout ids. A first attempt added an id there and broke TestModeRegistryParityTest, which is
right to fail: that list is a spec both platforms carry byte-for-byte, and iOS's own panel already
shows more rows than it names. The pure helper keeps the wiring testable without the Composable.

Also carries the #1818 nuance Android was missing. The log-only clock verdict says "charge to 100%"
unconditionally, while rtcWarning tells an already-charged strap that charging again will not help and
to export a log instead. Android users at 100% were being sent round the loop that copy was written to
end.
Re-review of my own change, two findings.

The verdict could outlive the link that produced it. strapNewestUnix was published and never cleared,
so after a disconnect the Clock-latched row kept answering from a dead link, and after a device SWITCH
it would have answered for the previous strap. macOS clears its strapRange on exactly this path, with
the note that a stale clock-drift window must not outlive the link either; clearedBiometrics is that
path here, and the field is cleared there now. Pinned by a test that reads the row either side of the
clear.

The publish line also landed between the CAPTURE-B comment and the block it explains, so that comment
read as documentation for my line and the block it belonged to lost its own. Plain slash comments,
which the doc lint does not inspect, so it had to be caught by reading. Moved above the block.
…dead link

Re-review of my own change, two findings, both in the call I added.

The scan ran for every mode, not just Connection. I wrote it as a call followed by takeIf, and takeIf
runs on the RESULT, so the log walk happened for every Test Centre panel and was then thrown away.
Worse than a wasted call: this panel recomposes once a second off the connection clock, and
clockCorrelatedDevice walks the whole log looking for a line a WHOOP 5/MG never emits, so the miss is
a full scan, once a second, on the family the readout exists for. Gated before the call now, and
remembered on its inputs so a clock tick alone cannot re-run it.

The charge could come from a link that had ended. rtcWarning tells its callers in as many words to
pass a reading from the CURRENT link and never a last-known one, because a stale 100% withdraws the
"charge it" remedy from the very strap that earned it by running flat and resetting its RTC. Android
never clears batteryPct on disconnect, where macOS clears its battery samples, so the stale value is
exactly what my call passed. It is gated on connected now; null keeps the charge advice, which is the
safe direction to be wrong in.

A residual stays, and is not this change's to take on: a reconnect that has not yet read battery on
the new link still carries the previous one's value. Clearing batteryPct on disconnect is the real
fix and belongs with the battery display it would also change.
…look

Test Centre is a diagnostic mode a user has to switch on. Devices is the screen they already have
open, and macOS has shown the clock state on the active card since the #987 work. Surfacing it only
in Test Centre left the person most likely to need it still unable to see it.

The device card takes the same two values macOS passes it, liveClockLine and liveClockWarning, active
card only. The line carries the tokens; the warning carries the sentence, which is the half that says
what to do.

Two deliberate differences from macOS, both because of where Android keeps things.

The line is the clock half only. macOS pairs it with a last-frame freshness that this platform cannot
show: it has no lastFrameAtUnix to read. That belongs with the other ConnectionReadout functions
Android computes and never surfaces, not smuggled in here.

The device clock is not resolved on this screen. It comes from the strap log, which lives in the BLE
client here rather than in LiveState as it does on macOS, so reading it would subscribe the Devices
screen to the log revision and rebuild the line list several times a second through an offload burst.
It is a WHOOP4-only signal anyway, and the strap's own dating of its records is what matters and is
the only evidence a 5/MG gives. The cost is wording on a 4.0 with a dead RTC, not the verdict.

So the helper now takes the device clock RESOLVED rather than the log to resolve it from, because the
two callers legitimately differ, and a test pins that it honours whichever it is handed.
The Devices call site runs per device, and I passed the clock line and warning to every card without
gating on which one is active. liveHistoryLayout sits two lines above doing exactly that gate, so on a
two-strap install the inactive strap's card would have printed the ACTIVE strap's verdict: a
diagnostic answering for a device it never observed, which is the failure this codebase already has a
rule against. macOS gates both on device.status == .active; so does this now.
@ryanbr

ryanbr commented Sep 11, 2026

Copy link
Copy Markdown
Owner Author

Closing unmerged.

The deciding fact came out of reviewing this against the Android reports it was meant to serve. #1313 ("Sleep isn't tracked at all") is the only Android occurrence of this failure on record, and its clock was FUTURE-dated, not epoch-era. clockLatchedLabel only asks whether the value is below the 1970/71 ceiling, so a future-dated clock reads "yes". This change would have shown that reporter a healthy-looking row while the clock was the whole problem.

Widening the label is not the answer either. A future-dated clock IS latched, it is set and wrong, so making the yes/no say "no" would be false. That case wants the clock VERDICT, and clockVerdict already exists on both platforms, already separates FUTURE-DATED / RTC-EPOCH / CLOCK-WARNING / clockOk, and is already computed on Android for the log. Surfacing that instead of a latched yes/no is a different change, on both platforms, and worth deciding deliberately rather than bolting onto this one.

So this is narrow, blind to the one Android case on file, and was never exercised against a strap with a broken RTC.

What is worth keeping is the finding rather than the code, and it is recorded in the issue linked below: Android computes five ConnectionReadout functions it never surfaces, all unit-tested and none wired, and the clock readout probably wants to be a verdict rather than a yes/no.

The branch is left in place for anyone who picks that up; the plumbing here (LiveState.strapNewestUnix, published where GET_DATA_RANGE is already parsed and cleared with the link) is the prerequisite either way.

Recorded as #2091.

@ryanbr ryanbr closed this Sep 11, 2026
@github-actions
github-actions Bot deleted the fix/android-clock-latched-readout branch September 28, 2026 04:33
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.

1 participant