Skip to content

fix: JRing history asks for today (#73), Connect button actually connects (#72) - #75

Merged
foureight84 merged 7 commits into
mainfrom
fix/issues-72-73
Sep 21, 2026
Merged

foureight84 merged 7 commits into
mainfrom
fix/issues-72-73

Conversation

@foureight84

@foureight84 foureight84 commented Sep 15, 2026 •

Copy link
Copy Markdown
Owner

Closes #73, closes #72.

Two unrelated bugs that share a shape: something the app asks for is silently answered by nobody, and nothing on screen says so.

1365 tests, 0 failures. No schema changes. Not hardware-tested here — #72 is confirmed on hardware by @Albabit, #73 is not yet fixed on hardware (see the update below).


#73 — the JRing history request asked for the wrong day, and asked for every day at once

Part one: byte 1 is a day offset, not a count

@srinikandala's report is the good kind: it names the bytes, cites the vendor app, and comes with a prototype that was tried on hardware. It's right, and the vendor SDK settles it.

Byte 1 of 0x10 is a day offset, not a count of days. IRemoteService.getDataByDay(int type, int day) reaches BluetoothLeService.a(int, int), which writes the type into bArr[0] — 1 → 16 (0x10, activity+sleep), 2 → 22 (0x16, heart rate) — and then the day argument straight into bArr[1]. One day per call; there is no count anywhere in the path. The caller loops: DupMainActivity.onGetMultipleSportData decrements P_SYNC_HISTORY_DAY on each day's sync-end and calls getDataByDay(1, i6) again, counting down to 0.

Read as a count, our 0x10/03 asked for the day before last and 0x10/01 for yesterday. No pass ever requested today — and 0x10 is the only source of sleep, since there's no sleep-only request — so last night's sleep could not arrive however many times the app synced.

Why steps and HR hid it. Neither depends on this request for today: activity arrives on the ring's automatic 0x03 push, and makeHistoryMeasurementQueryCommand already hardcoded 16 00 — offset 0. The one request in the file that asked for today was the one that worked.

Where the wrong reading came from. The KDoc credited Gadgetbridge's triggerActivityReportByDays(). That identifier appears nowhere in decompiled-jring-offical/ — only in our own files. A citation to another project's helper isn't vendor evidence, and the count semantics arrived with it. iOS was never affected: its makeHistoryQueryCommand() is the fixed hex 1000…, i.e. offset 0 — correct for today, and it simply never backfilled.

Part two: the ring answers one history request at a time

That fix alone did not restore sleep. @srinikandala tested v2.9.1+54-rc1 on the SR08 and still saw 45 minutes against an expected 6h30. Their capture says why: 10 00, 10 01, 10 02 and 16 00 all went out inside 176 ms, with 10 02 landing between two of today's sleep packets. Four 0x11 packets arrived in total — three consecutive 15-minute blocks for today (01:15–02:00, exactly the 45 minutes displayed) and one from the previous day. The window's days were truncating each other.

The first fix recorded, in JringSyncEngine, the precise condition that would force the vendor's chain: "if a ring is ever seen truncating a day's stream when the next request lands, the vendor's chain is the fix, and it needs a sync-end signal decoded first." This is that ring, and the capture is that observation.

JringHistorySync is a port of LuckRingHistorySync — the pager already in this repo for streams that carry no end-of-transfer marker — with a second leg per day. Per day: 0x10/offset → that day's activity/sleep stream → 0x16/offset → next day. That is the vendor's own chain (getDataByDay(1, day) then (2, day)).

  • Activity/sleep settles on a timer because nothing else exists. 0x10 and 0x11 are bare runs of 15 one-minute samples with nothing marking a day's end. JYouPro is in the same position and does the same thing — the 2000 ms idle timer @srinikandala documented, which is where the default comes from.
  • Heart rate chains on the protocol. 0x16 subtype 0xFF already decodes to HistorySyncFinished, so that leg advances on the marker and only falls back to the timers.
  • A day that answers nothing still gets its HR request. 16 00 used to be unconditional and it is the request that always worked.
  • start() reports whether it began a pass, so the once-per-connection backfill gate can't be spent by a no-op — runStartup is also the ~30-minute background sync, so a second pass landing mid-backfill is routine.
  • Timers are epoch-guarded and the pass is dropped on disconnect. A stale settle, or one surviving a dropped link, would land a stray 0x10 on a live stream — the same truncation from the inside.

Today still leads the window, where the vendor's countdown leaves it until last. It's the day the user opened the app to see.

#72 — Connect on the Settings card did nothing

@Albabit found this while testing #70: disconnect from Settings, tap Connect on the ring card, and nothing happens at all. No spinner, no error, no attempt.

Both buttons reconnect the same stored ring — through different entry points. userDisconnect() persists a stay-off flag so a manual Disconnect actually sticks against every auto-reconnect path (app foreground, the watchdog, RingSyncWorker, the coach runner), and connectLastKnown() honours it by returning immediately. The detail screen calls userConnect(), which clears the flag first. The hero card called connectLastKnown() — so it was suppressed by the flag in exactly the situation the button exists for.

The guard is right; the caller was wrong. The hero card and the pairing screen's "Reconnect last ring" now call userConnect(). Every genuine auto-reconnect caller is untouched and still honours it.

connectLastKnown() gains a KDoc saying it's the auto path and that a button must not call it, because its name reads exactly like what a Connect button wants.

Correction to an earlier version of this description. It claimed a second silent no-op on the same card — a "Disconnect" button drawn as enabled but wired to nothing. @Albabit pointed out no such button exists, and he's right. DeviceHeroCard has hidden the action row entirely when connected since de60096 (2026-07-06), deliberately: "the connected card is purely informational and opens Wearable settings, where Disconnect lives." Action.DISCONNECT is only produced in the connected state, so that branch was unreachable — the else -> {} was dead code, not a dead button. What this PR actually does there is make the when exhaustive and pass an onDisconnect that nothing can currently invoke: a tripwire for whoever removes the render condition later, not a fix. Disconnect on the Wearable detail screen always worked, and that's where it lives.


Testing and risk

./gradlew testDebugUnitTest — 1365 tests, 0 failures.

The discriminating test for the sequencing fix is the negative one: sleep frames arriving at half a settle window must not be built on top of. Ten more cover per-day ordering, the end-marker advance proven against a one-second settle window, the stall skip, the declined re-entrant start, cancel, the timer race, out-of-leg frames, and the driver wiring at both ends (ingest feeds the pager, disconnect ends it).

Behaviour change worth knowing: the first JRing connection now pulls three days of activity and three days of HR, serialized with settle gaps between them, where RC1 fired everything inside 176 ms. That's the fix working, but the first sync is visibly slower. Later passes are one day — today.

Also new and unvalidated on hardware: per-day 0x16. The SR08 has never been asked for 16 01/16 02. The vendor does it via getDataByDay(2, day), and the stall timeout covers a ring that ignores it.

Hardware status. #72: confirmed by @Albabit on v2.9.1+54-rc1 — Connect works, and Disconnect still sticks across 30+ minutes and an app restart. #73: the day-offset half is in RC1 and demonstrably not enough; the sequencing half needs a new build on @srinikandala's SR08.

🤖 Generated with Claude Code

foureight84 and others added 3 commits September 15, 2026 11:01
An SR08 synced steps and heart rate but never a single night of sleep, however
long it was worn — the ring's own display and JYouPro both showed the night.

Byte 1 of the `0x10` history query is a **day offset**, not a count of days.
`IRemoteService.getDataByDay(int type, int day)` reaches
`BluetoothLeService.a(int, int)`, which writes the type into `bArr[0]` (`1` → 16
= `0x10` activity/sleep, `2` → 22 = `0x16` heart rate) and the `day` argument
straight into `bArr[1]` — one day per call, no count anywhere. Its caller loops:
`DupMainActivity.onGetMultipleSportData` decrements `P_SYNC_HISTORY_DAY` on each
day's sync-end and calls `getDataByDay(1, i6)` again, counting down to 0.

Read as a count, `0x10/03` asked for the day before last and `0x10/01` for
yesterday. **No pass ever requested today**, and `0x10` is the only source of
sleep — there is no sleep-only request — so last night could not arrive at all.
Steps and HR masked it: activity comes from the ring's automatic `0x03` push,
and `makeHistoryMeasurementQueryCommand` already hardcoded `16 00`, i.e. offset
0. The one request that asked for today was the one that worked.

So each day gets its own request: today always, plus the rest of the backfill
window once per connection (the gate matters because `runStartup` is also the
~30-minute background sync). Today leads the window rather than trailing it as
the vendor's countdown does — it is the day the user opened the app to see, and
it is the shape the reporter hardware-validated on an SR08, where requesting
offsets 0, 1 and 2 separately produced 27 `0x11` sleep entries after `0x10/00`.
We stay fire-and-forget rather than reply-driven because nothing here decodes a
per-day sync-end to chain from; AGENTS.md records that divergence and what would
make the vendor's chain necessary.

The count reading came in with a citation to Gadgetbridge's
`triggerActivityReportByDays()` — an identifier that appears nowhere in the
vendor SDK, only in our own files. iOS was never affected: its
`makeHistoryQueryCommand()` is the fixed hex `1000…`, i.e. offset 0, and simply
never backfilled.

Also corrects two comments that cited the count semantics, and drops
`syncWindowDays`' "must match makeHistoryQueryCommand's default" claim — it
sizes the progress bar and nothing else, as AGENTS.md already warned after that
same mistake deferred this fix once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tapping Connect on the Settings hero card did nothing after disconnecting the
ring from Settings — no spinner, no error, no attempt. "Reconnect last ring" on
the ring detail screen worked, which is what made it look like a UI-only fault.

Both buttons reconnect the same stored ring, but through different entry points.
`userDisconnect()` persists a stay-off flag so a manual Disconnect actually
sticks against every auto-reconnect path — app foreground, the watchdog,
`RingSyncWorker`, the coach runner — and `connectLastKnown()` honours it by
returning immediately. The detail screen calls `userConnect()`, which clears the
flag first; the hero card called `connectLastKnown()`, so it was suppressed by
the flag in precisely the situation the button exists for. The guard is right —
the caller was wrong.

The hero card and the pairing screen's "Reconnect last ring" now call
`userConnect()`: a user tapping Connect *is* the intent the flag was waiting for.
Every genuine auto-reconnect caller is untouched and still honours it.

`connectLastKnown()` gains a KDoc saying it is the auto path and that a button
must not call it, since its name reads exactly like what a Connect button wants.

Also fixes the same silent no-op one action along: the card rendered "Disconnect"
as an enabled button whose `when` branch fell into `else -> {}`. It is wired to
`userDisconnect()`, and the `when` is now exhaustive over the action enum, so a
future action can't be added without handling it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`docs/crp-r11-hardening-plan.md` and `docs/health-connect-integration.md` both
said not to add a `Co-Authored-By` trailer, and 13 commits carry one anyway —
because those are task-specific plan documents that nothing loads automatically.
The rule was never in AGENTS.md, which is.

GitHub reads the trailer as a second author and lists the address as a
repository contributor, so an agent shows up in the contributors graph next to
the maintainer. That is what prompted this.

Existing commits are deliberately left alone: rewriting them would orphan the
v2.8.0+51, v2.9.0+52-rc1, v2.9.0+53 and v2.9.1+54-rc1 tags and the published
APKs hanging off them, to fix something cosmetic.
@srinikandala

Copy link
Copy Markdown

RC1 hardware test follow-up for #73: v2.9.1+54-rc1 still shows only 45 minutes instead of the expected 6h30 on the SR08. The unredacted capture contains three 15-minute packets for today and one for yesterday; all three day requests plus HR are sent within 176 ms, with requests overlapping incoming sleep packets.

Full decoded timeline and the working prototype's sequencing are documented here: #73 (comment)

The working prototype validated explicit offsets with waiting between transfers, not the queued-together sequence in this PR. Please address the sequencing before treating #73 as resolved. The capture supports incomplete retrieval, although it does not isolate which subsequent command interrupts the ring.

@Albabit

Albabit commented Sep 17, 2026

Copy link
Copy Markdown

On the YCBT side the transfer itself looks stable, for whatever that's worth as a contrast.

I read my ring's history twice back to back with my own Python client, no app involved, and got byte-identical results:
11 frames, 1916 bytes, same sessions both times.
So on this family I'm not seeing the truncation-under-pressure your capture shows on the SR08.

Not disputing your finding, the two rings clearly behave differently.
Mentioning it only so the YCBT side isn't assumed to share the same retrieval problem.

…l the stream settles (#73)

The day-offset fix was necessary and not sufficient. On v2.9.1+54-rc1 the same SR08 still
showed 45 minutes of sleep against an expected 6h30, and the reporter's capture says why:
`10 00`, `10 01`, `10 02` and `16 00` all went out inside 176 ms, with `10 02` landing
between two of today's sleep packets. Four `0x11` packets arrived in all — three
consecutive 15-minute blocks for today (01:15–02:00, exactly the 45 minutes displayed)
and one from the previous day. The ring answers one history request at a time, so the
window's days were truncating each other.

`JringSyncEngine` already recorded the condition that would force this change: "if a ring
is ever seen truncating a day's stream when the next request lands, the vendor's chain is
the fix, and it needs a sync-end signal decoded first." This is that ring.

JringHistorySync is a port of LuckRingHistorySync — the in-repo pager for streams that
carry no end-of-transfer marker — with a second leg per day. Per day: `0x10/offset`, then
that day's activity/sleep stream, then `0x16/offset`, then the next day, which is the
vendor's own chain (`getDataByDay(1, day)` then `(2, day)`).

The activity/sleep leg settles on a timer because nothing else is available: `0x10` and
`0x11` are bare runs of 15 one-minute samples with nothing marking a day's end. JYouPro is
in the same position and resets a 2000 ms idle timer on each reply, which is where the
default comes from. Heart rate does have a real end marker — `0x16` subtype `0xFF`, already
decoded as HistorySyncFinished — so that leg chains on the protocol and only falls back to
the timers.

Details that are load-bearing rather than incidental:

- `start()` returns whether it began a pass, and `historyBackfilled` is spent only when one
  did. runStartup is also the ~30-minute background sync, so a pass landing mid-backfill is
  routine; had the declined no-op consumed the gate, a connection could lose its backfill
  entirely and never ask for the older days again.
- A day that answers nothing still gets its HR request. `16 00` used to be unconditional
  and it is the request that always worked; a silent `0x10` must not take it down.
- Timers are epoch-guarded. Job.cancel() does not retract a coroutine already past its
  delay and waiting on the monitor, so a stale settle firing into a live stream would
  re-create the truncation from the inside.
- connectionDidEnd drops an in-flight pass. The writer outlives the driver, so a surviving
  timer would enqueue the next day's `0x10` into whatever connection comes next.

makeHistoryMeasurementQueryCommand takes a day offset now, matching getDataByDay(2, day);
it was a fixed `16 00`, which is why heart rate kept working while sleep did not.

The discriminating test is the negative one — sleep frames arriving at half a settle window
must not be built on top of. That is what the reporter asked for by name. Ten more cover
per-day ordering, the end-marker advance proven against a one-second settle window, the
stall skip, the declined re-entrant start, cancel, the timer race, out-of-leg frames, and
the driver wiring on both ends (ingest feeds the pager, disconnect ends it).

1365 tests, 0 failures. Not hardware-tested here; #73's reporter has the SR08.
Issue #74's "Next" section asked the reporter for "the timestamps of the heart-rate rows
stored around that minute, and whether they carry the spot flag". DiagnosticsExporter emits
app info, device info, logs, raw packets, crashes and logcat — no database rows — so that
question could not be answered from an export, and the ask went out twice before anyone
checked the format.

The reporter supplied the reason it should stay that way, and it is better than "the format
doesn't happen to have it": a diagnostics file people paste into public issues is not where
health readings should end up. maskPacketHex already enforces that one layer down, masking
health payloads out of the raw frames, so a measurements section would undo in clear exactly
what the masking exists to prevent.

Recorded next to the masking rules, because the next person to want stored-row detail will
be reading that section.
RingPacket.fromData rejects anything that isn't exactly PACKET_SIZE, and the
decoder with it — so noteFrame must apply the same contract, or a fragment
could hold a day's stream open on data the app will never read.
…ad button

The comment claimed the card rendered Disconnect as an enabled button wired
to nothing — the claim the PR description already corrected: the branch is
unreachable (the row only renders when action != DISCONNECT, and DISCONNECT
is only ever produced for the connected state). A wrong comment here is how
the #73 fix got deferred once already; make it match the corrected account.
@foureight84
foureight84 merged commit 35427a9 into main Sep 21, 2026
1 check passed
@foureight84
foureight84 deleted the fix/issues-72-73 branch September 21, 2026 21:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants