Skip to content

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

Open
foureight84 wants to merge 3 commits into
mainfrom
fix/issues-72-73
Open

foureight84 wants to merge 3 commits into
mainfrom
fix/issues-72-73

Conversation

@foureight84

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.

1353 tests, 0 failures. No schema changes. Neither is hardware-tested here — #73 has the reporter's own prototype validation on an SR08, #72 is verifiable on any ring in about ten seconds.


#73 — the JRing history request asked for the wrong day

@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, which is the clue that was sitting in the source the whole time.

The fix. Each day gets its own request: today always, plus the rest of the backfill window once per connection (that gate matters — runStartup is also the ~30-minute background sync, and each extra day is ~96 activity packets on top of the night).

Today leads the window, where the vendor's countdown leaves it until last. It's the day the user opened the app to see, and it's the shape the reporter validated: requesting offsets 0, 1 and 2 separately produced 27 0x11 sleep entries after 0x10/00, and sleep appeared. We also stay fire-and-forget rather than reply-driven, because nothing here decodes a per-day sync-end to chain from. Both divergences are deliberate and recorded in AGENTS.md along with what would make the vendor's chain necessary — a ring seen truncating a day's stream when the next request lands.

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.

The reporter's timing observation (JYouPro waits for the 0x10/0x11 stream to go quiet before requesting HR; we sent HR ~30 ms later) is not changed here, and their own timing-only prototype didn't fix sleep — so it isn't the bug. Worth revisiting only if a capture shows a truncated stream.

#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. "Reconnect last ring" on the detail screen works, which made it look like a UI-only fault.

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() — 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's the auto path and that a button must not call it, because its name reads exactly like what a Connect button wants. I considered renaming it, but its six callers are background workers and the coach runner — files with no business in this diff.

One more silent no-op, one action along. The same card rendered "Disconnect" as an enabled button whose when branch fell into else -> {}. It's wired to userDisconnect() now, and the when is exhaustive over the action enum so a future action can't be added without handling it. (This is presumably why the reporter disconnected from the detail screen rather than the card.)


Testing and risk

./gradlew testDebugUnitTest1353 tests, 0 failures. The JRing history tests were rewritten around offsets: that every pass asks for today, that the deep window is once per connection and today-only after it, that today leads, and that offsets survive the 0..27 coerce. The discriminating one is "always asks for today" — it's the assertion that would have caught this.

Behaviour change worth knowing: the first pass of a JRing connection now sends three 0x10 requests instead of one, so first-connection sync pulls roughly three days of activity where it previously pulled one (badly). Later passes are unchanged in volume — one request, now for the right day.

Not hardware-tested here. #73's shape is the reporter's validated prototype but this is our implementation of it; #72 needs a ring, a Disconnect and one tap.

🤖 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants