Skip to content
116 changes: 106 additions & 10 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,22 @@
Read this before touching ring/BLE hardware code (`app/src/main/java/com/pulseloop/ring/`,
`app/src/main/java/com/pulseloop/wearables/`). Full detail: `docs/qring-ble-adoption.md`.

## Commits carry no agent attribution

**Do not add a `Co-Authored-By` trailer to commits in this repo** — not for Claude, not for any
other agent or tool. GitHub reads that trailer as a second author and lists the address as a
repository *contributor*, so an agent appears in the contributors graph beside the maintainer.

This was already the rule, stated in `android/docs/crp-r11-hardening-plan.md` §"Ground rules" and
`android/docs/health-connect-integration.md` §4, and it was still violated on 13 commits — because
those are task-specific plan documents that nothing loads automatically, and the rule was nowhere
an agent reliably reads. It lives here now for that reason. The Claude Code harness injects a
session reminder *asking* for the trailer; that reminder defers to the project's own instructions,
and this is one.

Applies to both repos. Leave the existing commits alone: rewriting them would orphan the release
tags and the published APKs hanging off them.

## Ring BLE protocol work — match the vendor app, not iOS

When porting or fixing a ring's BLE protocol (connect/pairing sequence, GATT characteristic
Expand Down Expand Up @@ -98,16 +114,16 @@ simply produces no reply. `CRPSyncEngine.sendSleepBackfill` therefore pulls the
per connection** (not per pass — `runStartup` is also the ~30-minute background sync, and this ring
funnels everything through one `fdd2` channel).

jring has the same gap, for different reasons. Its depth is `makeHistoryQueryCommand()`'s default of
1, called with no argument at `JringDriver.kt:105`, against a command that accepts up to 27.
**`RingSyncCoordinator.syncWindowDays` is not that control** — despite its "must match
makeHistoryQueryCommand's default" comment, it has exactly one use, sizing the sync-progress window
in `beginSyncProgress`, and it applies to every family. Don't cite it as a per-family request depth;
that mistake is what deferred this fix once already. Two things do make jring harder than CRP:
`JringSyncEngine.runStartup` has no once-per-connection gate, so a wider `days` re-pulls the whole
span on every ~30-minute background pass rather than once; and `0x10` returns activity *and* sleep
together — there is no sleep-only request — so each extra day costs ~96 activity packets
(15× 1-minute buckets per packet) on top of the night.
jring had the same gap and a worse one underneath it (see "#73" below: byte 1 of `0x10` is a day
*offset*, so the app never asked for today at all). One day per request, so depth is a loop, not a
number. **`RingSyncCoordinator.syncWindowDays` is not that control** — despite the "must match
makeHistoryQueryCommand's default" comment it used to carry, it has exactly one use, sizing the
sync-progress window in `beginSyncProgress`, and it applies to every family. Don't cite it as a
per-family request depth; that mistake is what deferred this fix once already. Two things still make
jring harder than CRP: `runStartup` is also the ~30-minute background sync, so a wider window must be
gated to once per connection (`historyBackfilled`) or it re-pulls the whole span every half hour;
and `0x10` returns activity *and* sleep together — there is no sleep-only request — so each extra
day costs ~96 activity packets (15× 1-minute buckets per packet) on top of the night.

Consequence to keep in mind: nothing bulk-deletes real sleep any more, so a Forget followed by
pairing a different ring carries the previous ring's history over. If that ever needs to change,
Expand Down Expand Up @@ -179,6 +195,74 @@ Corollary for new protocol work: a reply that merely reports something about the
serial, capabilities) is not a connection event. Give it its own `RingDecodedEvent` — as
`FirmwareRevision` does — rather than hanging it off `Status`.

## `0x10`'s byte 1 is a day offset, not a day count (issue #73)

**An SR08 synced steps and HR but never a single night of sleep**, however long it was worn. The
request depth looked fine; the day being requested was not. `getDataByDay(int type, int day)`
(`IRemoteService.aidl`) reaches `BluetoothLeService.a(int, int)`, which writes the type into
`bArr[0]` — `1` → `16` (`0x10`, activity+sleep), `2` → `22` (`0x16`, heart rate), via the
`WeatherUtil` constants jadx happens to resolve those bytes to — and then the `day` argument
**straight into `bArr[1]`**. One day per call. 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, so **no
pass ever requested today** — and `0x10` is the only source of sleep, so last night could not
arrive. 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, which is the clue that was sitting in the source the whole
time.

Two process notes worth keeping:

- **The count reading came from a name that isn't in the vendor SDK.** The KDoc cited Gadgetbridge's
`triggerActivityReportByDays()`; that identifier appears nowhere in `decompiled-jring-offical/`,
only in our own files. A citation to a *different* project's helper is not vendor evidence — check
the decompile, which for this family means `jadx --single-class` against `classes2.dex`, since
only the `.aidl` files ship as sources.
- **Asking for the right day was only half of it — see below.** The first fix enqueued the whole
window in one pass and recorded the condition that would force the vendor's chain. That condition
arrived within a day.

## This ring answers one history request at a time (issue #73, second half)

**The day-offset fix alone did not restore sleep.** On `v2.9.1+54-rc1` the same SR08 still showed
**45 minutes 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 total — three consecutive 15-minute blocks for today
(01:15–02:00, exactly the 45 minutes displayed) and one from the previous day. A second request
mid-stream truncates the first, so the window's days were cutting each other off.

`JringHistorySync` is the fix: one day at a time, the next request held until the current day's
stream goes quiet. Per day, `0x10/offset` → settle → `0x16/offset` → next day, which is the vendor's
own chain (`getDataByDay(1, day)` then `(2, day)`, advancing on that day's sync-end).

- **The activity/sleep leg is time-settled, not reply-driven, and that is forced.** Nothing in the
`0x10`/`0x11` wire format marks the end of a day — both are bare runs of 15 one-minute samples.
JYouPro is in the same position and does the same thing: a **2000 ms** idle timer reset on each
reply, which is where `settleMs`' default comes from. The **heart-rate** leg does have a real end
marker (`0x16` subtype `0xFF` → `HistorySyncFinished`) and chains on it.
- **`LuckRingHistorySync` is the same machine for the same reason** — port from it rather than
inventing a third one. The jring version adds the second (HR) leg per day.
- **A re-entrant `start()` is declined, and the caller is told.** `runStartup` is also the
~30-minute background sync, so a pass landing mid-backfill is normal; restarting would abandon
the in-flight day mid-stream, which is the truncation itself. `start` returns whether it began a
pass so `historyBackfilled` is only spent by a pass that ran — otherwise a connection can lose
its backfill to a no-op 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 today's HR down with it.
- **The pass is dropped on disconnect** (`JringDriver.connectionDidEnd`). The writer outlives the
driver, so a timer firing after the link dropped would land a stray `0x10` in the *next*
connection — the same truncation, arriving from a connection that has already ended.
- **Timers are epoch-guarded** (`JringHistorySync.armTimer`). `Job.cancel()` does not retract a
coroutine already past its `delay` and waiting on the monitor, and a stale settle firing into a
live stream would re-create the bug from the inside.
- The one remaining divergence from the vendor is order: it counts **down** to today, we lead with
today — the day the user opened the app to see, and the one the reporter's prototype validated.
- **Don't generalise this to other families.** `YCBTHistoryTransfer` has a terminal block with a
CRC and a YCBT owner read the same history twice back to back byte-identically (11 frames,
1916 bytes) while this SR08 was truncating. Different transports, different failure modes.

## Colmi R11 (CRP "Da Rings") — diagnose from the capture, and decode wear state before blaming code

**Read this before changing anything in `CRP*` startup, sync, all-day-monitoring, or history code —
Expand Down Expand Up @@ -699,6 +783,18 @@ values intact**, because an undecoded frame fell through to `command_ack`, which
`HEALTH_KINDS`. A decode gap silently became a privacy gap. When you add a decoder for a frame that
carries physiological values, check that its `decodedKind` is one the redactor masks.

**The export carries no database rows, and that is deliberate — don't add them, and don't ask a
reporter for stored-row detail through it.** `DiagnosticsExporter` emits app info, device info,
logs, raw packets, crashes and logcat, and nothing else. Issue #74's "Next" section asked a reporter
for "the timestamps of the heart-rate rows stored around that minute, and whether they carry the
spot flag" — a question the format cannot answer, so the ask was made twice and answered neither
time. The reporter (@Albabit, #74) gave the reason the section should stay absent: **a diagnostics
file people paste into public issues is not where health readings should end up.** That is the same
principle `maskPacketHex` already enforces one layer down, so a measurements section would undo
deliberately, in clear, exactly what the masking exists to prevent. When you need to know what the
app *stored*, ask what the user sees on screen — for "is the user shown a bad reading?" that is also
the better evidence, since it answers the question the issue is actually about.

**The header length must come from the packet's own family, and a half-assembled frame has no header
at all.** Two further shapes of the same failure, fixed together:

Expand Down
100 changes: 71 additions & 29 deletions app/src/main/java/com/pulseloop/ring/JringDriver.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package com.pulseloop.ring

/** Days of history pulled on the first pass of a jring connection; every later pass asks for one.
* See [JringSyncEngine.historyDaysForThisPass] for why this is shorter than CRP's week. */
/** Days of history pulled on the first pass of a jring connection; every later pass asks only for
* today. Each day is its own `0x10` request — byte 1 is a day offset, not a count (issue #73).
* See [JringSyncEngine.historyDayOffsetsForThisPass] for why this is shorter than CRP's week. */
private const val JRING_BACKFILL_DAYS = 3

@OptIn(ExperimentalStdlibApi::class)
Expand Down Expand Up @@ -59,7 +60,15 @@ object JringCoordinator : WearableCoordinator {
* Ported from [JringDriver] in JringDriver.swift.
* Thin wrapper over RingDecoder/RingEncoder for jring devices.
*/
class JringDriver(private val writer: RingCommandWriter) : WearableDriver {
class JringDriver(
private val writer: RingCommandWriter,
/** The history pager. Owned here because only the driver sees whole frames ([ingest]); handed
* to the engine so `runStartup` can seed the day window. A fresh driver is built per
* connection (`RingBLEClient.installDriver` calls `coordinator.makeDriver` on every connect),
* so nothing here needs a reconnect *reset* — but its timers do need [connectionDidEnd].
* Injectable so a test can run a whole pass on millisecond timers. */
private val historySync: JringHistorySync = JringHistorySync(writer),
) : WearableDriver {
/** One clock per connection, shared by the decoder and the sync engine: the engine latches
* the UTC offset when it sends 0x01, and the decoder subtracts that same offset off every
* ring-stamped history timestamp. See [JringClock]. */
Expand All @@ -74,19 +83,42 @@ class JringDriver(private val writer: RingCommandWriter) : WearableDriver {

override fun frame(command: ByteArray) = command // jring: already 20 bytes, no checksum

override fun ingest(data: ByteArray, from: String): List<RingDecodedEvent> =
decoder.decode(data)
override fun ingest(data: ByteArray, from: String): List<RingDecodedEvent> {
// The pager settles on whole frames, so it is fed here rather than from the engine's
// decoded-event stream: one 0x10 frame fans out into 15 ActivityBucket events, which says
// nothing about how many frames are still coming.
historySync.noteFrame(data)
return decoder.decode(data)
}

override fun makeSyncEngine(): RingSyncEngine = JringSyncEngine(writer, clock, historySync)

override fun makeSyncEngine(): RingSyncEngine = JringSyncEngine(writer, clock)
/**
* Drop any in-flight history pass on disconnect.
*
* A fresh driver is built per connection, so no *state* needs resetting here — but the pager
* holds live timers, and [RingCommandWriter] outlives this driver. A settle firing after the
* link dropped would enqueue the next day's `0x10` into whatever connection comes next,
* landing a stray history request on top of that connection's own stream: the truncation of
* issue #73, arriving from a connection that has already ended.
*/
override fun connectionDidEnd() {
historySync.cancel()
}
}

/**
* Ported from [JringSyncEngine] in JringSyncEngine.swift.
* Fire-and-forget sync engine for jring devices.
* Fire-and-forget sync engine for jring devices — with one exception: history goes through
* [JringHistorySync], because this ring answers one history request at a time (issue #73).
*/
class JringSyncEngine(
private val writer: RingCommandWriter?,
private val clock: JringClock = JringClock(),
/** Owned by [JringDriver] in production so it can be fed whole frames; defaulted here so a
* caller holding only a writer (tests, and any future engine-only path) still gets a pager
* rather than an unpaced burst. */
private val historySync: JringHistorySync = JringHistorySync(writer),
) : RingSyncEngine {
override val supportsCombinedMeasurement: Boolean = true
private val encoder = RingEncoder
Expand All @@ -106,8 +138,11 @@ class JringSyncEngine(
// had to initialise with the vendor app first.
writer?.enqueue(encoder.makeAutomaticHeartRateCommand(enabled = true, cadenceMinutes = 30))
writer?.enqueue(encoder.makeBandFunctionCommand())
writer?.enqueue(encoder.makeHistoryQueryCommand(days = historyDaysForThisPass()))
writer?.enqueue(encoder.makeHistoryMeasurementQueryCommand())
// History is paged rather than enqueued: [JringHistorySync] sends one day's 0x10, holds
// the rest until that day's stream goes quiet, and asks for each day's 0x16 HR itself.
// The gate is only spent if a pass actually began — `start` declines while one is in
// flight, and a declined backfill must still be owed.
if (historySync.start(historyDayOffsetsForThisPass())) historyBackfilled = true
}

/** Whether this connection has already pulled the deep history window. A fresh engine is built
Expand All @@ -116,30 +151,37 @@ class JringSyncEngine(
private var historyBackfilled = false

/**
* How many days of history to ask for on this pass: the deep window once per connection, one
* day on every pass after it.
* Which days to ask for on this pass: **today always**, plus the older days of the backfill
* window once per connection.
*
* The ring holds days the app has never asked for. Before issue #43 that didn't matter, because
* connecting deleted the stored copy anyway; now that it doesn't, a single-day request means a
* user's history can only ever grow one night at a time from install, and never recovers what
* the ring already has. `0x10` takes a day count (`triggerActivityReportByDays`, capped at 27)
* and the ring replies with the days it actually has, so asking for more is safe.
* `0x10`'s byte 1 is a day offset rather than a count (issue #73 — see
* [RingEncoder.makeHistoryQueryCommand] for the vendor evidence), so each day needs its own
* request. Today (`0`) leads every pass because it is the day the user is looking at, and
* because under the old count reading it was the one day never requested at all: a single
* `0x10/01` asked for yesterday, so last night's sleep never arrived.
*
* **Why the gate matters more here than on CRP.** [runStartup] is also the ~30-minute background
* sync (and `refresh()`/`querySleep()` route through it), so an unconditional wider window would
* re-pull the whole span every half hour forever. And `0x10` returns activity *and* sleep — there
* is no sleep-only request — so each extra day is roughly 96 more packets (activity arrives as
* 15× 1-minute buckets each), against the nights we actually came for. That volume, not the
* nights, is why this window is deliberately shorter than the CRP backfill's week.
* **Why the once-per-connection gate matters more here than on CRP.** [runStartup] is also the
* ~30-minute background sync (and `refresh()`/`querySleep()` route through it), so an
* unconditional wider window would re-pull the whole span every half hour forever. And `0x10`
* returns activity *and* sleep — there is no sleep-only request — so each extra day is roughly
* 96 more packets (activity arrives as 15× 1-minute buckets each), against the nights we
* actually came for. That volume, not the nights, is why this window is deliberately shorter
* than the CRP backfill's week.
*
* Re-syncing the same days is harmless: activity buckets upsert by timestamp with the day total
* recomputed from distinct buckets, and sleep reconciles one waking day at a time.
* **This is the window, not the schedule.** Which days to ask for is decided here; *when* each
* request goes out is [JringHistorySync]'s, one day at a time. The two were briefly the same
* thing and that was the second half of #73: enqueued together, the window's requests
* truncated each other on an SR08.
*
* **One divergence from the vendor remains, deliberate:** it counts *down* to today
* (`DupMainActivity.onGetMultipleSportData`), we lead with today. It is the day the user opened
* the app to see, so it is the day that should land first and the one that survives if a pass
* is cut short. Re-syncing the same days is harmless anyway: activity buckets upsert by
* timestamp with the day total recomputed from distinct buckets, and sleep reconciles one
* waking day at a time.
*/
private fun historyDaysForThisPass(): Int {
if (historyBackfilled) return 1
historyBackfilled = true
return JRING_BACKFILL_DAYS
}
private fun historyDayOffsetsForThisPass(): List<Int> =
if (historyBackfilled) listOf(0) else (0 until JRING_BACKFILL_DAYS).toList()

override fun handle(event: RingDecodedEvent) {
when (event) {
Expand Down
Loading
Loading