From 6938ce326dedeb42b8b3ef83152688c97ffda156 Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Sat, 22 Aug 2026 10:05:07 -0700 Subject: [PATCH 01/22] docs(ios-sync): triage iOS PR #93 (Colmi R11 CRP driver) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS main moved from 88c0f6b to 439ca81 — 12 commits, one first-parent item. PR #93 is the iOS port of this repo's own CRP work, so the driver, the "Colmi R11 (Da Rings app)" pairing card and the not-worn measurement hint are all ALREADY-HAVE here. What Android lacks is the hardening iOS added afterwards in 4d65b60, an adversarial review of that branch. Five of its eight findings apply: 1. CRPDriver never overrides requiredSubscriptionsBeforeConnected, so CONNECTED fires on the fdd1 (steps) CCCD write rather than fdd3, which carries every command reply — runStartup can write its whole handshake into a channel nobody is listening to. 2. queryFirmwareVersion() runs on every poll pass, outside the readBacksSent gate that covers the six read-backs beside it. 3. requestedTimingFrames is keyed cmd*100+frameIndex with no day, so a vitals backfill would swallow day 1's frame-1 follow-up. 4. decodeFirmwareVersion coerces UTF-8 instead of validating it, so a binary payload renders as U+FFFD and presents as a firmware version. 5. Its trim is the vendor's wide `<= ' '`, which strips binary junk and passes whatever follows. Three deliberately do NOT port, recorded so nobody re-ports them: the frame-assembler reset (Android connects with autoConnect = false and every reconnect funnels through beginConnect -> installDriver, so the assembler is already fresh per link), the half-open backfill loop (Kotlin's 1..0 is empty, not a trap), and the "once per connection" comment corrections (Android's comments are already accurate). Sync state advanced to 439ca81; resume pointer gains the CRP item. --- docs/ios-sync.md | 79 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 75 insertions(+), 4 deletions(-) diff --git a/docs/ios-sync.md b/docs/ios-sync.md index c1479efd..c01c1f3c 100644 --- a/docs/ios-sync.md +++ b/docs/ios-sync.md @@ -24,10 +24,10 @@ intentional platform differences listed at the bottom. |---|---| | **Canonical iOS repo** | `github.com/saksham2001/PulseLoopiOS` (always `main`) | | **Fork baseline (iOS)** | `600c7a8` — Merge PR #6, 2026-06-20 | -| **Last triaged iOS commit** | `88c0f6b` — Merge PR #131 (sleep hypnogram alignment + scrubber), 2026-08-08 | -| **Last triage date** | 2026-08-08 | +| **Last triaged iOS commit** | `439ca81` — Merge PR #93 (Colmi R11 CRP driver), 2026-08-09 | +| **Last triage date** | 2026-08-22 | | **Last port date** | 2026-08-08 — PR #45 (ios_sync_2026-08-08, 5 plan commits + 2 CR remediation commits = 7 total) | -| **Range covered** | 11 first-parent items since `0d1b965` (2026-07-18): PRs #73, #94–#100, #130, #131 + 1 direct commit (`160c775`) → **10 ported, #130 backed out** | +| **Range covered** | 12 first-parent items since `0d1b965` (2026-07-18): PRs #73, #94–#100, #130, #131, #93 + 1 direct commit (`160c775`) → **10 ported, #130 backed out, #93 open (5 hardening gaps)** | --- @@ -114,6 +114,7 @@ seeded-data mode). **SKIP** — no portable behavior, Android has its own indepe | ☑ | [#130](https://github.com/saksham2001/PulseLoopiOS/pull/130) `cf5c0f4` | ~08-04 | RWfit ring family (dual 0x7E/0xAB protocol, full metric set, service-UUID recognition) | **ADAPT** | L–XL | Backed out of PR #45, then **rebuilt from `decompiled-rwfit-official/`** on `feat/rwfit-vendor-rebuild`. Legacy `0x7E` path complete; JieLi `0xAB` framing complete but its history bodies are not decoded yet. **No hardware validation.** See below. | | ☑ | [#131](https://github.com/saksham2001/PulseLoopiOS/pull/131) `88c0f6b` | ~08-08 | Sleep hypnogram label alignment + press-and-hold stage scrubber (+ sync spinner rewrite, iOS-only) | **ADAPT** | S–M | `802789d` | | ☑ | [#80](https://github.com/saksham2001/PulseLoopiOS/pull/80) `c1275ad` | 07-11 | **Apple Health sync → Health Connect** (per-type toggles, vitals/sleep/activity/workout export, backfill choice, remove-all). Re-triaged 2026-08-09 from SKIP: the *behaviour* ports even though HealthKit doesn't. Write-only; profile import can't port (Health Connect has no DOB/sex type). Design + 7-phase plan in [`health-connect-integration.md`](health-connect-integration.md); reference implementation is `Gadgetbridge/` at the parent repo root, not iOS. Not blocked by the Play Store — the declaration form is a publishing gate, and Gadgetbridge ships this sideload-only. | **ADAPT** | XL | **Phases 0–6 complete** on `feat/health-connect-foundation` (write-only, 16 `WRITE_*` / 0 `READ_*`; lifecycle, removal, grant/revocation resets, archive-restore stamp, docs). Runtime-verified API 35. See `health-connect-integration.md` §8 | +| ☐ | [#93](https://github.com/saksham2001/PulseLoopiOS/pull/93) `439ca81` | 08-09 | **Colmi R11 CRP driver** — the iOS port *of Android's own* CRP work, so the driver itself is ALREADY-HAVE. What does not exist on Android is the **adversarial-review hardening** iOS added on top in `4d65b60`: 5 real gaps, listed in the 2026-08-22 triage note below. | **PARTIAL** (hardening only) | S–M | ☐ open — see "2026-08-22 triage" | ## Port priority — open items (as of 2026-08-08) @@ -131,7 +132,11 @@ seeded-data mode). **SKIP** — no portable behavior, Android has its own indepe > `decompiled-rwfit-official/` (see the backed-out section below for what was wrong), > then recombined. > -> Next triage after those: `git -C log --first-parent --oneline 88c0f6b..main`. +> 3. **#93 CRP hardening** (queued 2026-08-22) — five small, independent fixes to the existing +> Android CRP driver, listed in "2026-08-22 triage" below. Item (1), gating `CONNECTED` on the +> `fdd3` reply channel, is worth doing on its own even if the rest waits. +> +> Next triage after those: `git -C log --first-parent --oneline 439ca81..main`. > > **Newly queued, independent of the two above:** **#80 → Health Connect** (re-triaged > 2026-08-09 from SKIP to ADAPT/XL). Design and a 7-phase implementation plan are written up in @@ -439,6 +444,72 @@ their own M-sized item and drop to Tier 2/3; only #61d/#61e are Tier-1-sized. - **#79 Activity Year-trends** (S) — blocked: no Activity-trends screen on Android yet (not created by #57's redesign either). - ~~**#74 Measurement-Frequency relocation**~~ ✅ **DONE** `368a3f2` (2026-07-19) — see the session note below. +### 2026-08-22 triage (since `88c0f6b` → `439ca81`, 12 commits / 1 first-parent) + +Exactly **one** untriaged first-parent item: **PR #93, the Colmi R11 CRP driver** (25 files, +2876 ins). It is not a normal upstream item — it is the *iOS port of this repo's own Android CRP +work* (`5427dc0 fix(crp): port the R11 opcode corrections and read-backs from Android`), so the +driver, the pairing card and the wear-state UX are all ALREADY-HAVE here. Verified present on +Android before writing this: `CRPDriver/CRPDecoder/CRPProtocol/CRPSyncEngine/CRPCoordinator`, +`WearableModel.colmiR11CRP` ("Colmi R11 (Da Rings app)", `forcedFamilyScanMatches`), and the +not-worn measurement hint (`RingSyncCoordinator.measureNotWorn` → `Screens.kt:320`). + +**What Android does NOT have** is the hardening iOS added afterwards in `4d65b60` ("reset the +frame assembler across reconnects, gate connect on fdd3"), an adversarial review of that branch. +Five of its eight findings apply here; three do not, for reasons worth recording so nobody +re-ports them. + +**Port these five (☐ open):** + +1. **`CONNECTED` fires before the reply channel is live.** `CRPDriver` doesn't override + `requiredSubscriptionsBeforeConnected` (only `YCBTDriver` does), so the connection counts as up + on the first successful CCCD write — which is `fdd1` (steps), never `fdd3`, which carries every + command reply. `RingBLEClient.kt:903` already threads the driver's list through, so this is a + one-property override. Consequence today: `runStartup` writes its whole handshake (~26 frames) + into a channel we may not be listening to yet, and a lost reply is indistinguishable from a slow + one — the exact signature of the opcode bug this driver just fixed. **Highest value of the five.** +2. **Firmware is re-queried on every poll pass.** `CRPSyncEngine.runStartup` sends + `queryFirmwareVersion()` outside the `readBacksSent` gate, while the six read-backs beside it are + gated. A firmware string is exactly as immutable as a sensor roster, and this ring funnels the + handshake, timing config, history pull *and* on-demand measures through the single `fdd2` + channel (a spot SpO2 alone needs ~48 s of it). Fold it into `sendConnectionReadBacks()`. +3. **The timing-history follow-up guard ignores the day.** `requestedTimingFrames` is keyed + `cmd * 100 + frameIndex`. Every timing query is day 0 today, but this engine already issues + multi-day sleep requests (`SLEEP_BACKFILL_DAYS = 6`), so the moment vitals get the same backfill + the key silently swallows day 1's frame-1 follow-up. Key on `day` as well as `cmd`. +4. **The firmware string is coerced, not validated.** `CRPDecoder.decodeFirmwareVersion` does + `String(payload, Charsets.UTF_8)`, which substitutes U+FFFD rather than failing — a binary + payload renders as replacement characters and is presented as a firmware version. Do strict + UTF-8, then trim padding, then reject any remaining control byte to an ack. +5. **The trim is the vendor's, and it is too wide.** The same function uses `trim { it <= ' ' }`, + which strips a binary payload's leading junk and passes whatever follows — `01 02 03 41` → `"A"`. + iOS deliberately narrowed this. Ports together with (4). + +**Do NOT port these three — they don't apply to Android:** + +- **Frame-assembler reset across reconnects.** This was iOS's headline bug: auto-reconnect there + re-dialled with a bare `central.connect` and kept the driver instance, so a half-assembled frame + from the dropped link was completed with bytes from the new one and decoded as a genuine (but + fabricated) vital sample. **Android is already safe by construction** — it connects with + `autoConnect = false` (`RingBLEClient.kt:804`, matching the official QRing app) and *every* + reconnect path funnels through `beginConnect`, which calls `installDriver` (`:787`) and builds a + fresh `CRPDriver` with a fresh `CRPFrameAssembler`. Adding a `reset()` hook here would be dead + code. `CRPDriver`'s KDoc already states this invariant correctly; keep it accurate if the + reconnect path is ever changed to reuse a driver, because that is what would reintroduce the bug. +- **Half-open sleep-backfill loop.** iOS used `1...0`, which traps if the tuning knob is turned + down to today-only. Kotlin's `1..0` is simply an empty range — `for (daysAgo in + 1..SLEEP_BACKFILL_DAYS)` is already safe at `SLEEP_BACKFILL_DAYS = 0`. +- **"Once per connection" comment corrections.** Android's comments already say the right thing + (`readBacksSent` is documented as per-engine-instance, and a fresh engine is built per connect). + +**Effort:** S–M in total; (1) is a one-line override, (2)–(3) are small engine edits, (4)–(5) are +one decoder function plus tests. No schema change, no UI. Existing `CRPDecoderTest` / +`CRPSyncEngineTest` / `CRPProtocolTest` are the natural homes for the oracles — iOS added 63 lines +to `CRPDecoderTests.swift` and 33 to `CRPSyncEngineTests.swift` in the same commit, so port those. + +**Also noted:** the iOS-side R11 branch (`feat/colmi-r11-crp-driver`, `4d65b60`) is fully merged +into iOS `main`; nothing is outstanding on that branch. + ### 2026-07-20 PR #28 review + fix pass (branch `iOS_sync_2026-07-16`) Full code review of the sync batch (two passes, 10 parallel agents total, cross-referenced From f38d0a33baa17b74058c83d5468f029e0c152354 Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Sat, 22 Aug 2026 10:36:15 -0700 Subject: [PATCH 02/22] =?UTF-8?q?docs(ios-sync):=20fix=20the=20resume=20bl?= =?UTF-8?q?ock=20=E2=80=94=20three=20threads,=20not=20two?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-08-22 triage added #93 as a third item but left the lead-in saying "Two open threads" and put the new entry after a blank quote line, which breaks the list out of its numbering. --- docs/ios-sync.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/ios-sync.md b/docs/ios-sync.md index c01c1f3c..febdadee 100644 --- a/docs/ios-sync.md +++ b/docs/ios-sync.md @@ -124,14 +124,13 @@ seeded-data mode). **SKIP** — no portable behavior, Android has its own indepe > out so the other 10 items can land. Version bumped to 2.5.0 (`68c9788`) to match iOS > MARKETING_VERSION. -> **▶ RESUME HERE (next session):** Two open threads, in order: +> **▶ RESUME HERE (next session):** Three open threads, in order: > 1. **PR #45 review remediation** — the 2026-08-09 review found parity bugs in #95, #98, > #99, #100 and a regression in #94. See "Session notes — 2026-08-09 cross-platform > review" below. > 2. **#130 RWfit redo** — on `feat/rwfit-ring-family`, rebuilt from > `decompiled-rwfit-official/` (see the backed-out section below for what was wrong), > then recombined. -> > 3. **#93 CRP hardening** (queued 2026-08-22) — five small, independent fixes to the existing > Android CRP driver, listed in "2026-08-22 triage" below. Item (1), gating `CONNECTED` on the > `fdd3` reply channel, is worth doing on its own even if the rest waits. From a6e35c14b680804e6f371a83ac7e8471c8992adb Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Sat, 22 Aug 2026 10:43:09 -0700 Subject: [PATCH 03/22] docs(crp): implementation plan for the #93 CRP hardening items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-08-22 triage recorded *what* is missing; this is the *how*, written for an agent picking the work up with no prior context. Per item: the diagnosis with the exact file:line it lives at, the Kotlin to write with its comment, the iOS hunk in 4d65b60 it corresponds to, the test to add, and — for item 2 — the two existing tests that will fail and must be updated rather than worked around. Two places the port is not a transliteration, called out so they aren't missed: - Item 4 needs materially different Kotlin. Swift's String(bytes:encoding:) returns nil on invalid UTF-8; Kotlin's String(bytes, UTF_8) is lenient and cannot fail, so it needs an explicit CharsetDecoder with CodingErrorAction.REPORT. A transliteration would silently keep the bug. - Item 1's element type is RequiredSubscription (uuid + mode), not iOS's bare CBUUID, and the test asserts the required UUID is also a declared notify characteristic — that mismatch would turn the fix into a connect hang. §7 records the three iOS findings that must NOT be ported, with the evidence: the frame-assembler reset is dead code here because every reconnect funnels through beginConnect -> installDriver with autoConnect = false, and the half-open loop fix is a Swift ClosedRange trap that Kotlin's IntRange doesn't have. Also states what the unit tests cannot establish — all five are BLE-timing or wire-format edge cases that need zaggash's R11 to observe for real — so the next agent doesn't write "verified" for something that wasn't. ios-sync.md's port-queue row, resume pointer and triage note now link here. --- docs/crp-r11-hardening-plan.md | 504 +++++++++++++++++++++++++++++++++ docs/ios-sync.md | 12 +- 2 files changed, 512 insertions(+), 4 deletions(-) create mode 100644 docs/crp-r11-hardening-plan.md diff --git a/docs/crp-r11-hardening-plan.md b/docs/crp-r11-hardening-plan.md new file mode 100644 index 00000000..013a360f --- /dev/null +++ b/docs/crp-r11-hardening-plan.md @@ -0,0 +1,504 @@ +# CRP (Colmi R11) driver hardening — implementation plan + +**Ledger item:** iOS PR [#93](https://github.com/saksham2001/PulseLoopiOS/pull/93), triaged +2026-08-22 in [`ios-sync.md`](ios-sync.md) (§ "2026-08-22 triage"). +**Branch:** `ios-sync-triage-2026-08-22`. +**Scope:** five small, independent fixes to the CRP driver that already exists on Android. +**Effort:** S–M total. No schema change, no UI, no new files. + +--- + +## 0. Read this first — what this task is and isn't + +iOS PR #93 is **not** a normal upstream feature to port. It is the *iOS port of this repo's own +Android CRP work*, so the driver, the decoder, the sync engine, the "Colmi R11 (Da Rings app)" +pairing card and the not-worn measurement hint are all **already present here**. Do not port them +again. If you find yourself writing a `CRPDecoder`, you are in the wrong task. + +What Android is missing is the hardening iOS added **afterwards**, in commit `4d65b60` +("fix(crp): reset the frame assembler across reconnects, gate connect on fdd3"), which was an +adversarial review of that branch. Five of its eight findings apply to Android. Three do not, and +§7 explains why so nobody re-ports them. + +**The iOS commit is your reference implementation.** Read it before you start: + +```sh +git -C show 4d65b60 +``` + +The iOS repo is the parent directory of this one (`../` from `android/`), on branch `main`. +Every item below cites the exact Swift hunk it corresponds to. **Judge behaviour, not syntax** — +a Swift fix ports as a Kotlin rule, and item 4 in particular needs materially different Kotlin, +because Kotlin's UTF-8 decode is not Swift's. + +### Ground rules + +- Read `AGENTS.md` at this repo root and at the parent repo root first. +- **Do not add a `Co-Authored-By` trailer** to commits in this repo. +- These five items are **independent**. Land them in one commit or five; item 1 is the most + valuable and stands alone if the rest slip. +- Every item has a test. The suite is `./gradlew testDebugUnitTest` and was **1099 tests, 0 + failures** at the tip of `ios-sync-triage-2026-08-22`. Do not finish below that count. +- **No hardware is available in this environment.** Nothing here needs a ring: all five are unit + testable. Say so plainly in the commit rather than implying hardware verification. + +### Files you will touch + +| File | Items | +|---|---| +| `app/src/main/java/com/pulseloop/ring/CRPDriver.kt` | 1 | +| `app/src/main/java/com/pulseloop/ring/CRPSyncEngine.kt` | 2, 3 | +| `app/src/main/java/com/pulseloop/ring/CRPDecoder.kt` | 4, 5 | +| `app/src/test/java/com/pulseloop/ring/CRPSyncEngineTest.kt` | 2, 3 | +| `app/src/test/java/com/pulseloop/ring/CRPDecoderTest.kt` | 1, 4, 5 | + +--- + +## 1. Gate `CONNECTED` on the `fdd3` reply channel + +**Severity: highest of the five. Do this one even if you do nothing else.** + +### The bug + +`CRPDriver` does not override `requiredSubscriptionsBeforeConnected`, so it inherits the default +`emptyList()` from `WearableDriver` (`WearableDriver.kt:32`). With an empty list, +`SubscriptionSetupGate.isReady` falls back to *first-notify* readiness — `completed.isNotEmpty()` +(`SubscriptionSetupGate.kt:31-35`). The connection therefore counts as up on whichever notify +characteristic finishes its CCCD write first. + +For CRP that is `fdd1` (the current-steps push), **never** `fdd3`, which carries *every* command +reply. `CONNECTED` is what runs `CRPSyncEngine.runStartup`, so the handshake can write its whole +sequence — set-time, firmware query, six read-backs, five timing enables, six history queries and +the six-day sleep backfill, ~26 frames — into a channel the app is not yet listening to. + +A reply lost that way is **indistinguishable from a slow one**. That is the exact signature of the +wrong-opcode bug this driver already fixed once (the group-7 firmware query that produced +23 sends / 0 replies in the 2026-07-25 capture), so a regression here would be diagnosed as a +protocol problem, not a connect-ordering problem. + +### The fix + +In `CRPDriver.kt`, alongside the other topology declarations: + +```kotlin + /** + * Hold CONNECTED until `fdd3` is live. Without this the connection counts as up on whichever + * notify characteristic completes its CCCD write first — for CRP that is `fdd1` (the steps + * push), never `fdd3`, which carries *every* command reply. CONNECTED is what runs + * [CRPSyncEngine.runStartup], so a handshake begun too early would write the clock, firmware + * query, read-backs, timing config and the whole history pull into a channel we aren't + * listening to yet, and each lost reply is indistinguishable from a slow one. + * + * Only `fdd3` is required: `fdd1`/`fdd6`/`2a37` carry no reply the handshake waits on, so + * gating on them would only delay the connect. NOTIFICATION, not INDICATION — CRP's + * characteristics are notify (unlike YCBT's indicate pair). + */ + override val requiredSubscriptionsBeforeConnected = listOf( + RequiredSubscription(CRPUUIDs.CHAR_CMD_NOTIFY, SubscriptionMode.NOTIFICATION), + ) +``` + +`RingBLEClient` already threads this through — `installDriver` builds the gate from it +(`RingBLEClient.kt:900-904`). There is no wiring to add. + +**Reference:** iOS `CRPDriver.swift`, the `requiredSubscriptionsBeforeConnected` property added in +`4d65b60`. Android's element type is `RequiredSubscription` (uuid + mode), not iOS's bare `CBUUID`. + +**Model this on:** `YCBTDriver.kt:43-46`, the only existing Android driver that overrides this. + +### The test + +Add to `CRPDecoderTest.kt` (where the other driver-topology tests live): + +```kotlin + @Test + fun `connect is held until the command-reply channel is live`() { + val driver = CRPDriver(null) + assertEquals( + listOf(RequiredSubscription(CRPUUIDs.CHAR_CMD_NOTIFY, SubscriptionMode.NOTIFICATION)), + driver.requiredSubscriptionsBeforeConnected, + ) + // A required subscription that isn't a declared notify char could never be satisfied, and + // the connect would hang until the watchdog killed it. + for (required in driver.requiredSubscriptionsBeforeConnected) { + assertTrue( + "${required.uuid} is not a declared notify characteristic", + driver.notifyUUIDs.any { it.equals(required.uuid, ignoreCase = true) }, + ) + } + } +``` + +That second assertion is not padding — it is the failure mode that would turn this fix into a +connect hang. + +--- + +## 2. Stop re-querying firmware on every poll pass + +### The bug + +`CRPSyncEngine.runStartup` sends `CRPProtocol.queryFirmwareVersion()` unconditionally +(`CRPSyncEngine.kt:42`), while the six read-backs immediately below it are gated behind +`readBacksSent`. The KDoc on `sendConnectionReadBacks` argues the gate exists because the single +`fdd2` channel is scarce and a spot SpO2 needs ~48 s of it — an argument the line above it +contradicts. + +`runStartup` **is** the poll pass: `RingSyncWorker`'s ~30-minute background sync and the foreground +`syncNow()` both re-invoke it. So this is one extra write on the scarce channel every half hour, +forever, for a string that cannot change between syncs. + +### The fix + +In `CRPSyncEngine.kt`: + +1. Delete the `send(CRPProtocol.queryFirmwareVersion())` call and its comment block from + `runStartup` (currently lines 37-42). +2. Move that send to the **top** of `sendConnectionReadBacks()`, before `querySupportSpO2Type()`. +3. Rename `sendConnectionReadBacks` → `sendConnectionQueries` and `readBacksSent` → + `connectionQueriesSent`. iOS did this because "read-backs" no longer describes the set once + firmware joins it. Rename the `CRPSyncEngineTest` helper `readBackQueries` to match. +4. Fold the firmware rationale (the 7/1-vs-3/3 opcode history — keep it, it is hard-won) into the + `sendConnectionQueries` KDoc. +5. Update that KDoc's "six writes" to "seven writes". + +**Ordering constraint — do not disturb it.** `sendConnectionQueries()` must still run **before** +`applyTimingSettings(...)`. The state queries report each monitor's *current* interval, and +`applyTimingSettings` force-enables everything moments later; asking afterwards would only describe +the state we just imposed, which answers nothing. `CRPSyncEngineTest` pins this. If that assertion +fails, fix the call site, not the expectation. + +**Reference:** iOS `CRPSyncEngine.swift` — the `sendConnectionReadBacks` → `sendConnectionQueries` +rename hunk in `4d65b60`. + +### Also update the now-false comments + +Two comments elsewhere assert the old behaviour and become wrong: + +- `CRPDecoder.kt:195-197` — "…and [CRPSyncEngine.runStartup] re-queries firmware on every sync + pass". After this change it does not. +- `CRPDecoderTest.kt:119-122`, the test named *`firmware version reaches the device record as its + own event, not a connection change`* — its comment says "runStartup re-queries firmware on every + ~30-minute sync pass, so that path would restate CONNECTED all session long." + +The **test itself stays and must keep passing** — firmware must still not bridge to +`DeviceStateChanged`. Only the justification changes: a firmware reply says nothing about the +connection, and bridging it to CONNECTED would restamp the device row as freshly connected. Rewrite +the comment; do not delete the test. + +### The tests + +Two existing tests in `CRPSyncEngineTest.kt` assert the current behaviour and **must** be updated — +they will fail, and that failure is correct: + +- **line 37**, `runStartup sends set-time, firmware query, user info, default monitor enables, then + the history pull`. Both `assertEquals` calls embed `3 to 3` in the expected opcode list. First + pass: `3 to 3` moves from position 2 into the read-back group. Second pass: `3 to 3` must + **disappear** — expected becomes `listOf(1 to 1, 1 to 0) + timingEnables + historyQueries`. + Rename the test to match its new meaning. +- **line 88**, `read-backs are sent once per connection, not once per poll pass`. Add firmware to + the set it guards, and rename to `connection queries are sent once per connection…`. + +Then add the explicit regression: + +```kotlin + @Test + fun `firmware is asked once per connection, not on every poll pass`() { + // runStartup IS the ~30-minute background sync. A firmware string is exactly as immutable + // as the sensor roster gated beside it, and fdd2 is the scarce channel (a spot SpO2 needs + // ~48 s of it). + val w = FakeWriter() + val engine = CRPSyncEngine(w) + engine.runStartup() + assertTrue("firmware asked on the first pass", (3 to 3) in w.opcodes()) + + w.sent.clear() + engine.runStartup() + assertTrue("firmware must not repeat every pass", (3 to 3) !in w.opcodes()) + + // A new connection builds a new engine, which asks again. + val reconnected = FakeWriter() + CRPSyncEngine(reconnected).runStartup() + assertTrue((3 to 3) in reconnected.opcodes()) + } +``` + +**Reference:** iOS `CRPSyncEngineTests.swift`, +`testConnectionQueriesAreSentOncePerConnectionNotPerPass`. + +--- + +## 3. Key the timing follow-up guard on `day` as well as `cmd` + +### The bug + +`CRPSyncEngine.kt:104` declares `requestedTimingFrames` as `mutableSetOf()`, and line 179 keys +it `event.cmd * 100 + nextIndex`. The `day` is not in the key. + +Today every timing query is `day = 0`, so nothing is broken *right now*. But this engine **already +issues multi-day requests** — `sendSleepBackfill()` walks `1..SLEEP_BACKFILL_DAYS` (6 days). The +moment the timing vitals get the same backfill treatment, day 1's frame-1 follow-up is silently +swallowed because day 0 already inserted the same key. Silently: no error, just a day that never +completes its multi-frame pull. + +This is pre-emptive, and worth doing because the failure is invisible when it lands. + +### The fix + +In `CRPSyncEngine.kt`, replace the `Int` key with a data class: + +```kotlin + /** One timing-history follow-up we've already asked for. Keyed on `day` as well as `cmd` — + * today's queries are all day 0, but this engine already issues multi-day requests for sleep + * ([sendSleepBackfill]), and a key without `day` would silently swallow day 1's frame-1 + * follow-up the moment the timing vitals get the same backfill treatment. */ + private data class TimingFrameRequest(val cmd: Int, val day: Int, val frameIndex: Int) + + /** Frame follow-ups already requested this poll pass, so a ring that re-sends the same frame + * can't trigger a request storm. Cleared at the start of every [queryAllHistory] pass so each + * sync re-pulls the full timeline. */ + private val requestedTimingFrames = mutableSetOf() +``` + +and at the guard (line 179): + +```kotlin + val request = TimingFrameRequest(event.cmd, event.day, nextIndex) + if (!requestedTimingFrames.add(request)) return +``` + +`requestedTimingFrames.clear()` in `queryAllHistory()` is unchanged. + +**Reference:** iOS `CRPSyncEngine.swift`, the `TimingFrameRequest` struct in `4d65b60`. + +### The test + +`a repeated frame does not spam duplicate follow-up requests` (line 181) must still pass unchanged — +that is the property this must not break. Add: + +```kotlin + @Test + fun `the follow-up guard distinguishes days`() { + val w = FakeWriter() + val engine = CRPSyncEngine(w) + engine.runStartup() + w.sent.clear() + engine.handle(RingDecodedEvent.TimingHistoryFrame(cmd = 15, day = 0, frameIndex = 0)) + engine.handle(RingDecodedEvent.TimingHistoryFrame(cmd = 15, day = 1, frameIndex = 0)) + assertEquals("a different day is a different follow-up", 2, w.sent.size) + assertEquals(0, w.sent[0][6].toInt()) // day 0 in the payload + assertEquals(1, w.sent[1][6].toInt()) // day 1 + } +``` + +Check `RingDecodedEvent.TimingHistoryFrame`'s actual constructor signature and the payload byte +offset against `CRPProtocol.queryTimingHeartRateHistory(day, frameIndex)` before trusting the +indices above — index 6 is what iOS asserts and Android's frame layout matches, but verify rather +than assume. + +**Reference:** iOS `CRPSyncEngineTests.swift`, `testFollowUpGuardDistinguishesDays`. + +--- + +## 4. Validate the firmware string instead of coercing it + +**This is the item whose Kotlin differs most from the Swift. Read carefully.** + +### The bug + +`CRPDecoder.decodeFirmwareVersion` (`CRPDecoder.kt:199-204`) does: + +```kotlin +val version = String(payload, Charsets.UTF_8).trim { it <= ' ' } +``` + +`String(bytes, UTF_8)` in Kotlin/JVM is **lenient**: invalid byte sequences are silently replaced +with U+FFFD. It cannot fail. So a binary payload becomes a row of replacement characters and is +published as `RingDecodedEvent.FirmwareRevision` — and whatever this returns is **shown verbatim in +the Settings device card**. The user sees replacement characters presented as their ring's firmware +version. + +### The fix + +Strict-decode, then trim padding, then reject anything still holding a control byte: + +```kotlin + private fun decodeFirmwareVersion(payload: ByteArray): List? { + // Validated, not coerced: whatever this returns is shown verbatim in Settings. + // `String(bytes, UTF_8)` is LENIENT on the JVM — it substitutes U+FFFD for invalid bytes + // and cannot fail — so a binary payload would render as a row of replacement characters + // presented as a firmware version. A REPORTing decoder throws instead. + val decoder = Charsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + val raw = try { + decoder.decode(ByteBuffer.wrap(payload)).toString() + } catch (_: CharacterCodingException) { + return null // not text at all — the caller acks it + } + // Trim only what firmwares actually pad with: whitespace and NUL. Deliberately narrower + // than the vendor's `trim { it <= ' ' }`, which strips ALL control bytes and would let a + // binary payload's leading junk come off so whatever printable byte followed passed as a + // "version" (01 02 03 41 -> "A"). Padding comes off, then anything still holding a control + // byte is rejected outright rather than salvaged. + val trimmed = raw.trim { it.isWhitespace() || it == NUL } + if (trimmed.isEmpty()) return null + if (trimmed.any { it.isISOControl() }) return null + return listOf(RingDecodedEvent.FirmwareRevision(trimmed)) + } +``` + +where `NUL` is the NUL character — declare it as a private constant, `private const val NUL = '\u0000'`, so the predicate stays readable. + +Imports needed: `java.nio.ByteBuffer`, `java.nio.charset.CharacterCodingException`, +`java.nio.charset.CodingErrorAction`. + +Returning `null` is already the "nothing readable" contract — the caller at `CRPDecoder.kt:135` +falls through to `CommandAck`. Do not change that path. + +**Item 5 is this same edit** — the narrower trim is the second half of the same function, kept as a +separate ledger row only because iOS listed it separately. There is nothing extra to do for it. + +**Reference:** iOS `CRPDecoder.swift`, `decodeFirmwareVersion` in `4d65b60`. Swift's +`String(bytes:encoding:)` returns `nil` on invalid UTF-8, which is why the Swift version needs no +explicit decoder — **Kotlin has no equivalent one-liner**, hence the `CharsetDecoder`. + +### The tests + +Three existing tests in `CRPDecoderTest.kt` must keep passing unchanged — they are the regression +net against over-tightening: + +- `firmware version decodes as the UTF-8 string the vendor reads` (line 109) — `MOY-R1K3-2.1.6`. +- `firmware version tolerates NUL padding` (line 130) — trailing NULs still trimmed. +- `empty firmware payload is acked, not reported as a blank version` (line 138) — a lone `0x00` + still acks. + +Add: + +```kotlin + @Test + fun `a non-text firmware payload is acked rather than coerced into a version`() { + // Whatever decodeFirmwareVersion returns is shown verbatim in Settings, so a payload that + // isn't a version string must ack. `String(bytes, UTF_8)` would have coerced the first into + // a U+FFFD run and the second into control junk, and published both as a firmware version. + val payloads = listOf( + byteArrayOf(0xC3.toByte(), 0x28, 0xA0.toByte(), 0xFF.toByte()), // invalid UTF-8 + byteArrayOf(0x01, 0x02, 0x03, 0x41), // valid UTF-8, binary + ) + for (payload in payloads) { + val frame = CRPProtocol.frame(3, CRPCommands.CMD_QUERY_FIRMWARE_VERSION, payload) + val events = CRPDecoder.decode(frame, fdd3) + assertTrue( + "payload must not publish a version", + events.none { it is RingDecodedEvent.FirmwareRevision }, + ) + assertTrue(events.single() is RingDecodedEvent.CommandAck) + } + } +``` + +The second payload is the one that proves the *narrow* trim (item 5): under the vendor's +`trim { it <= ' ' }` it would have yielded the version string `"A"`. + +**Reference:** iOS `CRPDecoderTests.swift`, +`testNonTextFirmwarePayloadsAreRejectedRatherThanCoerced` — same two payloads. + +--- + +## 5. (Same edit as item 4) + +Kept as its own row because the ledger and the iOS commit list it separately. The narrower trim is +implemented by the `trim { it.isWhitespace() || it == NUL }` + `isISOControl()` rejection in §4. +Nothing further to do. + +--- + +## 6. Verification + +```sh +./gradlew compileDebugKotlin # KSP/Room validate on the way through +./gradlew testDebugUnitTest # expect >= 1099 + your new tests, 0 failures +``` + +Count the suite the way the ledger does: + +```sh +python3 - <<'PY' +import glob, re +t = f = e = 0 +for p in glob.glob('app/build/test-results/testDebugUnitTest/*.xml'): + m = re.search(r'tests="(\d+)".*?failures="(\d+)".*?errors="(\d+)"', open(p).read(4000)) + if m: t += int(m[1]); f += int(m[2]); e += int(m[3]) +print(f"tests={t} failures={f} errors={e}") +PY +``` + +**What cannot be verified here:** every one of these is about BLE timing or wire-format edge cases +that need zaggash's R11 to observe for real. The unit tests pin the *rules*; they do not prove the +ring behaves as assumed. Item 1 in particular changes when `CONNECTED` fires, which is exactly the +kind of change that looks fine in tests and reveals itself on hardware. State this honestly in the +commit message — do not write "verified" for anything that wasn't. + +If hardware does become available, the honest test for item 1 is: pair the R11, confirm the +handshake completes rather than partially answering, and confirm the connect doesn't hang (a +required subscription that never completes would stall until the 30 s watchdog). + +--- + +## 7. Do NOT port these three + +They are in the iOS commit and they do not apply here. Recorded so nobody re-ports them. + +### 7a. Frame-assembler reset across reconnects + +This was iOS's headline bug: there, auto-reconnect re-dials with a bare `central.connect` and keeps +the `CRPDriver` instance, so a frame left half-assembled when the old link dropped is completed with +bytes from the new one and decoded as genuine. Because the group-2 history frames are long and +multi-notification, the spliced result is a *fabricated vital sample*, not a parse failure. + +**Android is safe by construction:** + +- It connects with `autoConnect = false` (`RingBLEClient.kt:804`), matching the official QRing app. +- Every reconnect path funnels through `beginConnect`, which calls `installDriver` + (`RingBLEClient.kt:787`). +- `installDriver` builds a fresh driver via `coordinator.makeDriver` and immediately calls + `driver.connectionDidStart()` (`RingBLEClient.kt:897-900`). + +So each link gets a brand-new `CRPDriver` with a brand-new `CRPFrameAssembler`. Adding a `reset()` +hook would be dead code. + +`CRPDriver`'s KDoc already states this invariant correctly. **Keep it accurate.** The single change +that would reintroduce the iOS bug is a reconnect path that reuses a driver instead of reinstalling +it — if you ever make that change, this item comes back, and `connectionDidStart`/`connectionDidEnd` +already exist on `WearableDriver` (`WearableDriver.kt:50-51`) to hang the reset on. `RWfitDriver` +and `YCBTDriver` show the pattern. + +### 7b. Half-open sleep-backfill loop + +iOS used `for daysAgo in 1...crpSleepBackfillDays`, which **traps at runtime** if the documented +tuning knob is turned down to today-only (`1...0` is an invalid `ClosedRange`). iOS changed it to +`1..<(n + 1)`. + +Kotlin's `1..SLEEP_BACKFILL_DAYS` is an `IntRange`, and `1..0` is simply **empty** — no exception, +the loop body doesn't run. `CRPSyncEngine.kt:151` is already safe at `SLEEP_BACKFILL_DAYS = 0`. +Changing it to `until` would be churn. + +### 7c. "Once per connection" comment corrections + +iOS's comments claimed connection scope for state that is really per-driver-install, and were +rewritten. Android's already say the right thing: `readBacksSent` and `sleepBackfillSent` are both +documented as per-engine-instance, with a fresh engine built per connect +(`CRPSyncEngine.kt:63-65`, `124-126`). + +Note the nuance if you touch them: on Android the "fresh engine per connect" claim is *true* +(§7a), which is why the comments are accurate here and were not on iOS. + +--- + +## 8. When you're done + +1. Update the port-queue row for #93 in [`ios-sync.md`](ios-sync.md) — flip `☐` to `☑` and put your + commit SHA in the **Android commit** column. +2. Remove #93 from the "▶ RESUME HERE" list and drop the count back to two threads. +3. If you land only some items, say which in the row rather than flipping it — a half-done row that + reads as done is worse than an open one. diff --git a/docs/ios-sync.md b/docs/ios-sync.md index febdadee..3eeed8a4 100644 --- a/docs/ios-sync.md +++ b/docs/ios-sync.md @@ -114,7 +114,7 @@ seeded-data mode). **SKIP** — no portable behavior, Android has its own indepe | ☑ | [#130](https://github.com/saksham2001/PulseLoopiOS/pull/130) `cf5c0f4` | ~08-04 | RWfit ring family (dual 0x7E/0xAB protocol, full metric set, service-UUID recognition) | **ADAPT** | L–XL | Backed out of PR #45, then **rebuilt from `decompiled-rwfit-official/`** on `feat/rwfit-vendor-rebuild`. Legacy `0x7E` path complete; JieLi `0xAB` framing complete but its history bodies are not decoded yet. **No hardware validation.** See below. | | ☑ | [#131](https://github.com/saksham2001/PulseLoopiOS/pull/131) `88c0f6b` | ~08-08 | Sleep hypnogram label alignment + press-and-hold stage scrubber (+ sync spinner rewrite, iOS-only) | **ADAPT** | S–M | `802789d` | | ☑ | [#80](https://github.com/saksham2001/PulseLoopiOS/pull/80) `c1275ad` | 07-11 | **Apple Health sync → Health Connect** (per-type toggles, vitals/sleep/activity/workout export, backfill choice, remove-all). Re-triaged 2026-08-09 from SKIP: the *behaviour* ports even though HealthKit doesn't. Write-only; profile import can't port (Health Connect has no DOB/sex type). Design + 7-phase plan in [`health-connect-integration.md`](health-connect-integration.md); reference implementation is `Gadgetbridge/` at the parent repo root, not iOS. Not blocked by the Play Store — the declaration form is a publishing gate, and Gadgetbridge ships this sideload-only. | **ADAPT** | XL | **Phases 0–6 complete** on `feat/health-connect-foundation` (write-only, 16 `WRITE_*` / 0 `READ_*`; lifecycle, removal, grant/revocation resets, archive-restore stamp, docs). Runtime-verified API 35. See `health-connect-integration.md` §8 | -| ☐ | [#93](https://github.com/saksham2001/PulseLoopiOS/pull/93) `439ca81` | 08-09 | **Colmi R11 CRP driver** — the iOS port *of Android's own* CRP work, so the driver itself is ALREADY-HAVE. What does not exist on Android is the **adversarial-review hardening** iOS added on top in `4d65b60`: 5 real gaps, listed in the 2026-08-22 triage note below. | **PARTIAL** (hardening only) | S–M | ☐ open — see "2026-08-22 triage" | +| ☐ | [#93](https://github.com/saksham2001/PulseLoopiOS/pull/93) `439ca81` | 08-09 | **Colmi R11 CRP driver** — the iOS port *of Android's own* CRP work, so the driver itself is ALREADY-HAVE. What does not exist on Android is the **adversarial-review hardening** iOS added on top in `4d65b60`: 5 real gaps, listed in the 2026-08-22 triage note below. | **PARTIAL** (hardening only) | S–M | ☐ open — step-by-step plan in [`crp-r11-hardening-plan.md`](crp-r11-hardening-plan.md) | ## Port priority — open items (as of 2026-08-08) @@ -132,8 +132,11 @@ seeded-data mode). **SKIP** — no portable behavior, Android has its own indepe > `decompiled-rwfit-official/` (see the backed-out section below for what was wrong), > then recombined. > 3. **#93 CRP hardening** (queued 2026-08-22) — five small, independent fixes to the existing -> Android CRP driver, listed in "2026-08-22 triage" below. Item (1), gating `CONNECTED` on the -> `fdd3` reply channel, is worth doing on its own even if the rest waits. +> Android CRP driver. **A full implementation plan, written for an agent picking this up cold, +> is in [`crp-r11-hardening-plan.md`](crp-r11-hardening-plan.md)** — per-item diagnosis, the +> Kotlin to write, the tests to add, the two existing tests that must change, and the three iOS +> findings that deliberately do NOT port. Item (1), gating `CONNECTED` on the `fdd3` reply +> channel, is worth doing on its own even if the rest waits. > > Next triage after those: `git -C log --first-parent --oneline 439ca81..main`. > @@ -458,7 +461,8 @@ frame assembler across reconnects, gate connect on fdd3"), an adversarial review Five of its eight findings apply here; three do not, for reasons worth recording so nobody re-ports them. -**Port these five (☐ open):** +**Port these five (☐ open).** Step-by-step instructions, with the Kotlin and the tests, are in +[`crp-r11-hardening-plan.md`](crp-r11-hardening-plan.md); this list is the summary. 1. **`CONNECTED` fires before the reply channel is live.** `CRPDriver` doesn't override `requiredSubscriptionsBeforeConnected` (only `YCBTDriver` does), so the connection counts as up From 701427e978b2c71339afb730b752cf65a82daf90 Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Sat, 22 Aug 2026 10:49:34 -0700 Subject: [PATCH 04/22] docs(ios-sync): one consolidated outstanding list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The work list was spread across three places that had drifted apart — the port queue's open rows, the RESUME HERE block, and the "Still open" bullets buried in the 2026-08-09 session note. Reading any one of them gave a wrong answer, and assembling the real picture meant reading all three. New "Outstanding — the single list" section near the top: eight rows, ordered by readiness rather than size, each saying what is *actually* left rather than naming a PR. RESUME HERE is now a pointer to it. Reconciling the three sources corrected four stale claims: - PR #45 review remediation was listed as an open thread. It isn't — every parity bug it found was fixed in 8df67b1 + 8f81c40. Only three items survive from it (#94's real feature, the #96 nutrition subset, and workout pause intervals), and those are now rows of their own instead of a footnote inside a session note. - #80 Health Connect is complete and merged to main; the row still pointed at feat/health-connect-foundation. - #130's RWfit rebuild is likewise merged to main; only the JieLi 0xAB history bodies remain, which is now its own row. - #82 and #90 read as unfinished ports. Their protocol layers are on main and no code gap is known — what they need is a live connect against hardware, so they are marked "needs hardware" rather than sitting in the same bucket as work someone could start today. Also records that feat/rwfit-vendor-rebuild, iOS_sync_2026-07-16 and ios_sync_2026-08-08 no longer exist, since several session notes still cite them as if they held unlanded work. --- docs/ios-sync.md | 62 +++++++++++++++++++++++++++++++++++++----------- 1 file changed, 48 insertions(+), 14 deletions(-) diff --git a/docs/ios-sync.md b/docs/ios-sync.md index 3eeed8a4..38f58e59 100644 --- a/docs/ios-sync.md +++ b/docs/ios-sync.md @@ -11,9 +11,14 @@ intentional platform differences listed at the bottom. (PR merges — not individual commits — are the unit of triage.) 2. For each PR: read the diff (`git diff ^1 `), decide a verdict, and add a row. Judge **behavior**, not code — a Swift fix ports as a Kotlin rule. -3. When a PORT/ADAPT item ships, fill in its **Android commit** column. +3. When a PORT/ADAPT item ships, fill in its **Android commit** column **and** remove it from + [Outstanding — the single list](#outstanding--the-single-list). 4. Update **Last triaged iOS commit** below. +**Just want to know what to work on?** Read [Outstanding — the single list](#outstanding--the-single-list) +and stop there. The port queue is the per-PR audit trail; the session notes are history. Neither is +the work list, and assembling one from all three is how items get missed. + **Verdicts:** `PORT` (Android needs it) · `ADAPT` (concept ports, implementation differs) · `PARTIAL` (some of it applies) · `ALREADY-HAVE` (Android already does this) · `SKIP` (iOS-only / docs / CI) · `BLOCKED` (depends on something Android lacks) @@ -31,6 +36,44 @@ intentional platform differences listed at the bottom. --- +## Outstanding — the single list + +Everything upstream that is **not yet on Android `main`**, in one place. This replaces reading the +port queue, the resume block and the session notes to assemble the picture yourself. The port queue +below is the per-PR audit trail; **this table is the work list.** + +Ordered by readiness, not size: the top rows can be started immediately, the bottom rows are +blocked on something outside the code. + +| # | Item | What is actually left | Size | Ready? | +|---|------|----------------------|------|--------| +| 1 | **#93 CRP (R11) hardening** | Five independent fixes to the existing CRP driver: gate `CONNECTED` on `fdd3`, stop re-querying firmware every poll pass, key the timing follow-up guard on `day`, validate the firmware string instead of coercing it (+ its narrower trim). Step-by-step plan: [`crp-r11-hardening-plan.md`](crp-r11-hardening-plan.md). | S–M | ✅ start now | +| 2 | **#94 `CoachNotificationDataTrigger`** | The *actual feature* of #94 was never ported — an event-bus subscriber that runs the due check-in slot when a sync completes, recovering a slot skipped for stale data. What shipped was the window constant mistaken for it (and its regression, since fixed in `8df67b1`). | M | ✅ start now | +| 3 | **Workout pause intervals** | `activity_events` is never written on Android, so Strava TCX can't drop paused trackpoints. `totalPauseSeconds` is already honoured — this is the per-interval detail only. | S–M | ✅ start now | +| 4 | **#96 nutrition subset** | `food_products`/`FoodProductDao`/`CachedFoodProductEntity` exist but **nothing can populate them**: no Open Food Facts client, no barcode scanner, no AI photo analysis, no coach `log_meal` tool. The ledger row is corrected to ADAPT (subset); this is the rest of it. | L | ✅ start now | +| 5 | **#130 RWfit — finish the JieLi `0xAB` path** | The vendor rebuild landed on `main`. The legacy `0x7E` path is complete; the JieLi `0xAB` framing is complete but **its history bodies are not decoded yet**. Read `decompiled-rwfit-official/`, never iOS and never guesswork (root `AGENTS.md`). | M | ✅ start now | +| 6 | **#82 YCBT (TK5 + SmartHealth-Colmi)** | Protocol layer is on `main` (`7a941a5`, `849131d`). **No code known to be missing** — what is missing is a live connect against real hardware. If one fails, re-read `BleHelper.java`'s connect sequence: the vendor's MTU/bonding/pacing timing was deliberately *not* copied (see the 2026-07-19 note). | — | ⛔ needs hardware | +| 7 | **#90 LuckRing / TK18** | Protocol layer is on `main` (`57e1e23`). Same position as #82: no known code gap, never validated against a real TK18. | — | ⛔ needs hardware | +| 8 | **#79 Activity Year trends** | Divide the in-progress current month by elapsed days, not a full 30/31. | S | ⛔ blocked — Android has no Activity-trends screen to fix | + +### Not on this list, and why + +- **#80 Health Connect** — done. All seven phases (0–6) are complete and **merged to `main`**; the + ledger's "on `feat/health-connect-foundation`" is stale. +- **PR #45 review remediation** — done. The 2026-08-09 review's parity bugs in #95/#98/#99/#100 and + the #94 regression were all fixed in `8df67b1` + `8f81c40`. Only rows 2–4 above survive from it. +- **#130 RWfit rebuild** — the *rebuild* is done and on `main` (the ledger's + `feat/rwfit-vendor-rebuild` is stale); only row 5 remains. +- Everything else in the port queue is `☑` or `⊘`. + +### Branch note + +The ledger's older entries name branches that no longer exist — `feat/rwfit-vendor-rebuild`, +`iOS_sync_2026-07-16`, `ios_sync_2026-08-08` were all merged and deleted. **Check `main` before +believing a branch reference in a session note below.** + +--- + ## Port queue (needs Android work) Ordered roughly by value-for-effort. Status: ☐ open · ☑ done. @@ -124,19 +167,10 @@ seeded-data mode). **SKIP** — no portable behavior, Android has its own indepe > out so the other 10 items can land. Version bumped to 2.5.0 (`68c9788`) to match iOS > MARKETING_VERSION. -> **▶ RESUME HERE (next session):** Three open threads, in order: -> 1. **PR #45 review remediation** — the 2026-08-09 review found parity bugs in #95, #98, -> #99, #100 and a regression in #94. See "Session notes — 2026-08-09 cross-platform -> review" below. -> 2. **#130 RWfit redo** — on `feat/rwfit-ring-family`, rebuilt from -> `decompiled-rwfit-official/` (see the backed-out section below for what was wrong), -> then recombined. -> 3. **#93 CRP hardening** (queued 2026-08-22) — five small, independent fixes to the existing -> Android CRP driver. **A full implementation plan, written for an agent picking this up cold, -> is in [`crp-r11-hardening-plan.md`](crp-r11-hardening-plan.md)** — per-item diagnosis, the -> Kotlin to write, the tests to add, the two existing tests that must change, and the three iOS -> findings that deliberately do NOT port. Item (1), gating `CONNECTED` on the `fdd3` reply -> channel, is worth doing on its own even if the rest waits. +> **▶ RESUME HERE:** see [**Outstanding — the single list**](#outstanding--the-single-list) above. +> It consolidates every open thread that used to be split across this block, the port queue and the +> session notes. Top of the list is **#93 CRP hardening**, which has a full implementation plan in +> [`crp-r11-hardening-plan.md`](crp-r11-hardening-plan.md). > > Next triage after those: `git -C log --first-parent --oneline 439ca81..main`. > From e7a1c582e2cb61644ff6bfbf010447f2c2e19546 Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Sat, 22 Aug 2026 10:52:12 -0700 Subject: [PATCH 05/22] =?UTF-8?q?docs(ios-sync):=20correct=20the=20branch?= =?UTF-8?q?=20note=20=E2=80=94=20merged-and-deleted=20vs=20merged-but-pres?= =?UTF-8?q?ent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verifying the note found one loose claim: it implied feat/health-connect-foundation no longer exists. The ref is still on origin — it is merged into main, which is a different thing, and a reader who checked it out would find a branch that looks alive. Split the note into the two cases with each SHA, and record that all five were confirmed ancestors of main on 2026-08-22. --- docs/ios-sync.md | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/ios-sync.md b/docs/ios-sync.md index 38f58e59..1c2bd752 100644 --- a/docs/ios-sync.md +++ b/docs/ios-sync.md @@ -58,8 +58,9 @@ blocked on something outside the code. ### Not on this list, and why -- **#80 Health Connect** — done. All seven phases (0–6) are complete and **merged to `main`**; the - ledger's "on `feat/health-connect-foundation`" is stale. +- **#80 Health Connect** — done. Phases 0–6 (all of them) are complete and **merged to `main`** at + `11abb92`. The `feat/health-connect-foundation` branch the port-queue row names still exists on + `origin`, but it is fully contained in `main` — read `main`, not the branch. - **PR #45 review remediation** — done. The 2026-08-09 review's parity bugs in #95/#98/#99/#100 and the #94 regression were all fixed in `8df67b1` + `8f81c40`. Only rows 2–4 above survive from it. - **#130 RWfit rebuild** — the *rebuild* is done and on `main` (the ledger's @@ -68,9 +69,17 @@ blocked on something outside the code. ### Branch note -The ledger's older entries name branches that no longer exist — `feat/rwfit-vendor-rebuild`, -`iOS_sync_2026-07-16`, `ios_sync_2026-08-08` were all merged and deleted. **Check `main` before -believing a branch reference in a session note below.** +Two different kinds of stale branch reference appear below, and both resolve the same way — the +work is on `main`: + +- **Merged and deleted** — `feat/rwfit-vendor-rebuild` (`8d16513`), `iOS_sync_2026-07-16` + (`0b971ac`), `ios_sync_2026-08-08` (`4434841`). All three are ancestors of `main`; neither the + local nor the `origin` ref still exists. +- **Merged but still present** — `feat/health-connect-foundation` (`11abb92`), + `feat/rwfit-ring-family` (`b073dad`). The refs exist on `origin` and are fully contained in + `main`, so checking one out gains nothing. + +**Check `main` before believing a branch reference in a session note below** (verified 2026-08-22). --- From c95b6e80fd1e960b122c25b5df97688b4821ce34 Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Sat, 22 Aug 2026 12:47:19 -0700 Subject: [PATCH 06/22] fix(crp): port the iOS R11 driver hardening (PR #93 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the five adversarial-review fixes from iOS commit 4d65b60 that the Colmi R11 CRP driver was missing on Android (step-by-step plan: docs/crp-r11-hardening-plan.md). The driver itself is ALREADY-HAVE (this is the iOS port of our own Android CRP work) — only the hardening was absent. 1. Gate CONNECTED on the fdd3 command-reply channel: CRPDriver now overrides requiredSubscriptionsBeforeConnected. Previously the connect counted as up on the first notify CCCD write (fdd1, the steps push) — never fdd3, which carries every command reply — so the runStartup handshake wrote the clock, firmware query, read-backs, timing config and the whole history pull into a channel we were not yet listening to; a lost reply was indistinguishable from a slow one (the exact signature of the opcode bug this driver already fixed once). 2. Fold the firmware query into the once-per-connection self-description set (sendConnectionReadBacks -> sendConnectionQueries). It was re-queried on every ~30-minute poll pass on the scarce fdd2 channel for a string that cannot change between syncs; the hard-won 7/1-vs-3/3 opcode history is preserved in the KDoc. 3. Key the timing-history follow-up guard on day as well as cmd (Int -> TimingFrameRequest), so a day-1 frame-1 follow-up is not silently swallowed once the timing vitals get the same multi-day backfill sleep already has. 4+5. Validate the firmware string instead of coercing it: strict UTF-8 via a reporting CharsetDecoder (the JVM's String(bytes, UTF_8) is lenient and substitutes U+FFFD), trim only whitespace + NUL, and reject any remaining control byte to an ack. The narrow trim closes the vendor's trim { it <= ' ' } hole (01 02 03 41 would have yielded "A"). No hardware verification: every item is about BLE timing or wire-format edge cases that need a real R11 to observe. The unit tests pin the rules; they do not prove the ring behaves as assumed (item 1 in particular changes when CONNECTED fires — the kind of change that looks fine in tests and reveals itself on hardware). :app:assembleDebug + :app:testDebugUnitTest green (1110 tests, 0 failures; 4 new: connect-gate, firmware-once-per-connection, follow-up-guard-distinguishes-days, non-text-firmware-acked). --- .../java/com/pulseloop/ring/CRPDecoder.kt | 37 +++++++-- .../main/java/com/pulseloop/ring/CRPDriver.kt | 16 ++++ .../java/com/pulseloop/ring/CRPSyncEngine.kt | 57 +++++++------ .../java/com/pulseloop/ring/CRPDecoderTest.kt | 48 ++++++++++- .../com/pulseloop/ring/CRPSyncEngineTest.kt | 81 ++++++++++++++----- 5 files changed, 183 insertions(+), 56 deletions(-) diff --git a/app/src/main/java/com/pulseloop/ring/CRPDecoder.kt b/app/src/main/java/com/pulseloop/ring/CRPDecoder.kt index 88d963be..76c0fb1f 100644 --- a/app/src/main/java/com/pulseloop/ring/CRPDecoder.kt +++ b/app/src/main/java/com/pulseloop/ring/CRPDecoder.kt @@ -1,5 +1,8 @@ package com.pulseloop.ring +import java.nio.ByteBuffer +import java.nio.charset.CharacterCodingException +import java.nio.charset.CodingErrorAction import java.time.Instant import java.time.ZoneId @@ -59,6 +62,9 @@ object CRPDecoder { private const val SESSION_GAP_MINUTES = 60 /** CRPHistoryDay caps at 14 days ago; a larger dayIndex is a corrupt reply. */ private const val MAX_HISTORY_DAY = 14 + /** NUL — the padding byte some firmwares pad the firmware string with; trimmed, but a control + * byte still present after trimming rejects the payload. */ + private const val NUL = '\u0000' fun decode( data: ByteArray, @@ -192,15 +198,32 @@ object CRPDecoder { * * Surfaced as [RingDecodedEvent.FirmwareRevision], not [RingDecodedEvent.FirmwareVersion] * (which carries an `Int` — the jring 0xF6 numeric build — and can't hold this) and not - * [RingDecodedEvent.Status] (which bridges to `DeviceStateChanged(CONNECTED, …)`; persistence - * rebuilds the sleep tables on every one of those, and [CRPSyncEngine.runStartup] re-queries - * firmware on every sync pass). + * [RingDecodedEvent.Status] (which bridges to `DeviceStateChanged(CONNECTED, …)`; a firmware + * reply says nothing about the connection, and bridging it would restamp the device row as + * freshly connected every time a firmware string arrived). */ private fun decodeFirmwareVersion(payload: ByteArray): List? { - // Trims NUL padding as well as whitespace: some firmwares pad the frame to a fixed width. - val version = String(payload, Charsets.UTF_8).trim { it <= ' ' } - if (version.isEmpty()) return null // empty or all-padding — the caller acks it instead - return listOf(RingDecodedEvent.FirmwareRevision(version)) + // Validated, not coerced: whatever this returns is shown verbatim in the Settings device + // card. `String(bytes, UTF_8)` is LENIENT on the JVM — it substitutes U+FFFD for invalid + // bytes and cannot fail — so a binary payload would render as a row of replacement + // characters presented as a firmware version. A REPORTing decoder throws instead. + val decoder = Charsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + val raw = try { + decoder.decode(ByteBuffer.wrap(payload)).toString() + } catch (_: CharacterCodingException) { + return null // not text at all — the caller acks it + } + // Trim only what firmwares actually pad with: whitespace and NUL. Deliberately narrower + // than the vendor's `trim { it <= ' ' }`, which strips ALL control bytes and would let a + // binary payload's leading junk come off so whatever printable byte followed passed as a + // "version" (01 02 03 41 -> "A"). Padding comes off, then anything still holding a control + // byte is rejected outright rather than salvaged. + val trimmed = raw.trim { it.isWhitespace() || it == NUL } + if (trimmed.isEmpty()) return null + if (trimmed.any { it.isISOControl() }) return null + return listOf(RingDecodedEvent.FirmwareRevision(trimmed)) } /** diff --git a/app/src/main/java/com/pulseloop/ring/CRPDriver.kt b/app/src/main/java/com/pulseloop/ring/CRPDriver.kt index ff6fc396..03ab8946 100644 --- a/app/src/main/java/com/pulseloop/ring/CRPDriver.kt +++ b/app/src/main/java/com/pulseloop/ring/CRPDriver.kt @@ -32,6 +32,22 @@ class CRPDriver(private val writer: RingCommandWriter?) : WearableDriver { override val batteryServiceUUID: String = CRPUUIDs.SERVICE_BATTERY override val batteryCharUUID: String = CRPUUIDs.CHAR_BATTERY_LEVEL + /** + * Hold CONNECTED until `fdd3` is live. Without this the connection counts as up on whichever + * notify characteristic completes its CCCD write first — for CRP that is `fdd1` (the steps + * push), never `fdd3`, which carries *every* command reply. CONNECTED is what runs + * [CRPSyncEngine.runStartup], so a handshake begun too early would write the clock, firmware + * query, read-backs, timing config and the whole history pull into a channel we aren't + * listening to yet, and each lost reply is indistinguishable from a slow one. + * + * Only `fdd3` is required: `fdd1`/`fdd6`/`2a37` carry no reply the handshake waits on, so + * gating on them would only delay the connect. NOTIFICATION, not INDICATION — CRP's + * characteristics are notify (unlike YCBT's indicate pair). + */ + override val requiredSubscriptionsBeforeConnected = listOf( + RequiredSubscription(CRPUUIDs.CHAR_CMD_NOTIFY, SubscriptionMode.NOTIFICATION), + ) + // MARK: Framing — the encoder/engine already build full CRP frames. override fun frame(command: ByteArray): ByteArray = command diff --git a/app/src/main/java/com/pulseloop/ring/CRPSyncEngine.kt b/app/src/main/java/com/pulseloop/ring/CRPSyncEngine.kt index d889edd5..056d6a5f 100644 --- a/app/src/main/java/com/pulseloop/ring/CRPSyncEngine.kt +++ b/app/src/main/java/com/pulseloop/ring/CRPSyncEngine.kt @@ -34,14 +34,8 @@ class CRPSyncEngine(private val writer: RingCommandWriter?) : RingSyncEngine { // Set the device clock first (matches the vendor's connect handshake), then user info so // the ring's step/calorie algorithm has real inputs. send(CRPProtocol.setTime()) - // Query firmware version so the UI doesn't show "Firmware: reading" (zaggash's report). - // The 23-sends/0-replies in the 2026-07-25 capture were our fault, not the ring's: the old - // opcode was group 7 cmd 1, which the vendor SDK uses for `querySavedGomoreKey`, not - // firmware. The real query is group 3 cmd 3 (`b1/l.k` → `d1/b.queryFirmwareVersion`), and - // it answers with a UTF-8 string — `MOY-R1K3-2.1.6` on zaggash's R11. - send(CRPProtocol.queryFirmwareVersion()) profile?.let { send(userInfoFrame(it)) } - sendConnectionReadBacks() + sendConnectionQueries() // Enable all-day vital monitoring. A fresh ring has these OFF, so without this the ring // stores no HR/SpO2/HRV/stress/temperature history and every history query below returns an // empty reply (issue #29, zaggash's full-day capture). When the user has saved a config we @@ -60,24 +54,31 @@ class CRPSyncEngine(private val writer: RingCommandWriter?) : RingSyncEngine { queryAllHistory() } - /** Whether this connection's read-backs have been sent. A fresh [CRPSyncEngine] is built per - * connection (`RingBLEClient` calls `driver.makeSyncEngine()` on connect), so instance state - * gives "once per connection" for free. */ - private var readBacksSent = false + /** Whether this connection's self-description queries have been sent. A fresh [CRPSyncEngine] + * is built per connection (`RingBLEClient` calls `driver.makeSyncEngine()` on connect), so + * instance state gives "once per connection" for free. */ + private var connectionQueriesSent = false /** * Ask the ring to describe itself, once per connection. * + * The firmware version string (`group 3 / cmd 3`) is immutable between syncs — exactly like + * the sensor roster beside it — so it is gated here too, not re-queried on every poll pass. + * The version query is group 3 cmd 3 (`b1/l.k` → `d1/b.queryFirmwareVersion`), NOT the group + * 7 cmd 1 it used to send: that opcode is the vendor's `querySavedGomoreKey`, which is why the + * R11 answered none of the 23 sends in the 2026-07-25 capture. It answers with a bare UTF-8 + * string — `MOY-R1K3-2.1.6` on zaggash's R11. + * * `querySupportSpO2Type` answers NOT_SUPPORT / SLEEP_OXYGEN / TIMING_OXYGEN; the timing-state * queries report each all-day monitor's configured interval (0 = off). Together they are the * evidence base for whether a silent history query means "the monitor is off" or "this ring - * lacks the sensor" — stress (`2/47`), temperature (`2/22`) and firmware (`7/1`) all went - * unanswered on zaggash's ring, and these replies are how we tell those apart next capture. + * lacks the sensor" — stress (`2/47`) and temperature (`2/22`) went unanswered on zaggash's + * ring, and these replies are how we tell those apart next capture. * * Deliberately **not** part of the poll pass. [runStartup] doubles as the ~30-minute background * re-sync and is also reached from `refresh()`/`querySleep()`, but what a ring supports cannot - * change between syncs. Re-asking would add six writes to every pass on a ring that funnels the - * handshake, timing config, history pull *and* on-demand measures through the single `fdd2` + * change between syncs. Re-asking would add seven writes to every pass on a ring that funnels + * the handshake, timing config, history pull *and* on-demand measures through the single `fdd2` * channel — and a spot SpO2 needs ~48 s of that channel to return a reading. * * **Call order matters: this must run BEFORE [applyTimingSettings].** The state queries report @@ -87,9 +88,10 @@ class CRPSyncEngine(private val writer: RingCommandWriter?) : RingSyncEngine { * monitor was off. `CRPSyncEngineTest` pins the ordering; if that assertion ever fails, fix the * call site rather than the expectation. */ - private fun sendConnectionReadBacks() { - if (readBacksSent) return - readBacksSent = true + private fun sendConnectionQueries() { + if (connectionQueriesSent) return + connectionQueriesSent = true + send(CRPProtocol.queryFirmwareVersion()) send(CRPProtocol.querySupportSpO2Type()) send(CRPProtocol.queryTimingHeartRateState()) send(CRPProtocol.queryTimingHrvState()) @@ -98,10 +100,16 @@ class CRPSyncEngine(private val writer: RingCommandWriter?) : RingSyncEngine { send(CRPProtocol.queryTimingTempState()) } - /** Frame follow-ups already requested this poll pass, keyed `cmd * 100 + frameIndex`, so a ring - * that re-sends the same frame can't trigger a request storm. Cleared at the start of every - * [queryAllHistory] pass so each sync re-pulls the full timeline. */ - private val requestedTimingFrames = mutableSetOf() + /** One timing-history follow-up we've already asked for. Keyed on `day` as well as `cmd` — + * today's queries are all day 0, but this engine already issues multi-day requests for sleep + * ([sendSleepBackfill]), and a key without `day` would silently swallow day 1's frame-1 + * follow-up the moment the timing vitals get the same backfill treatment. */ + private data class TimingFrameRequest(val cmd: Int, val day: Int, val frameIndex: Int) + + /** Frame follow-ups already requested this poll pass, so a ring that re-sends the same frame + * can't trigger a request storm. Cleared at the start of every [queryAllHistory] pass so each + * sync re-pulls the full timeline. */ + private val requestedTimingFrames = mutableSetOf() /** Request the stored all-day timelines the ring has accumulated: the group-2 "timing" vital * timelines (HR/SpO2/HRV/stress), temperature, and sleep. Vendor `u3/g1.java` fires the same set @@ -123,7 +131,7 @@ class CRPSyncEngine(private val writer: RingCommandWriter?) : RingSyncEngine { } /** Whether this connection has already backfilled older nights. Same "fresh engine per - * connection" trick as [readBacksSent]. */ + * connection" trick as [connectionQueriesSent]. */ private var sleepBackfillSent = false /** @@ -176,7 +184,8 @@ class CRPSyncEngine(private val writer: RingCommandWriter?) : RingSyncEngine { if (event.frameIndex >= terminalFrameIndex(event.cmd)) return val nextIndex = event.frameIndex + 1 // Guard against a ring that re-sends the same frame spamming duplicate follow-ups. - if (!requestedTimingFrames.add(event.cmd * 100 + nextIndex)) return + val request = TimingFrameRequest(event.cmd, event.day, nextIndex) + if (!requestedTimingFrames.add(request)) return send(timingQuery(event.cmd, event.day, nextIndex)) } } diff --git a/app/src/test/java/com/pulseloop/ring/CRPDecoderTest.kt b/app/src/test/java/com/pulseloop/ring/CRPDecoderTest.kt index a4435cb7..acdd946b 100644 --- a/app/src/test/java/com/pulseloop/ring/CRPDecoderTest.kt +++ b/app/src/test/java/com/pulseloop/ring/CRPDecoderTest.kt @@ -117,9 +117,9 @@ class CRPDecoderTest { @Test fun `firmware version reaches the device record as its own event, not a connection change`() { - // NOT RingDecodedEvent.Status: that bridges to DeviceStateChanged(CONNECTED), and a - // device-info reply says nothing about the connection. runStartup re-queries firmware on - // every ~30-minute sync pass, so that path would restate CONNECTED all session long. + // NOT RingDecodedEvent.Status: that bridges to DeviceStateChanged(CONNECTED), and a firmware + // reply says nothing about the connection — bridging it would restamp the device row as + // freshly connected every time a firmware string arrived. val decoded = RingDecodedEvent.FirmwareRevision("MOY-R1K3-2.1.6") val events = RingEventBridge.eventsFor(decoded) assertEquals("MOY-R1K3-2.1.6", (events.single() as PulseEvent.FirmwareRevision).version) @@ -152,6 +152,28 @@ class CRPDecoderTest { assertEquals(3, sent[5].toInt() and 0xFF) } + @Test + fun `a non-text firmware payload is acked rather than coerced into a version`() { + // Whatever decodeFirmwareVersion returns is shown verbatim in the Settings device card, so a + // payload that isn't a version string must ack. `String(bytes, UTF_8)` would have coerced + // the first into a U+FFFD run and the second into control junk, and published both as a + // firmware version. The second is the narrow-trim case: the vendor's `trim { it <= ' ' }` + // would have yielded "A" from it. + val payloads = listOf( + byteArrayOf(0xC3.toByte(), 0x28, 0xA0.toByte(), 0xFF.toByte()), // invalid UTF-8 + byteArrayOf(0x01, 0x02, 0x03, 0x41), // valid UTF-8, binary + ) + for (payload in payloads) { + val frame = CRPProtocol.frame(3, CRPCommands.CMD_QUERY_FIRMWARE_VERSION, payload) + val events = CRPDecoder.decode(frame, fdd3) + assertTrue( + "payload must not publish a version", + events.none { it is RingDecodedEvent.FirmwareRevision }, + ) + assertTrue(events.single() is RingDecodedEvent.CommandAck) + } + } + @Test fun `unrecognised group3 cmd is acked, not dropped`() { val ev = CRPDecoder.decode(CRPProtocol.frame(3, 2, byteArrayOf(0)), fdd3)[0] @@ -201,6 +223,26 @@ class CRPDecoderTest { assertEquals(1, driver.ingest(full.copyOfRange(4, full.size), fdd3).size) } + @Test + fun `connect is held until the command-reply channel is live`() { + // Without this override the connect counts as up on whichever notify char finishes its CCCD + // write first — for CRP that is fdd1 (steps), never fdd3 (every command reply), so the + // runStartup handshake would write into a channel we aren't listening to yet. + val driver = CRPDriver(null) + assertEquals( + listOf(RequiredSubscription(CRPUUIDs.CHAR_CMD_NOTIFY, SubscriptionMode.NOTIFICATION)), + driver.requiredSubscriptionsBeforeConnected, + ) + // A required subscription that isn't a declared notify char could never be satisfied, and + // the connect would fail its topology check — guard against it. + for (required in driver.requiredSubscriptionsBeforeConnected) { + assertTrue( + "${required.uuid} is not a declared notify characteristic", + driver.notifyUUIDs.any { it.equals(required.uuid, ignoreCase = true) }, + ) + } + } + // ── Sleep history (group 2 / cmd 14, vendor e1/j.b) ────────────────────────────────────── @Test diff --git a/app/src/test/java/com/pulseloop/ring/CRPSyncEngineTest.kt b/app/src/test/java/com/pulseloop/ring/CRPSyncEngineTest.kt index 5cab42e2..0c1c95ce 100644 --- a/app/src/test/java/com/pulseloop/ring/CRPSyncEngineTest.kt +++ b/app/src/test/java/com/pulseloop/ring/CRPSyncEngineTest.kt @@ -25,29 +25,29 @@ class CRPSyncEngineTest { * `daysAgo` rising in the payload — see CRPSyncEngine.sendSleepBackfill. */ private val sleepBackfill = List(6) { 2 to 14 } - /** The read-backs that let the ring describe itself instead of us guessing: SpO2 support type, - * then each all-day monitor's configured interval. See CRPSyncEngine.runStartup. */ - private val readBackQueries = listOf(2 to 37, 2 to 6, 2 to 7, 2 to 8, 2 to 45, 2 to 21) + /** The connection-scoped self-description queries, sent once per connection: the firmware + * version, SpO2 support type, then each all-day monitor's configured interval. + * See CRPSyncEngine.runStartup / sendConnectionQueries. */ + private val connectionQueries = listOf(3 to 3, 2 to 37, 2 to 6, 2 to 7, 2 to 8, 2 to 45, 2 to 21) /** The all-day monitor enables sent on connect (default ALL_ON): HR, HRV, stress, SpO2, temp — * see CRPSyncEngine.applyTimingSettings. Without these a fresh R11 records no history. */ private val timingEnables = listOf(1 to 6, 1 to 7, 1 to 39, 1 to 8, 1 to 13) @Test - fun `runStartup sends set-time, firmware query, user info, default monitor enables, then the history pull`() { - // The firmware query is 3/3 (`b1/l.k` -> d1/b.queryFirmwareVersion), NOT the 7/1 it used to - // send -- that opcode is the vendor's `querySavedGomoreKey` and the R11 never answers it. + fun `runStartup sends set-time, the connection queries, user info, default monitor enables, then the history pull`() { val w = FakeWriter() val engine = CRPSyncEngine(w) engine.runStartup() - // set-time, firmware query, read-backs, default-on monitor enables, then the history pull. + // set-time, then the once-per-connection self-description queries (firmware + read-backs), + // the default-on monitor enables, then the history pull. // - // The read-backs MUST precede the enables: they report each monitor's current interval, and - // the enables force everything on moments later. Asking afterwards would only describe the - // state we just imposed. If this assertion fails, move the call site back — don't reorder the - // expectation. See CRPSyncEngine.sendConnectionReadBacks. + // The connection queries MUST precede the enables: the state queries report each monitor's + // current interval, and the enables force everything on moments later. Asking afterwards + // would only describe the state we just imposed. If this assertion fails, move the call + // site back — don't reorder the expectation. See CRPSyncEngine.sendConnectionQueries. assertEquals( - listOf(1 to 1, 3 to 3) + readBackQueries + timingEnables + historyQueries + sleepBackfill, + listOf(1 to 1) + connectionQueries + timingEnables + historyQueries + sleepBackfill, w.opcodes(), ) @@ -56,11 +56,12 @@ class CRPSyncEngineTest { UserProfileValues(metric = true, gender = 1u, age = 30u, heightCm = 180u, weightKg = 75u), ) engine.runStartup() - // A second pass on the same connection re-sends the poll work but NOT the read-backs, and + // A second pass on the same connection re-sends the poll work but NOT the connection + // queries (firmware included — a firmware string is as immutable as the sensor roster) and // NOT the sleep backfill — what the ring supports cannot change between syncs, and the older // nights were already pulled. runStartup is the ~30-minute background sync, so anything // repeated here lands on the single fdd2 channel every half hour forever. - assertEquals(listOf(1 to 1, 3 to 3, 1 to 0) + timingEnables + historyQueries, w.opcodes()) + assertEquals(listOf(1 to 1, 1 to 0) + timingEnables + historyQueries, w.opcodes()) } @Test @@ -80,28 +81,48 @@ class CRPSyncEngineTest { /** * `runStartup` doubles as the ~30-minute background poll and is also reached from - * `refresh()`/`querySleep()`. Re-asking what the ring supports on every one of those would add - * six writes per pass to the single `fdd2` channel a spot SpO2 needs for ~48 s. A fresh engine is - * built per connection, so the next connection asks again. + * `refresh()`/`querySleep()`. Re-asking what the ring supports (and its firmware) on every one + * of those would add seven writes per pass to the single `fdd2` channel a spot SpO2 needs for + * ~48 s. A fresh engine is built per connection, so the next connection asks again. */ @Test - fun `read-backs are sent once per connection, not once per poll pass`() { + fun `connection queries are sent once per connection, not once per poll pass`() { val w = FakeWriter() val engine = CRPSyncEngine(w) engine.runStartup() - assertTrue(w.opcodes().containsAll(readBackQueries)) + assertTrue(w.opcodes().containsAll(connectionQueries)) w.sent.clear() engine.runStartup() engine.runStartup() - for (q in readBackQueries) { - assertTrue("read-back $q must not repeat within a connection", q !in w.opcodes()) + for (q in connectionQueries) { + assertTrue("connection query $q must not repeat within a connection", q !in w.opcodes()) } // A new connection builds a new engine, which asks again. val reconnected = FakeWriter() CRPSyncEngine(reconnected).runStartup() - assertTrue(reconnected.opcodes().containsAll(readBackQueries)) + assertTrue(reconnected.opcodes().containsAll(connectionQueries)) + } + + @Test + fun `firmware is asked once per connection, not on every poll pass`() { + // runStartup IS the ~30-minute background sync. A firmware string is exactly as immutable + // as the sensor roster gated beside it, and fdd2 is the scarce channel (a spot SpO2 needs + // ~48 s of it). + val w = FakeWriter() + val engine = CRPSyncEngine(w) + engine.runStartup() + assertTrue("firmware asked on the first pass", (3 to 3) in w.opcodes()) + + w.sent.clear() + engine.runStartup() + assertTrue("firmware must not repeat every pass", (3 to 3) !in w.opcodes()) + + // A new connection builds a new engine, which asks again. + val reconnected = FakeWriter() + CRPSyncEngine(reconnected).runStartup() + assertTrue((3 to 3) in reconnected.opcodes()) } @Test @@ -191,6 +212,22 @@ class CRPSyncEngineTest { assertEquals(1, w.sent.size) } + @Test + fun `the follow-up guard distinguishes days`() { + // The engine already issues multi-day sleep requests (sendSleepBackfill); the moment the + // timing vitals get the same backfill, a key without `day` would silently swallow day 1's + // frame-1 follow-up. A different day must be a different follow-up. + val w = FakeWriter() + val engine = CRPSyncEngine(w) + engine.runStartup(); w.sent.clear() + engine.handle(RingDecodedEvent.TimingHistoryFrame(cmd = CRPCommands.CMD_QUERY_TIMING_HR, day = 0, frameIndex = 0)) + engine.handle(RingDecodedEvent.TimingHistoryFrame(cmd = CRPCommands.CMD_QUERY_TIMING_HR, day = 1, frameIndex = 0)) + assertEquals("a different day is a different follow-up", 2, w.sent.size) + // queryTimingHeartRateHistory frames the [day][frameIndex] payload at frame bytes 6/7. + assertEquals(0, w.sent[0][6].toInt()) // day 0 in the payload + assertEquals(1, w.sent[1][6].toInt()) // day 1 + } + @Test fun `applyUserProfile pushes user info immediately`() { val w = FakeWriter() From 678467f1e5eb8033c6c0c1301286193721e0c887 Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Sat, 22 Aug 2026 12:49:00 -0700 Subject: [PATCH 07/22] docs(ios-sync): close out #93 CRP hardening (ported in c95b6e8) - Flip the #93 port-queue row to done with the commit SHA. - Remove #93 from the Outstanding single list (renumber; top is now #94). - Update Last port date + Range covered to 11 ported (incl. #93 hardening). - Mark the 2026-08-22 triage note's five findings done; point RESUME at #94. --- docs/ios-sync.md | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/docs/ios-sync.md b/docs/ios-sync.md index 1c2bd752..7abab75e 100644 --- a/docs/ios-sync.md +++ b/docs/ios-sync.md @@ -31,8 +31,8 @@ the work list, and assembling one from all three is how items get missed. | **Fork baseline (iOS)** | `600c7a8` — Merge PR #6, 2026-06-20 | | **Last triaged iOS commit** | `439ca81` — Merge PR #93 (Colmi R11 CRP driver), 2026-08-09 | | **Last triage date** | 2026-08-22 | -| **Last port date** | 2026-08-08 — PR #45 (ios_sync_2026-08-08, 5 plan commits + 2 CR remediation commits = 7 total) | -| **Range covered** | 12 first-parent items since `0d1b965` (2026-07-18): PRs #73, #94–#100, #130, #131, #93 + 1 direct commit (`160c775`) → **10 ported, #130 backed out, #93 open (5 hardening gaps)** | +| **Last port date** | 2026-08-22 — PR #93 hardening (`c95b6e8`, 5 fixes from iOS `4d65b60`) | +| **Range covered** | 12 first-parent items since `0d1b965` (2026-07-18): PRs #73, #94–#100, #130, #131, #93 + 1 direct commit (`160c775`) → **11 ported (incl. #93 hardening), #130 backed out** | --- @@ -47,14 +47,13 @@ blocked on something outside the code. | # | Item | What is actually left | Size | Ready? | |---|------|----------------------|------|--------| -| 1 | **#93 CRP (R11) hardening** | Five independent fixes to the existing CRP driver: gate `CONNECTED` on `fdd3`, stop re-querying firmware every poll pass, key the timing follow-up guard on `day`, validate the firmware string instead of coercing it (+ its narrower trim). Step-by-step plan: [`crp-r11-hardening-plan.md`](crp-r11-hardening-plan.md). | S–M | ✅ start now | -| 2 | **#94 `CoachNotificationDataTrigger`** | The *actual feature* of #94 was never ported — an event-bus subscriber that runs the due check-in slot when a sync completes, recovering a slot skipped for stale data. What shipped was the window constant mistaken for it (and its regression, since fixed in `8df67b1`). | M | ✅ start now | -| 3 | **Workout pause intervals** | `activity_events` is never written on Android, so Strava TCX can't drop paused trackpoints. `totalPauseSeconds` is already honoured — this is the per-interval detail only. | S–M | ✅ start now | -| 4 | **#96 nutrition subset** | `food_products`/`FoodProductDao`/`CachedFoodProductEntity` exist but **nothing can populate them**: no Open Food Facts client, no barcode scanner, no AI photo analysis, no coach `log_meal` tool. The ledger row is corrected to ADAPT (subset); this is the rest of it. | L | ✅ start now | -| 5 | **#130 RWfit — finish the JieLi `0xAB` path** | The vendor rebuild landed on `main`. The legacy `0x7E` path is complete; the JieLi `0xAB` framing is complete but **its history bodies are not decoded yet**. Read `decompiled-rwfit-official/`, never iOS and never guesswork (root `AGENTS.md`). | M | ✅ start now | -| 6 | **#82 YCBT (TK5 + SmartHealth-Colmi)** | Protocol layer is on `main` (`7a941a5`, `849131d`). **No code known to be missing** — what is missing is a live connect against real hardware. If one fails, re-read `BleHelper.java`'s connect sequence: the vendor's MTU/bonding/pacing timing was deliberately *not* copied (see the 2026-07-19 note). | — | ⛔ needs hardware | -| 7 | **#90 LuckRing / TK18** | Protocol layer is on `main` (`57e1e23`). Same position as #82: no known code gap, never validated against a real TK18. | — | ⛔ needs hardware | -| 8 | **#79 Activity Year trends** | Divide the in-progress current month by elapsed days, not a full 30/31. | S | ⛔ blocked — Android has no Activity-trends screen to fix | +| 1 | **#94 `CoachNotificationDataTrigger`** | The *actual feature* of #94 was never ported — an event-bus subscriber that runs the due check-in slot when a sync completes, recovering a slot skipped for stale data. What shipped was the window constant mistaken for it (and its regression, since fixed in `8df67b1`). | M | ✅ start now | +| 2 | **Workout pause intervals** | `activity_events` is never written on Android, so Strava TCX can't drop paused trackpoints. `totalPauseSeconds` is already honoured — this is the per-interval detail only. | S–M | ✅ start now | +| 3 | **#96 nutrition subset** | `food_products`/`FoodProductDao`/`CachedFoodProductEntity` exist but **nothing can populate them**: no Open Food Facts client, no barcode scanner, no AI photo analysis, no coach `log_meal` tool. The ledger row is corrected to ADAPT (subset); this is the rest of it. | L | ✅ start now | +| 4 | **#130 RWfit — finish the JieLi `0xAB` path** | The vendor rebuild landed on `main`. The legacy `0x7E` path is complete; the JieLi `0xAB` framing is complete but **its history bodies are not decoded yet**. Read `decompiled-rwfit-official/`, never iOS and never guesswork (root `AGENTS.md`). | M | ✅ start now | +| 5 | **#82 YCBT (TK5 + SmartHealth-Colmi)** | Protocol layer is on `main` (`7a941a5`, `849131d`). **No code known to be missing** — what is missing is a live connect against real hardware. If one fails, re-read `BleHelper.java`'s connect sequence: the vendor's MTU/bonding/pacing timing was deliberately *not* copied (see the 2026-07-19 note). | — | ⛔ needs hardware | +| 6 | **#90 LuckRing / TK18** | Protocol layer is on `main` (`57e1e23`). Same position as #82: no known code gap, never validated against a real TK18. | — | ⛔ needs hardware | +| 7 | **#79 Activity Year trends** | Divide the in-progress current month by elapsed days, not a full 30/31. | S | ⛔ blocked — Android has no Activity-trends screen to fix | ### Not on this list, and why @@ -166,7 +165,7 @@ seeded-data mode). **SKIP** — no portable behavior, Android has its own indepe | ☑ | [#130](https://github.com/saksham2001/PulseLoopiOS/pull/130) `cf5c0f4` | ~08-04 | RWfit ring family (dual 0x7E/0xAB protocol, full metric set, service-UUID recognition) | **ADAPT** | L–XL | Backed out of PR #45, then **rebuilt from `decompiled-rwfit-official/`** on `feat/rwfit-vendor-rebuild`. Legacy `0x7E` path complete; JieLi `0xAB` framing complete but its history bodies are not decoded yet. **No hardware validation.** See below. | | ☑ | [#131](https://github.com/saksham2001/PulseLoopiOS/pull/131) `88c0f6b` | ~08-08 | Sleep hypnogram label alignment + press-and-hold stage scrubber (+ sync spinner rewrite, iOS-only) | **ADAPT** | S–M | `802789d` | | ☑ | [#80](https://github.com/saksham2001/PulseLoopiOS/pull/80) `c1275ad` | 07-11 | **Apple Health sync → Health Connect** (per-type toggles, vitals/sleep/activity/workout export, backfill choice, remove-all). Re-triaged 2026-08-09 from SKIP: the *behaviour* ports even though HealthKit doesn't. Write-only; profile import can't port (Health Connect has no DOB/sex type). Design + 7-phase plan in [`health-connect-integration.md`](health-connect-integration.md); reference implementation is `Gadgetbridge/` at the parent repo root, not iOS. Not blocked by the Play Store — the declaration form is a publishing gate, and Gadgetbridge ships this sideload-only. | **ADAPT** | XL | **Phases 0–6 complete** on `feat/health-connect-foundation` (write-only, 16 `WRITE_*` / 0 `READ_*`; lifecycle, removal, grant/revocation resets, archive-restore stamp, docs). Runtime-verified API 35. See `health-connect-integration.md` §8 | -| ☐ | [#93](https://github.com/saksham2001/PulseLoopiOS/pull/93) `439ca81` | 08-09 | **Colmi R11 CRP driver** — the iOS port *of Android's own* CRP work, so the driver itself is ALREADY-HAVE. What does not exist on Android is the **adversarial-review hardening** iOS added on top in `4d65b60`: 5 real gaps, listed in the 2026-08-22 triage note below. | **PARTIAL** (hardening only) | S–M | ☐ open — step-by-step plan in [`crp-r11-hardening-plan.md`](crp-r11-hardening-plan.md) | +| ☑ | [#93](https://github.com/saksham2001/PulseLoopiOS/pull/93) `439ca81` | 08-09 | **Colmi R11 CRP driver** — the iOS port *of Android's own* CRP work, so the driver itself is ALREADY-HAVE. The **adversarial-review hardening** iOS added on top in `4d65b60` (5 gaps, 2026-08-22 triage note below) is now ported. | **PARTIAL** (hardening only) | S–M | `c95b6e8` (2026-08-22) — fdd3 connect gate, firmware once-per-connection, day-keyed follow-up guard, validated + narrow-trim firmware string (items 4+5). See [`crp-r11-hardening-plan.md`](crp-r11-hardening-plan.md) | ## Port priority — open items (as of 2026-08-08) @@ -178,8 +177,8 @@ seeded-data mode). **SKIP** — no portable behavior, Android has its own indepe > **▶ RESUME HERE:** see [**Outstanding — the single list**](#outstanding--the-single-list) above. > It consolidates every open thread that used to be split across this block, the port queue and the -> session notes. Top of the list is **#93 CRP hardening**, which has a full implementation plan in -> [`crp-r11-hardening-plan.md`](crp-r11-hardening-plan.md). +> session notes. **#93 CRP hardening is now done** (`c95b6e8`, 2026-08-22); top of the list is now +> **#94 `CoachNotificationDataTrigger`**. > > Next triage after those: `git -C log --first-parent --oneline 439ca81..main`. > @@ -504,8 +503,8 @@ frame assembler across reconnects, gate connect on fdd3"), an adversarial review Five of its eight findings apply here; three do not, for reasons worth recording so nobody re-ports them. -**Port these five (☐ open).** Step-by-step instructions, with the Kotlin and the tests, are in -[`crp-r11-hardening-plan.md`](crp-r11-hardening-plan.md); this list is the summary. +**Port these five (☑ done 2026-08-22, `c95b6e8`).** Step-by-step instructions, with the Kotlin and +the tests, are in [`crp-r11-hardening-plan.md`](crp-r11-hardening-plan.md); this list is the summary. 1. **`CONNECTED` fires before the reply channel is live.** `CRPDriver` doesn't override `requiredSubscriptionsBeforeConnected` (only `YCBTDriver` does), so the connection counts as up From 9d43227ed1de9777a91f218933135c31623d6d90 Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Sat, 22 Aug 2026 14:21:23 -0700 Subject: [PATCH 08/22] Port iOS #94 CoachNotificationDataTrigger (run due slot on sync completion) The actual feature of #94 was never ported -- only its stale-data window constant was. Add the missing event-bus subscriber so a check-in slot the periodic worker skipped as SkippedStaleData is delivered the moment a full sync completes, instead of being lost until the next day. - CoachNotificationSlotRunner: a single shared runDueSlot (companion-level in-flight AtomicBoolean guard, (dateKey,slotRaw) dedupe, enabled + morning sleep gates, freshness stage returning Boolean, generate/record/deliver) with the worker's due-slot body extracted into a production engine. The Outcome sealed class mirrors iOS (Sent, SkippedNoSlot, SkippedDuplicate, SkippedDisabled, SkippedNoSleepData, SkippedStaleData, SkippedNoData). - CoachNotificationDataTrigger: bus subscriber -- SyncProgress("done") -> 3s settle debounce -> runDueSlot. Owns no slot/dedupe/freshness logic, like iOS. - Worker is now a thin wrapper over the runner (keeps its fire-time opt-in re-check). fallbackToForcedSlot keeps the 24h periodic delivering a daily check-in even when the cycle lands outside a slot window (Android's periodic is not scheduled in-window the way iOS's scheduler is); the data trigger stays strict, and the shared dedupe stops either from double-sending. - Room migration 21->22 adds dateKey/slotRaw to coach_notification_records (NOT NULL DEFAULT, composite index) so each slot is delivered exactly once. - runProactiveAlertIfNeeded omitted: no Android anomaly/proactive subsystem exists (verified by search). Tests: CoachNotificationSlotRunnerTest (9) + CoachNotificationDataTriggerTest (2, deterministic handle() contract). Self-review caught and fixed a worker out-of-window regression (added fallbackToForcedSlot) and a flaky real-bus test (dropped in favor of deterministic handle() tests). Reference: android/docs/ios-sync.md #94. --- .../com/pulseloop/data/PulseLoopDatabase.kt | 20 +- .../main/java/com/pulseloop/data/dao/Daos.kt | 6 + .../data/entity/SleepCoachEntities.kt | 18 +- .../CoachNotificationDataTrigger.kt | 97 +++ .../CoachNotificationSlotRunner.kt | 570 ++++++++++++++++++ .../notifications/CoachNotifications.kt | 323 +--------- .../java/com/pulseloop/ui/PulseLoopApp.kt | 19 + .../CoachNotificationDataTriggerTest.kt | 94 +++ .../CoachNotificationSlotRunnerTest.kt | 225 +++++++ 9 files changed, 1066 insertions(+), 306 deletions(-) create mode 100644 app/src/main/java/com/pulseloop/notifications/CoachNotificationDataTrigger.kt create mode 100644 app/src/main/java/com/pulseloop/notifications/CoachNotificationSlotRunner.kt create mode 100644 app/src/test/java/com/pulseloop/notifications/CoachNotificationDataTriggerTest.kt create mode 100644 app/src/test/java/com/pulseloop/notifications/CoachNotificationSlotRunnerTest.kt diff --git a/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt b/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt index accfbaaa..df881d9d 100644 --- a/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt +++ b/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt @@ -43,7 +43,7 @@ import com.pulseloop.data.entity.* MealEntryEntity::class, CachedFoodProductEntity::class, ], - version = 21, + version = 22, exportSchema = false, ) abstract class PulseLoopDatabase : RoomDatabase() { @@ -408,6 +408,23 @@ abstract class PulseLoopDatabase : RoomDatabase() { } } + /** + * v21 -> v22: per-day/slot dedupe on delivered check-ins (iOS #94 + * CoachNotificationDataTrigger). The data trigger re-runs the due slot when a + * full sync completes, so both the periodic worker and the trigger need a shared + * "did this slot already fire today?" lookup — dateKey (local epoch day) + slotRaw + * (lowercase slot name) is that key. Both columns are NOT NULL with defaults so + * pre-#94 rows (and archive restores, which don't carry the fields) get 0/"" and + * can never match a real dedupe query. The composite index backs the EXISTS check. + */ + private val MIGRATION_21_22 = object : Migration(21, 22) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `coach_notification_records` ADD COLUMN `dateKey` INTEGER NOT NULL DEFAULT 0") + db.execSQL("ALTER TABLE `coach_notification_records` ADD COLUMN `slotRaw` TEXT NOT NULL DEFAULT ''") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_coach_notification_records_dateKey_slotRaw` ON `coach_notification_records` (`dateKey`, `slotRaw`)") + } + } + private fun adoptStableMeasurementIdentities(db: SupportSQLiteDatabase) { db.execSQL("DROP INDEX IF EXISTS `index_measurements_kindRaw_timestamp_sourceRaw`") db.execSQL( @@ -495,6 +512,7 @@ abstract class PulseLoopDatabase : RoomDatabase() { MIGRATION_18_19, MIGRATION_19_20, MIGRATION_20_21, + MIGRATION_21_22, ) // Downgrades only (sideloading an older APK). A blanket destructive // fallback would silently wipe every measurement, sleep session, and diff --git a/app/src/main/java/com/pulseloop/data/dao/Daos.kt b/app/src/main/java/com/pulseloop/data/dao/Daos.kt index 8303f16f..b1282484 100644 --- a/app/src/main/java/com/pulseloop/data/dao/Daos.kt +++ b/app/src/main/java/com/pulseloop/data/dao/Daos.kt @@ -500,6 +500,12 @@ interface CoachNotificationRecordDao { @Query("SELECT * FROM coach_notification_records ORDER BY createdAt DESC LIMIT :limit") suspend fun recent(limit: Int = 6): List + /** Whether a check-in was already recorded for (day, slot) — the iOS #94 dedupe + * that makes the periodic worker and the sync-completion data trigger safe to + * both run the due slot without double-sending. */ + @Query("SELECT EXISTS(SELECT 1 FROM coach_notification_records WHERE dateKey = :dateKey AND slotRaw = :slotRaw)") + suspend fun existsForDateKeyAndSlot(dateKey: Long, slotRaw: String): Boolean + @Query("DELETE FROM coach_notification_records") suspend fun clear() } diff --git a/app/src/main/java/com/pulseloop/data/entity/SleepCoachEntities.kt b/app/src/main/java/com/pulseloop/data/entity/SleepCoachEntities.kt index 2ea0dea6..278aacc6 100644 --- a/app/src/main/java/com/pulseloop/data/entity/SleepCoachEntities.kt +++ b/app/src/main/java/com/pulseloop/data/entity/SleepCoachEntities.kt @@ -215,13 +215,25 @@ data class CoachSummaryEntity( /** * Ported from [CoachNotificationRecord] in CoachNotificationModels.swift (iOS * #65's `recentNotificationTexts()` reader — the record itself predates #65). - * A delivered daily check-in, kept purely so the generator can avoid repeating - * its own recent phrasing/openings (iOS #65 anti-repeat hint). + * A delivered daily check-in, kept so the generator can avoid repeating its own + * recent phrasing/openings (iOS #65 anti-repeat hint) — and (iOS #94) to enforce + * the once-per-slot-per-day cap: [dateKey] + [slotRaw] is the dedupe key the + * periodic worker and the sync-completion data trigger both check before sending. */ -@Entity(tableName = "coach_notification_records", indices = [Index("createdAt")]) +@Entity( + tableName = "coach_notification_records", + indices = [Index("createdAt"), Index("dateKey", "slotRaw")], +) data class CoachNotificationRecordEntity( @PrimaryKey val id: String = java.util.UUID.randomUUID().toString(), val title: String, val body: String, val createdAt: Long = System.currentTimeMillis(), + /** Local-timezone epoch day the check-in was delivered — the per-day dedupe key + * (iOS #94; the INTEGER analog of iOS's "yyyy-MM-dd" string dateKey). 0 = unknown: + * pre-#94 rows and archive restores, which can never match a real dedupe lookup. */ + val dateKey: Long = 0, + /** Lowercase slot name ("morning"/"evening") — iOS's `slot.rawValue`. "" = unknown + * (pre-#94 rows). */ + val slotRaw: String = "", ) diff --git a/app/src/main/java/com/pulseloop/notifications/CoachNotificationDataTrigger.kt b/app/src/main/java/com/pulseloop/notifications/CoachNotificationDataTrigger.kt new file mode 100644 index 00000000..718a8b76 --- /dev/null +++ b/app/src/main/java/com/pulseloop/notifications/CoachNotificationDataTrigger.kt @@ -0,0 +1,97 @@ +package com.pulseloop.notifications + +import android.content.Context +import com.pulseloop.ring.PulseEvent +import com.pulseloop.ring.PulseEventBus +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * Ported from CoachNotificationDataTrigger.swift (iOS #94). + * + * Fires the coach check-in the moment fresh data actually lands, instead of + * only at the scheduled wake. Subscribes to [PulseEventBus] and, when a full + * history sync completes (SyncProgress stage "done"), runs the due slot — so a + * slot the periodic worker skipped as SkippedStaleData (ring out of range, sync + * didn't finish in budget) is delivered right after the sync lands, while the + * numbers are minutes old. + * + * Deliberately owns no slot/dedupe/freshness logic: + * [CoachNotificationSlotRunner.runDueSlot] gates everything. A sync that + * completes outside a slot window is SkippedNoSlot (silence), an already-sent + * slot is SkippedDuplicate, and the runner's static in-flight guard covers a + * race with a concurrently running worker. Lives for the app lifetime, like + * [com.pulseloop.coach.summaries.CoachSummaryCoordinator]. + */ +class CoachNotificationDataTrigger( + /** Needed only when the production runner is used (the default [runDueSlot]). */ + private val context: Context? = null, + /** The opt-in slice the pre-check reads (iOS runAfterSync's settings check). */ + private val checkinSettings: () -> CoachCheckinSettings, + /** The due-slot runner to nudge on sync completion. Injectable for tests; + * the default is the shared production runner. */ + private val runDueSlot: (suspend () -> CoachNotificationOutcome)? = null, +) { + private val slotRun: suspend () -> CoachNotificationOutcome = + runDueSlot ?: { + val c = requireNotNull(context) { + "CoachNotificationDataTrigger needs a Context when the production runner is used" + } + CoachNotificationSlotRunner.forContext(c).runDueSlot() + } + + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + private var streamJob: Job? = null + private var debounceJob: Job? = null + + /** + * Short settle window after the "done" event: bus fan-out order between + * subscribers isn't guaranteed, so this lets [com.pulseloop.service.EventPersistenceSubscriber] + * stamp Device.lastFullSyncAt (what the freshness gate reads) before the slot + * runs — and coalesces back-to-back completions into one attempt. + */ + private val debounceMs = 3_000L + + fun start() { + if (streamJob != null) return + streamJob = scope.launch { + PulseEventBus.events.collect { event -> handle(event) } + } + } + + fun stop() { + streamJob?.cancel(); streamJob = null + debounceJob?.cancel(); debounceJob = null + } + + fun destroy() { + stop() + scope.cancel() + } + + /** Internal for unit tests (friend source set): the event filter + debounce + * are the whole of this class, and the bus itself is covered separately by + * PulseEventBusTest. */ + internal fun handle(event: PulseEvent) { + if (event !is PulseEvent.SyncProgress || event.stage != DONE_STAGE) return + debounceJob?.cancel() + debounceJob = scope.launch { + delay(debounceMs) + // Pre-check (iOS runAfterSync): a disabled feature shouldn't wake the runner + // on every sync-completion a re-linked ring produces. + val s = checkinSettings() + if (!s.coachEnabled || !s.notificationsEnabled) return@launch + slotRun() + } + } + + companion object { + /** The SyncProgress stage that means a full history sync COMPLETED. */ + const val DONE_STAGE = "done" + } +} diff --git a/app/src/main/java/com/pulseloop/notifications/CoachNotificationSlotRunner.kt b/app/src/main/java/com/pulseloop/notifications/CoachNotificationSlotRunner.kt new file mode 100644 index 00000000..1702c727 --- /dev/null +++ b/app/src/main/java/com/pulseloop/notifications/CoachNotificationSlotRunner.kt @@ -0,0 +1,570 @@ +package com.pulseloop.notifications + +import android.content.Context +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ProcessLifecycleOwner +import com.pulseloop.coach.config.CoachSleepSyncGate +import com.pulseloop.coach.config.CoachVarietyHints +import com.pulseloop.coach.context.WeatherContextService +import com.pulseloop.coach.openai.OpenAIResponsesClient +import com.pulseloop.data.PulseLoopDatabase +import com.pulseloop.data.dao.CoachNotificationRecordDao +import com.pulseloop.data.entity.CoachNotificationRecordEntity +import com.pulseloop.data.entity.DeviceEntity +import com.pulseloop.ring.PulseEvent +import com.pulseloop.ring.PulseEventBus +import com.pulseloop.ring.RingBLEClient +import com.pulseloop.ring.RingConnectionState +import com.pulseloop.service.loadPersistedMeasurementSettings +import com.pulseloop.service.loadPersistedUserProfile +import com.pulseloop.settings.ApiKeyStore +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import java.time.Instant +import java.time.ZoneId +import java.util.concurrent.atomic.AtomicBoolean + +/** + * The result of one due-slot attempt — ported from the `Outcome` cases of + * CoachNotificationService.swift (iOS #94). The two results this port's data + * trigger exists to recover are [SkippedStaleData] and [SkippedNoSleepData]: + * neither is recorded, so a slot skipped for lack of data can still be + * delivered by a later trigger (a completed sync, a +45min retry, or the next + * periodic run). [SkippedDuplicate] is the other half of the contract: it is + * what stops that later trigger from double-sending once a slot HAS fired. + */ +sealed class CoachNotificationOutcome { + /** A check-in was delivered for [slot]. */ + data class Sent(val slot: CoachNotificationSlot) : CoachNotificationOutcome() + /** Outside every slot window (and not forced) — silence, not an error. */ + data object SkippedNoSlot : CoachNotificationOutcome() + /** A record for (dateKey, slotRaw) already exists, or a run is in flight. */ + data object SkippedDuplicate : CoachNotificationOutcome() + /** Coach master toggle or the check-in opt-in is off. */ + data object SkippedDisabled : CoachNotificationOutcome() + /** Morning slot due but last night's sleep hasn't fully synced (not recorded). */ + data object SkippedNoSleepData : CoachNotificationOutcome() + /** Slot due but the data is stale and couldn't be refreshed in time (not recorded). */ + data object SkippedStaleData : CoachNotificationOutcome() + /** Empty store under [CoachStaleDataPolicy.SEND_WITH_LAST_KNOWN]. */ + data object SkippedNoData : CoachNotificationOutcome() +} + +/** + * What to do when a pre-notification sync can't produce fresh data in time — + * ported from `StaleDataPolicy` (iOS #94). [SKIP] stays quiet and leaves the + * slot unrecorded so the data trigger (iOS #94) can fire it the moment a + * background sync completes — a check-in built on this morning's numbers at 8pm + * is worse than a late one. [SEND_WITH_LAST_KNOWN] (the pre-#94 behavior) sends + * anyway; kept for tests and as an escape hatch. + */ +enum class CoachStaleDataPolicy { SKIP, SEND_WITH_LAST_KNOWN } + +/** + * The slice of [ApiKeyStore] the due-slot run reads, snapshotted at run start + * (iOS `settingsStore.settings`). A plain value so the runner — and the data + * trigger's pre-check — can be unit-tested without EncryptedSharedPreferences. + */ +data class CoachCheckinSettings( + val coachEnabled: Boolean, + val notificationsEnabled: Boolean, + val apiKey: String, + val model: String, + val morningHour: Int = 8, + val eveningHour: Int = 20, +) + +/** + * The single shared runner for the daily check-in due slot — ported from the + * body of CoachNotificationService.runDueSlot (iOS #94). + * + * Both entry points call this SAME code, which is what keeps them from + * double-sending: the 24h WorkManager worker (Android's analog of the iOS + * BGTask) and [CoachNotificationDataTrigger] (runs the slot when a full history + * sync completes). Gating order mirrors the iOS service exactly: + * + * 1. static in-flight guard → [CoachNotificationOutcome.SkippedDuplicate] + * 2. slot window (force or fallbackToForcedSlot falls back to [CoachNotificationSlot.forcedSlot]) + * → [CoachNotificationOutcome.SkippedNoSlot] + * 3. dedupe: a record exists for (dateKey, slotRaw) → SkippedDuplicate + * 4. coach + check-in opt-in enabled → SkippedDisabled + * 5. morning && last night's sleep not fully synced → SkippedNoSleepData + * (NOT recorded, so a later trigger can fire it) + * 6. freshness: ensureFreshData reports whether the store now holds recent data; + * stale + policy SKIP → SkippedStaleData (NOT recorded — this is the exact + * outcome the data trigger exists to recover); stale + SEND_WITH_LAST_KNOWN + * → proceed only if a latest measurement exists, else SkippedNoData + * 7. generate (AI, deterministic fallback), record, deliver → Sent + * + * The dedupe in (3) is what makes worker + data trigger safe to both call it: + * once a slot is recorded for (dateKey, slotRaw), every later same-day attempt + * from either entry point is SkippedDuplicate. + */ +class CoachNotificationSlotRunner( + private val settings: () -> CoachCheckinSettings, + private val recordDao: CoachNotificationRecordDao, + /** End timestamp of the most recent sleep session (iOS sleepDataSynced's session leg). */ + private val latestSleepSessionEndAt: suspend () -> Long? = { null }, + /** The current device row's lastFullSyncAt stamp (iOS sleepDataSynced's sync leg). */ + private val currentDeviceFullSyncAt: suspend () -> Long? = { null }, + /** Newest measurement of any kind — iOS `latestMeasurementTimestamp()`. */ + private val latestMeasurementTimestamp: suspend () -> Long? = { null }, + private val staleDataPolicy: CoachStaleDataPolicy = CoachStaleDataPolicy.SKIP, + /** The freshness stage (iOS ensureFreshData): bounded connect-and-sync, then a + * re-check. Returns whether the store now holds recent data. */ + private val ensureFreshData: suspend (Long) -> Boolean, + /** Packet build + generation (AI, deterministic fallback) for [slot] at [now]. */ + private val generate: suspend (CoachNotificationSlot, Long) -> CoachNotificationContent, + /** Delivery (local notification in production). */ + private val deliver: (String, String) -> Unit, + /** iOS #65's +45min retry: fired when the morning slot is blocked on sleep data. */ + private val onSleepRetryNeeded: () -> Unit = {}, + private val clock: () -> Long = { System.currentTimeMillis() }, +) { + + suspend fun runDueSlot( + force: Boolean = false, + fallbackToForcedSlot: Boolean = false, + now: Long = clock(), + ): CoachNotificationOutcome { + if (!runInFlight.compareAndSet(false, true)) return CoachNotificationOutcome.SkippedDuplicate + var resolvedSlot: CoachNotificationSlot? = null + try { + val s = settings() + val hour = hourOf(now) + + // (2) Slot window — iOS: current() ?: (force ? forcedSlot(now) : nil). A sync that + // completes outside a window is SkippedNoSlot (silence). The data trigger relies on + // this strictness: it only ever fires the slot that is actually due. The periodic + // worker passes fallbackToForcedSlot because Android's 24h WorkManager periodic fires + // wherever the cycle lands (it is NOT scheduled inside a slot window the way iOS's + // CoachNotificationScheduler is), so without the fallback an out-of-window fire would + // silently drop the day's check-in. The dedupe below still makes it safe to combine + // with the trigger (no double-send). + val slot = CoachNotificationSlot.current(hour, s.morningHour, s.eveningHour) + ?: if (force || fallbackToForcedSlot) CoachNotificationSlot.forcedSlot(hour) + else return CoachNotificationOutcome.SkippedNoSlot + resolvedSlot = slot + + // (3) Per-day/slot dedupe (iOS isDuplicate): a record for (dateKey, slotRaw) + // means this slot already fired today — from the worker or the data trigger. + if (!force && recordDao.existsForDateKeyAndSlot(dateKeyFor(now), slotRaw(slot))) { + return CoachNotificationOutcome.SkippedDuplicate + } + + // (4) Enabled gate — iOS: force || flags.coachEnabled. + if (!force && (!s.coachEnabled || !s.notificationsEnabled)) { + return CoachNotificationOutcome.SkippedDisabled + } + + // Coach is on but no API key: the user still opted into check-ins, so fall back + // to the generic scripted text (pre-extraction worker behavior, kept). Recorded + // like any other delivery so the data trigger can't double-send the same slot a + // few minutes later — the pre-extraction worker skipped this record, which was + // exactly how a second send could slip through. + if (s.apiKey.isBlank()) { + deliver(GENERIC_TITLE, GENERIC_BODY) + recordDao.insert( + CoachNotificationRecordEntity( + title = GENERIC_TITLE, + body = GENERIC_BODY, + dateKey = dateKeyFor(now), + slotRaw = slotRaw(slot), + ) + ) + return CoachNotificationOutcome.Sent(slot) + } + + // (5) Morning-only (iOS #65): don't fire until last night's sleep has fully + // synced — otherwise the check-in leads with partial/absent sleep. Skip WITHOUT + // recording so a +45min retry (or the data trigger) can fire it once synced. + if (!force && slot == CoachNotificationSlot.MORNING && !sleepDataSynced(now)) { + onSleepRetryNeeded() + return CoachNotificationOutcome.SkippedNoSleepData + } + + // (6) Sync-before-notify (iOS #61c/#94): a bounded, best-effort refresh. Stale + // data that can't be refreshed in time → skip WITHOUT recording (a check-in + // built on yesterday's numbers is worse than a late one), so the data trigger + // or a retry fires the slot once fresh data lands. + if (!force) { + val fresh = ensureFreshData(now) + if (!fresh) { + if (staleDataPolicy == CoachStaleDataPolicy.SKIP) { + return CoachNotificationOutcome.SkippedStaleData + } + // sendWithLastKnown: proceed, but never with a totally empty store. + if (latestMeasurementTimestamp() == null) return CoachNotificationOutcome.SkippedNoData + } + } + + // (7) Generate (AI, deterministic fallback), record, deliver. + val notification = generate(slot, now) + recordDao.insert( + CoachNotificationRecordEntity( + title = notification.title, + body = notification.body, + dateKey = dateKeyFor(now), + slotRaw = slotRaw(slot), + ) + ) + deliver(notification.title, notification.body) + return CoachNotificationOutcome.Sent(slot) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // Ultimate fallback — pre-extraction worker behavior: even when everything + // else blows up, the user still gets the generic check-in. + runCatching { deliver(GENERIC_TITLE, GENERIC_BODY) } + return CoachNotificationOutcome.Sent(resolvedSlot ?: CoachNotificationSlot.forcedSlot(hourOf(now))) + } finally { + runInFlight.set(false) + } + } + + /** + * Ported from CoachNotificationService.sleepDataSynced (iOS #65). Whether last + * night's sleep is safe to summarize in the morning check-in. See + * [CoachSleepSyncGate.sleepDataSynced] for the exact rule; this only reads the + * two timestamps it needs. + */ + private suspend fun sleepDataSynced(now: Long): Boolean = + CoachSleepSyncGate.sleepDataSynced(latestSleepSessionEndAt(), currentDeviceFullSyncAt(), now) + + companion object { + /** The generic scripted check-in (pre-extraction worker fallback text). */ + const val GENERIC_TITLE = "PulseLoop Coach" + const val GENERIC_BODY = "Good morning! Sync your ring and check your vitals to start the day." + + /** + * iOS `CoachNotificationService.runInFlight` — process-wide, because each entry + * point (the periodic worker, the sync-completion data trigger) builds its own + * runner, and generation awaits for seconds — plenty of room for a second entry + * to pass the dedupe check before the first one records. + */ + private val runInFlight = AtomicBoolean(false) + + /** + * A stable per-day dedupe key: the local-timezone epoch day of [nowMillis] + * (Android's analog of iOS's `yyyy-MM-dd` string dateKey — an INTEGER is what + * the dedupe query and its index want). + */ + fun dateKeyFor(nowMillis: Long): Long = + Instant.ofEpochMilli(nowMillis).atZone(ZoneId.systemDefault()).toLocalDate().toEpochDay() + + /** The stored slot name, lowercase — iOS's `slot.rawValue`. */ + fun slotRaw(slot: CoachNotificationSlot): String = slot.name.lowercase() + + /** Local hour-of-day of [nowMillis] — feeds the slot window. */ + fun hourOf(nowMillis: Long): Int = + Instant.ofEpochMilli(nowMillis).atZone(ZoneId.systemDefault()).hour + + /** + * Production runner shared by the worker and the data trigger: real settings, + * Room DAOs, the BLE freshness stage, LLM generation (deterministic fallback), + * and local-notification delivery. + */ + fun forContext(context: Context): CoachNotificationSlotRunner { + val appContext = context.applicationContext + val keyStore = ApiKeyStore(appContext) + val db = PulseLoopDatabase.getInstance(appContext) + val engine = CoachSlotProductionEngine(appContext, keyStore, db) + return CoachNotificationSlotRunner( + settings = { + CoachCheckinSettings( + coachEnabled = keyStore.coachEnabled, + notificationsEnabled = keyStore.notificationsEnabled, + apiKey = keyStore.apiKey, + model = keyStore.model, + morningHour = keyStore.morningHour, + eveningHour = keyStore.eveningHour, + ) + }, + recordDao = db.coachNotificationRecordDao(), + latestSleepSessionEndAt = { db.sleepSessionDao().recent(1).firstOrNull()?.endAt }, + currentDeviceFullSyncAt = { db.deviceDao().current()?.lastFullSyncAt }, + latestMeasurementTimestamp = { db.measurementDao().latestTimestamp() }, + ensureFreshData = engine::ensureFreshData, + generate = engine::generate, + deliver = engine::deliver, + onSleepRetryNeeded = { CoachNotifications.scheduleSleepRetry(appContext) }, + ) + } + } +} + +/** + * The production halves of the runner — everything Android-specific that the pure + * gate logic in [CoachNotificationSlotRunner] delegates: the bounded + * connect-and-sync freshness stage (moved from the pre-extraction + * CoachNotificationWorker), packet build + LLM generation, and local-notification + * delivery. Kept as a private class so the runner stays unit-testable with fakes. + */ +private class CoachSlotProductionEngine( + private val context: Context, + private val keyStore: ApiKeyStore, + private val db: PulseLoopDatabase, +) { + + /** + * Ported from CoachNotificationService.ensureFreshData (iOS #61c), now reporting + * freshness (iOS `return hasRecentData(now:)`) so the runner can apply the #94 + * stale-data skip. This run always owns a private [RingBLEClient] (unlike the + * foreground [com.pulseloop.service.RingSyncCoordinator]), so there's no "sync + * already in flight" to await — only connect-and-sync, bounded by + * [SYNC_WAIT_TIMEOUT_MS] so a stale link can never hang the run past its own + * budget. Skips outright when no real ring is paired, or the last completed sync + * is still fresh. + * + * There was once a STALE_DATA_WINDOW_MS (1h) window here, added for iOS #94. It + * could never be false: it was evaluated only *after* the 3h RECENT_DATA_WINDOW_MS + * early-return above, so `now - latestMeasurementAt` was already ≥ 3h by the time + * it ran. Wiring it into the foreground check therefore deleted that guard + * outright, letting this run open a second transient GATT client while the + * foreground app held the link — the exact thing the comment below says iOS never + * does. iOS #94's real contribution is [CoachNotificationDataTrigger]: it runs + * the due slot when a sync *completes*, so a slot skipped for stale data is + * delivered a few minutes later instead of being lost. + */ + suspend fun ensureFreshData(now: Long): Boolean { + val device = db.deviceDao().currentReal() ?: return hasRecentData(null, now) + val fresh = device.lastFullSyncAt?.let { now - it < FRESH_SYNC_WINDOW_MS } ?: false + if (fresh) return true + + // iOS `hasRecentData`: a fresh *live* measurement is as good as a completed sync + // (covers jring, which streams samples continuously rather than running a paged + // history sync) — skip the forced connect + full runStartup iOS deliberately + // avoids paying at every check-in. + val latestMeasurementAt = db.measurementDao().latestTimestamp() + if (latestMeasurementAt != null && now - latestMeasurementAt < RECENT_DATA_WINDOW_MS) return true + + // iOS branches on the app's *shared* coordinator and never opens a second + // client: when the ring is already connected (the foreground app holding the + // link — its CONNECTED event is what stamps this state), just give any + // in-flight sync a bounded chance to land. Opening our own GATT here would wipe + // the sleep tables under the user (the CONNECTED event rebuilds them), + // duplicate every decode into the shared bus, and interleave two sync + // engines' history commands on one link. + if (device.stateRaw == "CONNECTED") { + awaitSyncDone() + return hasRecentData(device, now) + } + if (isAppForeground()) return hasRecentData(device, now) + + val bleClient = RingBLEClient(context, transientOwner = true) + if (!bleClient.hasPermissions()) { + // destroy(), not just drop the reference: the client's init-started + // connection watchdog would otherwise keep firing into permission-less + // connect attempts. + bleClient.destroy() + return hasRecentData(device, now) + } + + val measurementSettings = loadPersistedMeasurementSettings(db) + val profileValues = loadPersistedUserProfile(db, keyStore) + + try { + withTimeoutOrNull(SYNC_WAIT_TIMEOUT_MS) { + val doneSignal = async { + PulseEventBus.events.filterIsInstance().first { it.stage == "done" } + } + bleClient.onConnected = { + val engine = bleClient.syncEngine + engine?.setMeasurementSettings(measurementSettings) + profileValues?.let { engine?.setUserProfile(it) } + engine?.runStartup() + } + bleClient.connectLastKnown() + while (!doneSignal.isCompleted && !isAppForeground()) delay(500) + if (isAppForeground()) { + doneSignal.cancel() + return@withTimeoutOrNull + } + doneSignal.await() + } + } finally { + // destroy(), not disconnect(): the client's connection watchdog (started in + // init) survives disconnect() and re-attaches the ring ~15s after the run + // exits — re-firing onConnected → a full runStartup, then holding the ring + // with no UI. + val releasedConnection = bleClient.destroy() + if (releasedConnection && !isAppForeground()) { + PulseEventBus.publishBlocking( + PulseEvent.DeviceStateChanged( + RingConnectionState.DISCONNECTED, + null, + ) + ) + } + } + // Re-read the device row: the sync we just ran may have stamped lastFullSyncAt + // while we were waiting (EventPersistenceSubscriber writes it on "done"). + return hasRecentData(db.deviceDao().currentReal(), now) + } + + /** + * iOS `hasRecentData` — the store holds data inside the freshness window (3h): a + * completed full sync, or a recent live measurement (covers streaming rings). + * Gates on lastFullSyncAt, NOT lastSyncAt — the latter is re-stamped on every bare + * CONNECT before any data streams (iOS #61c's freshness-gate fix). + */ + private suspend fun hasRecentData(device: DeviceEntity?, now: Long): Boolean { + device?.lastFullSyncAt?.let { if (now - it < RECENT_DATA_WINDOW_MS) return true } + val latest = db.measurementDao().latestTimestamp() + return latest != null && now - latest < RECENT_DATA_WINDOW_MS + } + + private suspend fun isAppForeground(): Boolean = withContext(Dispatchers.Main.immediate) { + ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) + } + + /** Give an in-flight sync (driven by whoever owns the live link) a bounded chance to finish. */ + private suspend fun awaitSyncDone() { + withTimeoutOrNull(SYNC_WAIT_TIMEOUT_MS) { + PulseEventBus.events.filterIsInstance().first { it.stage == "done" } + } + } + + /** + * Packet build + generation (moved from the pre-extraction worker). The weather + * service degrades to a cached (or null) reading on its own when the app isn't + * foregrounded — see WeatherContextService — so it's always safe to call from a + * background entry point. + */ + suspend fun generate(slot: CoachNotificationSlot, now: Long): CoachNotificationContent { + val environment = WeatherContextService(context).snapshot() + val packet = NotificationContextBuilder.build(slot, db, now, environment = environment) + + // Variety + anti-repeat (iOS #65): a deterministic per-day/slot coaching angle, + // plus the last few delivered check-ins so the model doesn't repeat itself. + // (The seed keeps the pre-extraction worker's exact "yyyy-MM-dd" shape so + // the angle stream doesn't shift.) + val angle = CoachVarietyHints.angle(localDateKey(now) + slot.name.lowercase()) + val recentTexts = db.coachNotificationRecordDao().recent(6).map { "${it.title} — ${it.body}" } + + // Generate via AI or fallback + return try { + generateWithAI(slot, packet, keyStore.apiKey, keyStore.model, angle, recentTexts) + } catch (e: Exception) { + scripted(slot, packet) + } + } + + /** Local "yyyy-MM-dd" of [nowMillis] — the pre-extraction worker's angle-seed shape. */ + private fun localDateKey(nowMillis: Long): String = + Instant.ofEpochMilli(nowMillis).atZone(ZoneId.systemDefault()).toLocalDate().toString() + + fun deliver(title: String, body: String) { + CoachNotifications.showNow(context, title, body) + } + + private suspend fun generateWithAI( + slot: CoachNotificationSlot, + packet: NotificationContextPacket, + apiKey: String, + model: String, + angle: String = "", + recentTexts: List = emptyList(), + ): CoachNotificationContent { + val client = OpenAIResponsesClient(apiKey) + + val input = JsonArray(listOf( + JsonObject(mapOf( + "role" to JsonPrimitive("system"), + "content" to JsonPrimitive(NotificationPromptBuilder.systemPrompt(slot)), + )), + JsonObject(mapOf( + "role" to JsonPrimitive("developer"), + "content" to JsonPrimitive(NotificationPromptBuilder.developerMessage(packet, angle, recentTexts)), + )), + )) + + val schemaProps = JsonObject(mapOf( + "title" to JsonObject(mapOf("type" to JsonPrimitive("string"), "maxLength" to JsonPrimitive(50))), + "body" to JsonObject(mapOf("type" to JsonPrimitive("string"), "maxLength" to JsonPrimitive(160))), + )) + val schema = JsonObject(mapOf( + "type" to JsonPrimitive("object"), + "properties" to schemaProps, + "required" to JsonArray(listOf(JsonPrimitive("title"), JsonPrimitive("body"))), + "additionalProperties" to JsonPrimitive(false), + )) + val format = JsonObject(mapOf( + "type" to JsonPrimitive("json_schema"), + "name" to JsonPrimitive("coach_notification"), + "schema" to schema, + "strict" to JsonPrimitive(true), + )) + val text = JsonObject(mapOf("format" to format)) + + val requestBody = JsonObject(mapOf( + "model" to JsonPrimitive(model), + "input" to input, + "tools" to JsonArray(emptyList()), + "text" to text, + )) + + val response = client.send(requestBody.toString().toByteArray()) + val output = response.outputText + return CoachNotificationContent.decodeFromJson(output) + ?: scripted(slot, packet) + } + + /** Deterministic fallback — ported from CoachNotificationGenerator.scripted(). */ + private fun scripted(slot: CoachNotificationSlot, packet: NotificationContextPacket): CoachNotificationContent { + val name = packet.profileName?.let { ", $it" } ?: "" + return when (slot) { + CoachNotificationSlot.MORNING -> { + val sleep = packet.latestSleep + if (sleep != null) { + val h = sleep.totalMin / 60 + val m = sleep.totalMin % 60 + CoachNotificationContent( + title = "Good morning$name", + body = "You logged ${h}h ${m}m of sleep. Here's to a strong day — get moving when you can.", + ) + } else { + CoachNotificationContent( + title = "Good morning$name", + body = "Ready to start the day? Take a measurement and I'll help you plan it.", + ) + } + } + CoachNotificationSlot.EVENING -> { + val steps = packet.today.steps + if (steps != null) { + val goal = packet.goals.stepsDaily + val hit = if (steps >= goal) "You hit your $goal step goal — nice work." else "${goal - steps} steps to your goal." + CoachNotificationContent( + title = "Evening check-in", + body = "$steps steps today. $hit Time to start winding down.", + ) + } else { + CoachNotificationContent( + title = "Evening check-in", + body = "How did today feel? Sync your ring and I'll recap your day.", + ) + } + } + } + } + + companion object { + /** iOS #61c `syncWaitTimeout` — caps ensureFreshData so a stale BLE link can't hang the run. */ + private const val SYNC_WAIT_TIMEOUT_MS = 15_000L + /** iOS #61c `hasFreshFullSync` — a completed sync within this window skips a new one. */ + private const val FRESH_SYNC_WINDOW_MS = 10 * 60_000L + /** iOS `freshnessWindow` (3h) — a live measurement this recent counts as fresh data even + * without a completed full sync (covers rings that stream continuously). */ + private const val RECENT_DATA_WINDOW_MS = 3 * 60 * 60_000L + } +} diff --git a/app/src/main/java/com/pulseloop/notifications/CoachNotifications.kt b/app/src/main/java/com/pulseloop/notifications/CoachNotifications.kt index bdc1791f..b5d73387 100644 --- a/app/src/main/java/com/pulseloop/notifications/CoachNotifications.kt +++ b/app/src/main/java/com/pulseloop/notifications/CoachNotifications.kt @@ -8,34 +8,13 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.os.Build +import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.ProcessLifecycleOwner import androidx.work.* import com.pulseloop.MainActivity -import com.pulseloop.coach.config.CoachSleepSyncGate -import com.pulseloop.coach.config.CoachVarietyHints -import com.pulseloop.coach.openai.OpenAIResponsesClient -import com.pulseloop.data.PulseLoopDatabase -import com.pulseloop.data.entity.CoachNotificationRecordEntity -import com.pulseloop.ring.PulseEvent -import com.pulseloop.ring.PulseEventBus -import com.pulseloop.ring.RingBLEClient -import com.pulseloop.ring.RingConnectionState -import com.pulseloop.service.loadPersistedMeasurementSettings -import com.pulseloop.service.loadPersistedUserProfile import com.pulseloop.settings.ApiKeyStore -import kotlinx.coroutines.async -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull -import kotlinx.serialization.json.* -import java.time.LocalDateTime import java.util.concurrent.TimeUnit /** @@ -103,7 +82,7 @@ object CoachNotifications { /** * One +45min one-off wake to retry a morning check-in that was skipped because - * last night's sleep hadn't synced yet (iOS #65 `submitSleepRetry`). Best-effort; + * last night's sleep hadn't synced yet (iOS #65 submitSleepRetry). Best-effort; * the next periodic run also covers the case where this doesn't land. */ fun scheduleSleepRetry(context: Context) { @@ -142,9 +121,13 @@ object CoachNotifications { } /** - * WorkManager worker for generating daily coach check-in notifications. - * Ported from CoachNotificationGenerator.swift — generates AI-powered - * personalized notifications via OpenAI, with a deterministic fallback. + * WorkManager worker for the daily coach check-in notification — the Android + * analog of the iOS BGTask (CoachNotificationScheduler). The entire due-slot + * body (gates, freshness, generation, record, delivery) lives in + * [CoachNotificationSlotRunner] — the SAME code the sync-completion data + * trigger (iOS #94) calls, so the two entry points can never double-send. + * This worker only re-checks the opt-in at fire time and maps the outcome to + * a WorkManager result. */ class CoachNotificationWorker( context: Context, @@ -152,24 +135,7 @@ class CoachNotificationWorker( ) : CoroutineWorker(context, params) { companion object { - /** iOS #61c `syncWaitTimeout` — caps ensureFreshData so a stale BLE link can't hang the worker. */ - private const val SYNC_WAIT_TIMEOUT_MS = 15_000L - /** iOS #61c `hasFreshFullSync` — a completed sync within this window skips a new one. */ - private const val FRESH_SYNC_WINDOW_MS = 10 * 60_000L - /** iOS `freshnessWindow` (3h) — a live measurement this recent counts as fresh data even - * without a completed full sync (covers rings that stream continuously). */ - private const val RECENT_DATA_WINDOW_MS = 3 * 60 * 60_000L - // There was a STALE_DATA_WINDOW_MS (1h) here, added for iOS #94. It could never be false: - // it was evaluated only *after* the 3h RECENT_DATA_WINDOW_MS early-return above, so - // `now - latestMeasurementAt` was already ≥ 3h by the time it ran. Wiring it into the - // foreground check therefore deleted that guard outright, letting this worker open a - // second transient GATT client while the foreground app held the link — the exact thing - // the comment in ensureFreshData says iOS never does. - // - // iOS #94's real contribution is CoachNotificationDataTrigger: it runs the due slot when a - // sync *completes*, so a slot skipped for stale data is delivered a few minutes later - // instead of being lost. That is an event-bus subscriber, not a window constant, and it is - // not ported yet — see docs/ios-sync.md. + private const val TAG = "CoachNotificationWorker" } override suspend fun doWork(): Result { @@ -182,263 +148,16 @@ class CoachNotificationWorker( return Result.success() } - return try { - val db = PulseLoopDatabase.getInstance(applicationContext) - - // Coach is on but no API key: the user still opted into check-ins, so - // fall back to the generic scripted text. - if (keyStore.apiKey.isBlank()) { - CoachNotifications.showNow( - applicationContext, - "PulseLoop Coach", - "Good morning! Sync your ring and check your vitals to start the day.", - ) - return Result.success() - } - - // Determine current slot - val now = LocalDateTime.now() - val nowMillis = System.currentTimeMillis() - val hour = now.hour - val slot = CoachNotificationSlot.current(hour, keyStore.morningHour, keyStore.eveningHour) - ?: CoachNotificationSlot.forcedSlot(hour) - - // Morning-only (iOS #65): don't fire until last night's sleep has fully synced — - // otherwise the check-in leads with partial/absent sleep. Skip WITHOUT showing a - // notification so the +45min retry (or the next periodic run) can fire it once synced. - if (slot == CoachNotificationSlot.MORNING && !sleepDataSynced(db, nowMillis)) { - CoachNotifications.scheduleSleepRetry(applicationContext) - return Result.success() - } - - // Sync-before-notify (iOS #61c): a bounded, best-effort connect so the check-in - // reflects today's data instead of whatever happened to be in Room when the ring - // was last opened. Always proceeds to build+send afterward with whatever's now - // there — a stale check-in beats a missed one. - ensureFreshData(db) - - // Build context. The weather service degrades to a cached (or null) reading on - // its own when the app isn't foregrounded — see WeatherContextService — so it's - // always safe to call from this background worker. - val environment = com.pulseloop.coach.context.WeatherContextService(applicationContext).snapshot() - val packet = NotificationContextBuilder.build(slot, db, environment = environment) - - // Variety + anti-repeat (iOS #65): a deterministic per-day/slot coaching angle, - // plus the last few delivered check-ins so the model doesn't repeat itself. - val angle = CoachVarietyHints.angle(now.toLocalDate().toString() + slot.name.lowercase()) - val recentTexts = db.coachNotificationRecordDao().recent(6).map { "${it.title} — ${it.body}" } - - // Generate via AI or fallback - val notification = try { - generateWithAI(slot, packet, keyStore.apiKey, keyStore.model, angle, recentTexts) - } catch (e: Exception) { - scripted(slot, packet) - } - - db.coachNotificationRecordDao().insert( - CoachNotificationRecordEntity(title = notification.title, body = notification.body) - ) - CoachNotifications.showNow(applicationContext, notification.title, notification.body) - Result.success() - } catch (e: Exception) { - // Ultimate fallback - CoachNotifications.showNow( - applicationContext, - "PulseLoop Coach", - "Good morning! Sync your ring and check your vitals to start the day.", - ) - Result.success() - } - } - - /** - * Ported from CoachNotificationService.ensureFreshData (iOS #61c). This worker always owns a - * private [RingBLEClient] (unlike the foreground [com.pulseloop.service.RingSyncCoordinator]), - * so there's no "sync already in flight" to await — only connect-and-sync, bounded by - * [SYNC_WAIT_TIMEOUT_MS] so a stale link can never hang the worker past its own budget. - * Skips outright when no real ring is paired, or the last completed sync is still fresh. - */ - private suspend fun ensureFreshData(db: PulseLoopDatabase) { - val device = db.deviceDao().currentReal() ?: return - val now = System.currentTimeMillis() - val fresh = device.lastFullSyncAt?.let { now - it < FRESH_SYNC_WINDOW_MS } ?: false - if (fresh) return - - // iOS `hasRecentData`: a fresh *live* measurement is as good as a completed sync (covers - // jring, which streams samples continuously rather than running a paged history sync) — - // skip the forced connect + full runStartup iOS deliberately avoids paying at every - // check-in. - val latestMeasurementAt = db.measurementDao().latestTimestamp() - if (latestMeasurementAt != null && now - latestMeasurementAt < RECENT_DATA_WINDOW_MS) return - - // iOS branches on the app's *shared* coordinator and never opens a second client: when - // the ring is already connected (the foreground app holding the link — its CONNECTED - // event is what stamps this state), just give any in-flight sync a bounded chance to - // land. Opening our own GATT here would wipe the sleep tables under the user (the - // CONNECTED event rebuilds them), duplicate every decode into the shared bus, and - // interleave two sync engines' history commands on one link. - if (device.stateRaw == "CONNECTED") { - awaitSyncDone() - return - } - if (isAppForeground()) return - - val bleClient = RingBLEClient(applicationContext, transientOwner = true) - if (!bleClient.hasPermissions()) { - // destroy(), not just drop the reference: the client's init-started connection - // watchdog would otherwise keep firing into permission-less connect attempts. - bleClient.destroy() - return - } - - val measurementSettings = loadPersistedMeasurementSettings(db) - val profileValues = loadPersistedUserProfile(db, ApiKeyStore(applicationContext)) - - try { - withTimeoutOrNull(SYNC_WAIT_TIMEOUT_MS) { - val doneSignal = async { - PulseEventBus.events.filterIsInstance().first { it.stage == "done" } - } - bleClient.onConnected = { - val engine = bleClient.syncEngine - engine?.setMeasurementSettings(measurementSettings) - profileValues?.let { engine?.setUserProfile(it) } - engine?.runStartup() - } - bleClient.connectLastKnown() - while (!doneSignal.isCompleted && !isAppForeground()) delay(500) - if (isAppForeground()) { - doneSignal.cancel() - return@withTimeoutOrNull - } - doneSignal.await() - } - } finally { - // destroy(), not disconnect(): the client's connection watchdog (started in init) - // survives disconnect() and re-attaches the ring ~15s after the worker exits — - // re-firing onConnected → a full runStartup, then holding the ring with no UI. - val releasedConnection = bleClient.destroy() - if (releasedConnection && !isAppForeground()) { - PulseEventBus.publishBlocking( - PulseEvent.DeviceStateChanged( - RingConnectionState.DISCONNECTED, - null, - ) - ) - } - } - } - - private suspend fun isAppForeground(): Boolean = withContext(Dispatchers.Main.immediate) { - ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) - } - - /** Give an in-flight sync (driven by whoever owns the live link) a bounded chance to finish. */ - private suspend fun awaitSyncDone() { - withTimeoutOrNull(SYNC_WAIT_TIMEOUT_MS) { - PulseEventBus.events.filterIsInstance().first { it.stage == "done" } - } - } - - /** - * Ported from CoachNotificationService.sleepDataSynced (iOS #65). Whether last - * night's sleep is safe to summarize in the morning check-in. See - * [CoachSleepSyncGate.sleepDataSynced] for the exact rule. - */ - private suspend fun sleepDataSynced(db: PulseLoopDatabase, now: Long): Boolean { - val session = db.sleepSessionDao().recent(1).firstOrNull() - val device = db.deviceDao().current() - return CoachSleepSyncGate.sleepDataSynced(session?.endAt, device?.lastFullSyncAt, now) - } - - private suspend fun generateWithAI( - slot: CoachNotificationSlot, - packet: NotificationContextPacket, - apiKey: String, - model: String, - angle: String = "", - recentTexts: List = emptyList(), - ): CoachNotificationContent { - val client = OpenAIResponsesClient(apiKey) - - val input = JsonArray(listOf( - JsonObject(mapOf( - "role" to JsonPrimitive("system"), - "content" to JsonPrimitive(NotificationPromptBuilder.systemPrompt(slot)), - )), - JsonObject(mapOf( - "role" to JsonPrimitive("developer"), - "content" to JsonPrimitive(NotificationPromptBuilder.developerMessage(packet, angle, recentTexts)), - )), - )) - - val schemaProps = JsonObject(mapOf( - "title" to JsonObject(mapOf("type" to JsonPrimitive("string"), "maxLength" to JsonPrimitive(50))), - "body" to JsonObject(mapOf("type" to JsonPrimitive("string"), "maxLength" to JsonPrimitive(160))), - )) - val schema = JsonObject(mapOf( - "type" to JsonPrimitive("object"), - "properties" to schemaProps, - "required" to JsonArray(listOf(JsonPrimitive("title"), JsonPrimitive("body"))), - "additionalProperties" to JsonPrimitive(false), - )) - val format = JsonObject(mapOf( - "type" to JsonPrimitive("json_schema"), - "name" to JsonPrimitive("coach_notification"), - "schema" to schema, - "strict" to JsonPrimitive(true), - )) - val text = JsonObject(mapOf("format" to format)) - - val requestBody = JsonObject(mapOf( - "model" to JsonPrimitive(model), - "input" to input, - "tools" to JsonArray(emptyList()), - "text" to text, - )) - - val response = client.send(requestBody.toString().toByteArray()) - val output = response.outputText - return CoachNotificationContent.decodeFromJson(output) - ?: scripted(slot, packet) - } - - /** Deterministic fallback — ported from CoachNotificationGenerator.scripted(). */ - private fun scripted(slot: CoachNotificationSlot, packet: NotificationContextPacket): CoachNotificationContent { - val name = packet.profileName?.let { ", $it" } ?: "" - return when (slot) { - CoachNotificationSlot.MORNING -> { - val sleep = packet.latestSleep - if (sleep != null) { - val h = sleep.totalMin / 60 - val m = sleep.totalMin % 60 - CoachNotificationContent( - title = "Good morning$name", - body = "You logged ${h}h ${m}m of sleep. Here's to a strong day — get moving when you can.", - ) - } else { - CoachNotificationContent( - title = "Good morning$name", - body = "Ready to start the day? Take a measurement and I'll help you plan it.", - ) - } - } - CoachNotificationSlot.EVENING -> { - val steps = packet.today.steps - if (steps != null) { - val goal = packet.goals.stepsDaily - val hit = if (steps >= goal) "You hit your $goal step goal — nice work." else "${goal - steps} steps to your goal." - CoachNotificationContent( - title = "Evening check-in", - body = "$steps steps today. $hit Time to start winding down.", - ) - } else { - CoachNotificationContent( - title = "Evening check-in", - body = "How did today feel? Sync your ring and I'll recap your day.", - ) - } - } - } + // fallbackToForcedSlot: this is the 24h periodic, which fires wherever the cycle lands + // (not inside a slot window, unlike iOS's scheduler). Fall back to a forced slot so it + // still delivers the day's check-in; the data trigger (strict, in-window) covers the + // other slot and the shared (dateKey, slotRaw) dedupe stops either from double-sending. + val outcome = CoachNotificationSlotRunner.forContext(applicationContext) + .runDueSlot(fallbackToForcedSlot = true) + Log.i(TAG, "due slot -> $outcome") + // Every outcome is a normal completion: the skipped ones are a gate deciding + // "not now" (the data trigger, a +45min sleep retry, or the next periodic + // run picks the slot up), not a failure worth retrying. + return Result.success() } } diff --git a/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt b/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt index 57abfeea..05c51527 100644 --- a/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt +++ b/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt @@ -77,6 +77,24 @@ fun PulseLoopApp() { val batteryAlerts = remember { com.pulseloop.service.BatteryAlertMonitor(context) } val providerStore = remember { com.pulseloop.coach.config.CoachProviderSettingsStore(context) } val summaryCoordinator = remember { CoachSummaryCoordinator(db, apiKeyStore, providerStore) } + // iOS #94: runs the due check-in slot the moment a full sync completes, so a + // slot the periodic worker skipped for stale data is delivered a few minutes + // later instead of being lost. Same app-lifetime lifecycle as the coordinator. + val checkinDataTrigger = remember { + com.pulseloop.notifications.CoachNotificationDataTrigger( + context = context, + checkinSettings = { + com.pulseloop.notifications.CoachCheckinSettings( + coachEnabled = apiKeyStore.coachEnabled, + notificationsEnabled = apiKeyStore.notificationsEnabled, + apiKey = apiKeyStore.apiKey, + model = apiKeyStore.model, + morningHour = apiKeyStore.morningHour, + eveningHour = apiKeyStore.eveningHour, + ) + }, + ) + } // ── Coach wiring ───────────────────────────────────────────────── // Both the client AND the feature flags are resolved per turn through @@ -172,6 +190,7 @@ fun PulseLoopApp() { batteryAlerts.start() coordinator.start() summaryCoordinator.start() + checkinDataTrigger.start() // Stale-state guard: a persisted "CONNECTED"/"CONNECTING" must not survive a // process restart — the live GATT is gone, so the views would otherwise show a diff --git a/app/src/test/java/com/pulseloop/notifications/CoachNotificationDataTriggerTest.kt b/app/src/test/java/com/pulseloop/notifications/CoachNotificationDataTriggerTest.kt new file mode 100644 index 00000000..e53b65f2 --- /dev/null +++ b/app/src/test/java/com/pulseloop/notifications/CoachNotificationDataTriggerTest.kt @@ -0,0 +1,94 @@ +package com.pulseloop.notifications + +import com.pulseloop.ring.PulseEvent +import com.pulseloop.ring.RingConnectionState +import java.time.Instant +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Tests for [CoachNotificationDataTrigger]'s handle() contract — the iOS #94 + * subscriber behavior: it fires only on SyncProgress("done"), coalesces + * back-to-back completions into a single attempt after the settle window, and + * stays silent when the feature is off. + * + * These drive the internal handle() directly on a virtual Main dispatcher rather + * than publishing through the shared [PulseEventBus]: the bus fans out on real + * Dispatchers.Default threads, and bridging that into a virtual test clock is + * racy (it was a flaky failure). The bus itself is covered by PulseEventBusTest, + * and the one-line events.collect { handle(it) } wiring in start() is exercised + * in production, so the deterministic handle() tests here are the meaningful + * slice. + */ +class CoachNotificationDataTriggerTest { + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun settings( + coachEnabled: Boolean = true, + notificationsEnabled: Boolean = true, + ): () -> CoachCheckinSettings = { + CoachCheckinSettings(coachEnabled, notificationsEnabled, "sk-test", "gpt-5.4") + } + + @Test + fun `fires only on a completed sync and coalesces back-to-back completions`() = runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + var calls = 0 + val trigger = CoachNotificationDataTrigger( + checkinSettings = settings(), + runDueSlot = { + calls++ + CoachNotificationOutcome.Sent(CoachNotificationSlot.MORNING) + }, + ) + try { + // Other sync stages and unrelated events are ignored — no settle window is armed: + trigger.handle(PulseEvent.SyncProgress("Syncing sleep…")) + trigger.handle(PulseEvent.HeartRateSample(72, Instant.now())) + trigger.handle(PulseEvent.BatteryLevel(88)) + trigger.handle(PulseEvent.DeviceStateChanged(RingConnectionState.CONNECTED, "AA:BB:CC")) + advanceUntilIdle() + assertEquals(0, calls) + + // A full sync completion arms the settle window; a back-to-back completion + // coalesces into the same single attempt (the earlier debounce is cancelled). + trigger.handle(PulseEvent.SyncProgress("done")) + trigger.handle(PulseEvent.SyncProgress("done")) + advanceUntilIdle() + assertEquals(1, calls) + } finally { + trigger.destroy() + } + } + + @Test + fun `a disabled feature never wakes the runner`() = runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + var calls = 0 + val trigger = CoachNotificationDataTrigger( + checkinSettings = settings(coachEnabled = false), + runDueSlot = { + calls++ + CoachNotificationOutcome.Sent(CoachNotificationSlot.MORNING) + }, + ) + try { + trigger.handle(PulseEvent.SyncProgress("done")) + advanceUntilIdle() + assertEquals(0, calls) + } finally { + trigger.destroy() + } + } +} diff --git a/app/src/test/java/com/pulseloop/notifications/CoachNotificationSlotRunnerTest.kt b/app/src/test/java/com/pulseloop/notifications/CoachNotificationSlotRunnerTest.kt new file mode 100644 index 00000000..86ace03f --- /dev/null +++ b/app/src/test/java/com/pulseloop/notifications/CoachNotificationSlotRunnerTest.kt @@ -0,0 +1,225 @@ +package com.pulseloop.notifications + +import com.pulseloop.data.dao.CoachNotificationRecordDao +import com.pulseloop.data.entity.CoachNotificationRecordEntity +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runTest +import java.time.LocalDateTime +import java.time.ZoneId +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Gate tests for [CoachNotificationSlotRunner] — the iOS #94 contract: the + * in-flight guard, per-(day, slot) dedupe, the disabled gate, and the two + * "not now, not yet" skips (stale data, unsynced sleep) that are deliberately + * NOT recorded so the data trigger can fire the slot later. + * + * The app has no in-memory-Room/Robolectric harness (see ActivityAggregatesTest's + * note), so the runner is exercised against an in-memory fake of the small + * [CoachNotificationRecordDao] interface — the dedupe query runs against the + * same rows the fake inserts, so the key logic is the real one. + */ +class CoachNotificationSlotRunnerTest { + + /** In-memory [CoachNotificationRecordDao]: inserts and the dedupe EXISTS query + * share one row list, so tests exercise the real interface + real key logic. */ + private class InMemoryRecordDao : CoachNotificationRecordDao { + val records = mutableListOf() + + override suspend fun insert(record: CoachNotificationRecordEntity) { + records += record + } + + override suspend fun recent(limit: Int): List = + records.sortedByDescending { it.createdAt }.take(limit) + + override suspend fun existsForDateKeyAndSlot(dateKey: Long, slotRaw: String): Boolean = + records.any { it.dateKey == dateKey && it.slotRaw == slotRaw } + + override suspend fun clear() { + records.clear() + } + } + + /** Mutable harness: one runner wired to in-memory fakes, all knobs default to + * "healthy, fresh, inside the morning window, feature enabled." */ + private class Harness( + var coachEnabled: Boolean = true, + var notificationsEnabled: Boolean = true, + var apiKey: String = "sk-test", + var fresh: Boolean = true, + var latestMeasurementAt: Long? = null, + var sleepSessionEndAt: Long? = null, + var deviceFullSyncAt: Long? = null, + var policy: CoachStaleDataPolicy = CoachStaleDataPolicy.SKIP, + ) { + val recordDao = InMemoryRecordDao() + val delivered = mutableListOf>() + var sleepRetriesScheduled = 0 + var generateGate: CompletableDeferred? = null + + // Lambdas read the mutable knobs via this. so a test mutating a knob + // (h.fresh = false, ...) is seen by the runner on its next run. + val runner = CoachNotificationSlotRunner( + settings = { + CoachCheckinSettings( + this.coachEnabled, this.notificationsEnabled, this.apiKey, "gpt-5.4", + ) + }, + recordDao = recordDao, + latestSleepSessionEndAt = { this.sleepSessionEndAt }, + currentDeviceFullSyncAt = { this.deviceFullSyncAt }, + latestMeasurementTimestamp = { this.latestMeasurementAt }, + staleDataPolicy = policy, + ensureFreshData = { this.fresh }, + generate = { slot, _ -> + this.generateGate?.await() + CoachNotificationContent("AI-${slot.name}", "ai body") + }, + deliver = { t, b -> this.delivered += t to b }, + onSleepRetryNeeded = { this.sleepRetriesScheduled++ }, + clock = { NOW_MORNING }, + ) + } + + private companion object { + private fun at(local: LocalDateTime): Long = + local.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() + + /** 09:00 — inside the default morning window (08:00–12:00). */ + val NOW_MORNING = at(LocalDateTime.of(2026, 8, 22, 9, 0)) + val NOW_MORNING_TOMORROW = at(LocalDateTime.of(2026, 8, 23, 9, 0)) + /** 15:00 — inside no slot window. */ + val NOW_AFTERNOON = at(LocalDateTime.of(2026, 8, 22, 15, 0)) + } + + @Test + fun `a due slot sends once and records the dedupe key`() = runTest { + val h = Harness() + assertEquals( + CoachNotificationOutcome.Sent(CoachNotificationSlot.MORNING), + h.runner.runDueSlot(), + ) + assertEquals(1, h.recordDao.records.size) + val rec = h.recordDao.records.single() + assertEquals(CoachNotificationSlotRunner.dateKeyFor(NOW_MORNING), rec.dateKey) + assertEquals("morning", rec.slotRaw) + assertEquals(listOf("AI-MORNING" to "ai body"), h.delivered) + } + + @Test + fun `a second run for the same day and slot is skippedDuplicate`() = runTest { + val h = Harness() + assertEquals(CoachNotificationOutcome.Sent(CoachNotificationSlot.MORNING), h.runner.runDueSlot()) + assertEquals(CoachNotificationOutcome.SkippedDuplicate, h.runner.runDueSlot()) + // No double record, no double delivery — the worker/data-trigger safety net. + assertEquals(1, h.recordDao.records.size) + assertEquals(1, h.delivered.size) + } + + @Test + fun `the same slot the next day is not a duplicate`() = runTest { + val h = Harness() + assertEquals(CoachNotificationOutcome.Sent(CoachNotificationSlot.MORNING), h.runner.runDueSlot()) + assertEquals( + CoachNotificationOutcome.Sent(CoachNotificationSlot.MORNING), + h.runner.runDueSlot(now = NOW_MORNING_TOMORROW), + ) + assertEquals(2, h.recordDao.records.size) + } + + @Test + fun `outside every slot window is skippedNoSlot`() = runTest { + val h = Harness() + assertEquals(CoachNotificationOutcome.SkippedNoSlot, h.runner.runDueSlot(now = NOW_AFTERNOON)) + assertTrue(h.recordDao.records.isEmpty()) + assertTrue(h.delivered.isEmpty()) + } + + @Test + fun `a concurrent entry while a run is in flight is skippedDuplicate`() = runTest { + val h = Harness() + val gate = CompletableDeferred() + h.generateGate = gate + + // The first run holds the static in-flight guard while generation awaits. + val first = async(start = CoroutineStart.UNDISPATCHED) { h.runner.runDueSlot() } + assertEquals(CoachNotificationOutcome.SkippedDuplicate, h.runner.runDueSlot()) + + gate.complete(CoachNotificationContent("done", "body")) + assertEquals(CoachNotificationOutcome.Sent(CoachNotificationSlot.MORNING), first.await()) + assertEquals(1, h.recordDao.records.size) + assertEquals(1, h.delivered.size) + } + + @Test + fun `a disabled coach or opt-in is skippedDisabled with no delivery`() = runTest { + val coachOff = Harness(coachEnabled = false) + assertEquals(CoachNotificationOutcome.SkippedDisabled, coachOff.runner.runDueSlot()) + assertTrue(coachOff.recordDao.records.isEmpty()) + assertTrue(coachOff.delivered.isEmpty()) + + val optInOff = Harness(notificationsEnabled = false) + assertEquals(CoachNotificationOutcome.SkippedDisabled, optInOff.runner.runDueSlot()) + assertTrue(optInOff.recordDao.records.isEmpty()) + assertTrue(optInOff.delivered.isEmpty()) + } + + @Test + fun `a stale-data skip is not recorded so a later fresh run still sends`() = runTest { + val h = Harness() + h.fresh = false + assertEquals(CoachNotificationOutcome.SkippedStaleData, h.runner.runDueSlot()) + // The whole point of iOS #94: nothing was recorded, so the slot is still + // deliverable. + assertTrue(h.recordDao.records.isEmpty()) + assertTrue(h.delivered.isEmpty()) + + // A full sync lands (what the data trigger is waiting for) — the slot fires. + h.fresh = true + assertEquals(CoachNotificationOutcome.Sent(CoachNotificationSlot.MORNING), h.runner.runDueSlot()) + assertEquals(1, h.recordDao.records.size) + assertEquals(1, h.delivered.size) + } + + @Test + fun `sendWithLastKnown sends with data but skips an empty store`() = runTest { + val h = Harness(policy = CoachStaleDataPolicy.SEND_WITH_LAST_KNOWN) + h.fresh = false + assertEquals(CoachNotificationOutcome.SkippedNoData, h.runner.runDueSlot()) + h.latestMeasurementAt = NOW_MORNING - 60_000L + assertEquals(CoachNotificationOutcome.Sent(CoachNotificationSlot.MORNING), h.runner.runDueSlot()) + assertEquals(1, h.recordDao.records.size) + } + + @Test + fun `a morning slot blocked on sleep data is not recorded and schedules the retry`() = runTest { + val h = Harness() + // Last night ended 5h ago (recent) but the last full sync is OLDER than that — + // the one shape CoachSleepSyncGate.sleepDataSynced blocks on. + h.sleepSessionEndAt = NOW_MORNING - 5 * 3600_000L + h.deviceFullSyncAt = NOW_MORNING - 10 * 3600_000L + + assertEquals(CoachNotificationOutcome.SkippedNoSleepData, h.runner.runDueSlot()) + assertTrue(h.recordDao.records.isEmpty()) + assertTrue(h.delivered.isEmpty()) + assertEquals(1, h.sleepRetriesScheduled) + } + + @Test + fun `a missing api key delivers the generic check-in and records the slot`() = runTest { + val h = Harness(apiKey = "") + assertEquals(CoachNotificationOutcome.Sent(CoachNotificationSlot.MORNING), h.runner.runDueSlot()) + assertEquals( + listOf(CoachNotificationSlotRunner.GENERIC_TITLE to CoachNotificationSlotRunner.GENERIC_BODY), + h.delivered, + ) + // ...and a later trigger run the same day (fresh data landing) can't double-send. + assertEquals(CoachNotificationOutcome.SkippedDuplicate, h.runner.runDueSlot()) + assertEquals(1, h.recordDao.records.size) + } +} From d36b923ee4009859a6d536faa8a2c686252515bd Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Sat, 22 Aug 2026 14:22:25 -0700 Subject: [PATCH 09/22] Ledger: #94 CoachNotificationDataTrigger done (9d43227) Mark the #94 data-trigger feature ported, remove it from the Outstanding single list (renumber; Workout pause intervals is now the top item), update the port-queue #94 row with the code SHA, and annotate the #94 'still open' note as now ported. Self-review caught and fixed a worker out-of-window regression (added fallbackToForcedSlot so the 24h periodic still delivers a daily check-in) and replaced a flaky real-bus test with deterministic handle() tests. --- docs/ios-sync.md | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/docs/ios-sync.md b/docs/ios-sync.md index 7abab75e..b9663182 100644 --- a/docs/ios-sync.md +++ b/docs/ios-sync.md @@ -31,8 +31,8 @@ the work list, and assembling one from all three is how items get missed. | **Fork baseline (iOS)** | `600c7a8` — Merge PR #6, 2026-06-20 | | **Last triaged iOS commit** | `439ca81` — Merge PR #93 (Colmi R11 CRP driver), 2026-08-09 | | **Last triage date** | 2026-08-22 | -| **Last port date** | 2026-08-22 — PR #93 hardening (`c95b6e8`, 5 fixes from iOS `4d65b60`) | -| **Range covered** | 12 first-parent items since `0d1b965` (2026-07-18): PRs #73, #94–#100, #130, #131, #93 + 1 direct commit (`160c775`) → **11 ported (incl. #93 hardening), #130 backed out** | +| **Last port date** | 2026-08-22 — PR #94 `CoachNotificationDataTrigger` (`9d43227`) + PR #93 hardening (`c95b6e8`) | +| **Range covered** | 12 first-parent items since `0d1b965` (2026-07-18): PRs #73, #94–#100, #130, #131, #93 + 1 direct commit (`160c775`) → **11 ported, #130 backed out** (#94's data-trigger feature and #93's 5 hardening fixes both landed this session) | --- @@ -47,13 +47,12 @@ blocked on something outside the code. | # | Item | What is actually left | Size | Ready? | |---|------|----------------------|------|--------| -| 1 | **#94 `CoachNotificationDataTrigger`** | The *actual feature* of #94 was never ported — an event-bus subscriber that runs the due check-in slot when a sync completes, recovering a slot skipped for stale data. What shipped was the window constant mistaken for it (and its regression, since fixed in `8df67b1`). | M | ✅ start now | -| 2 | **Workout pause intervals** | `activity_events` is never written on Android, so Strava TCX can't drop paused trackpoints. `totalPauseSeconds` is already honoured — this is the per-interval detail only. | S–M | ✅ start now | -| 3 | **#96 nutrition subset** | `food_products`/`FoodProductDao`/`CachedFoodProductEntity` exist but **nothing can populate them**: no Open Food Facts client, no barcode scanner, no AI photo analysis, no coach `log_meal` tool. The ledger row is corrected to ADAPT (subset); this is the rest of it. | L | ✅ start now | -| 4 | **#130 RWfit — finish the JieLi `0xAB` path** | The vendor rebuild landed on `main`. The legacy `0x7E` path is complete; the JieLi `0xAB` framing is complete but **its history bodies are not decoded yet**. Read `decompiled-rwfit-official/`, never iOS and never guesswork (root `AGENTS.md`). | M | ✅ start now | -| 5 | **#82 YCBT (TK5 + SmartHealth-Colmi)** | Protocol layer is on `main` (`7a941a5`, `849131d`). **No code known to be missing** — what is missing is a live connect against real hardware. If one fails, re-read `BleHelper.java`'s connect sequence: the vendor's MTU/bonding/pacing timing was deliberately *not* copied (see the 2026-07-19 note). | — | ⛔ needs hardware | -| 6 | **#90 LuckRing / TK18** | Protocol layer is on `main` (`57e1e23`). Same position as #82: no known code gap, never validated against a real TK18. | — | ⛔ needs hardware | -| 7 | **#79 Activity Year trends** | Divide the in-progress current month by elapsed days, not a full 30/31. | S | ⛔ blocked — Android has no Activity-trends screen to fix | +| 1 | **Workout pause intervals** | `activity_events` is never written on Android, so Strava TCX can't drop paused trackpoints. `totalPauseSeconds` is already honoured — this is the per-interval detail only. | S–M | ✅ start now | +| 2 | **#96 nutrition subset** | `food_products`/`FoodProductDao`/`CachedFoodProductEntity` exist but **nothing can populate them**: no Open Food Facts client, no barcode scanner, no AI photo analysis, no coach `log_meal` tool. The ledger row is corrected to ADAPT (subset); this is the rest of it. | L | ✅ start now | +| 3 | **#130 RWfit — finish the JieLi `0xAB` path** | The vendor rebuild landed on `main`. The legacy `0x7E` path is complete; the JieLi `0xAB` framing is complete but **its history bodies are not decoded yet**. Read `decompiled-rwfit-official/`, never iOS and never guesswork (root `AGENTS.md`). | M | ✅ start now | +| 4 | **#82 YCBT (TK5 + SmartHealth-Colmi)** | Protocol layer is on `main` (`7a941a5`, `849131d`). **No code known to be missing** — what is missing is a live connect against real hardware. If one fails, re-read `BleHelper.java`'s connect sequence: the vendor's MTU/bonding/pacing timing was deliberately *not* copied (see the 2026-07-19 note). | — | ⛔ needs hardware | +| 5 | **#90 LuckRing / TK18** | Protocol layer is on `main` (`57e1e23`). Same position as #82: no known code gap, never validated against a real TK18. | — | ⛔ needs hardware | +| 6 | **#79 Activity Year trends** | Divide the in-progress current month by elapsed days, not a full 30/31. | S | ⛔ blocked — Android has no Activity-trends screen to fix | ### Not on this list, and why @@ -154,7 +153,7 @@ seeded-data mode). **SKIP** — no portable behavior, Android has its own indepe | # | iOS PR | Merged | Title | Verdict | Effort | Android commit | |---|--------|--------|-------|---------|--------|----------------| | ☑ | [#73](https://github.com/saksham2001/PulseLoopiOS/pull/73) `7a30014` | ~07-20 | Privacy & Data Reset (Unpair Ring / Reset App Data / Unpair+Reset) | **PORT** | S–M | `802789d` | -| ☑ | [#94](https://github.com/saksham2001/PulseLoopiOS/pull/94) `459f7f1` | ~07-21 | Background syncs + `StaleDataPolicy` + data-gated coach notifications | **ADAPT** | M | `0ca53a1` + `c4aab74` (CR fix: wire STALE_DATA_WINDOW_MS) | +| ☑ | [#94](https://github.com/saksham2001/PulseLoopiOS/pull/94) `459f7f1` | ~07-21 | Background syncs + `StaleDataPolicy` + data-gated coach notifications | **ADAPT** | M | `0ca53a1` + `c4aab74` (CR fix: wire STALE_DATA_WINDOW_MS) + **`9d43227`** (the data-trigger feature itself — the bus subscriber + (dateKey,slotRaw) dedupe + stale-skip — was the one part of #94 never ported) | | ☑ | [#95](https://github.com/saksham2001/PulseLoopiOS/pull/95) `dae95ab` | ~07-22 | HR zone colors/thresholds (evidence-based defaults + Standard/Auto/Custom modes + resting-HR baseline learning) | **PORT** | M–L | `0ca53a1` | | ☑ | [#97](https://github.com/saksham2001/PulseLoopiOS/pull/97) `cb8e1cd` | ~07-23 | LittleMeatball R10M YCBT support + 9 shared YCBT bugfixes | **ALREADY-HAVE** | — | iOS PR is itself a port of PulseLoopAndroid#31 | | ☑ | [#96](https://github.com/saksham2001/PulseLoopiOS/pull/96) `c0def0f` | ~07-24 | Calorie + macro nutrition tracking (meal logging, barcode scan, OFF search, AI photo analysis, coach `log_meal` tool, intake goals, provenance tags) | **ADAPT (subset — manual meal logging + goals only; no OFF search, barcode, AI photo or coach `log_meal`)** | XL | `4084671` + `c4aab74` (CR fix: null-goal guard, dead button wired) | @@ -1515,9 +1514,9 @@ the pre-fix builder. Suite: 794 → 812. ### Still open -- **#94's actual feature** is `CoachNotificationDataTrigger` (run the due slot when a sync - completes, recovering a slot skipped for stale data). Not ported — it's an event-bus subscriber, - not the window constant that was mistaken for it. +- ~~**#94's actual feature**~~ **now ported in `9d43227`** (`CoachNotificationDataTrigger` + bus subscriber + (dateKey,slotRaw) dedupe + stale-skip). It was an event-bus subscriber, not the + window constant that had been mistaken for it. - **#96 subset**: no OFF search, no barcode scan, no AI photo analysis, no coach `log_meal` tool. - **Pause intervals**: `activity_events` is never written on Android, so TCX can't drop paused trackpoints yet. `totalPauseSeconds` is honoured. From 71f251e20e955a99b2f328c96bd08be669af8923 Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Sat, 22 Aug 2026 14:36:30 -0700 Subject: [PATCH 10/22] Write activity pause events so Strava TCX drops paused trackpoints Android's TCX builder already supported pause intervals (pauseIntervals() drops trackpoints recorded while paused), but the activity_events table was never written on Android, so every upload carried the paused span's GPS fixes. - LiveWorkoutManager.pause/resume now write the same ActivityEvent markers iOS's PulseServices.pause/resume write: pause -> "paused" + "gps_stopped", resume -> "resumed" + "gps_started" (both at the shared timestamp, mirroring iOS's single Date() per action). The endedAt pausedAt marker and all totalPauseSeconds math are untouched (iOS computes the span from the last "paused" event; the marker is kept so the tick clock, finish carry and TCX TotalTimeSeconds still key on it). - ActivityEventDao (forSession ordered by timestamp / insert) + the database accessor. No schema change: ActivityEventEntity and the activity_events table already existed, so no version bump / migration. - StravaUploader reads the session's events on the same DAO path as gpsPoints/hrSamples and passes real pauseIntervals into the builder (a workout finished while paused has no closing "resumed"; pauseIntervals closes that trailing pause at the session end). StravaTCXBuilderTest: one new case proving the gps_stopped/gps_started markers are transparent to pairing. Reference: android/docs/ios-sync.md "Workout pause intervals". --- .../com/pulseloop/data/PulseLoopDatabase.kt | 3 +++ .../main/java/com/pulseloop/data/dao/Daos.kt | 14 +++++++++++ .../pulseloop/service/LiveWorkoutManager.kt | 24 +++++++++++++++---- .../com/pulseloop/strava/StravaUploader.kt | 14 +++++++---- .../pulseloop/strava/StravaTCXBuilderTest.kt | 20 ++++++++++++++++ 5 files changed, 67 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt b/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt index df881d9d..f7938188 100644 --- a/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt +++ b/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt @@ -54,6 +54,9 @@ abstract class PulseLoopDatabase : RoomDatabase() { abstract fun deviceMeasurementConfigDao(): DeviceMeasurementConfigDao abstract fun activitySessionDao(): ActivitySessionDao abstract fun activityGpsPointDao(): ActivityGpsPointDao + // ActivityEventEntity is already in the @Database entities above (and "activity_events" is + // in ALL_TABLES), so exposing its DAO adds no schema — no version bump, no migration. + abstract fun activityEventDao(): ActivityEventDao abstract fun sleepSessionDao(): SleepSessionDao abstract fun sleepStageBlockDao(): SleepStageBlockDao abstract fun coachConversationDao(): CoachConversationDao diff --git a/app/src/main/java/com/pulseloop/data/dao/Daos.kt b/app/src/main/java/com/pulseloop/data/dao/Daos.kt index b1282484..1d5a4336 100644 --- a/app/src/main/java/com/pulseloop/data/dao/Daos.kt +++ b/app/src/main/java/com/pulseloop/data/dao/Daos.kt @@ -271,6 +271,20 @@ interface ActivityGpsPointDao { suspend fun insert(point: ActivityGpsPointEntity) } +@Dao +interface ActivityEventDao { + // iOS ActivityRepository.events(): the pause/resume lifecycle markers the live-workout + // recorder writes (LiveWorkoutManager.pause/resume mirror PulseServices.pause/resume). The + // Strava TCX build reads them to drop trackpoints recorded while paused — before this DAO + // existed the table had neither an Android writer nor a reader, so every upload carried the + // paused span's fixes. + @Query("SELECT * FROM activity_events WHERE sessionId = :sessionId ORDER BY timestamp ASC") + suspend fun forSession(sessionId: String): List + + @Insert + suspend fun insert(event: ActivityEventEntity) +} + @Dao interface SleepSessionDao { // A day can now hold several sessions (main night + daytime naps, split by SleepSegmentation). diff --git a/app/src/main/java/com/pulseloop/service/LiveWorkoutManager.kt b/app/src/main/java/com/pulseloop/service/LiveWorkoutManager.kt index ea973ed6..3ecf80b7 100644 --- a/app/src/main/java/com/pulseloop/service/LiveWorkoutManager.kt +++ b/app/src/main/java/com/pulseloop/service/LiveWorkoutManager.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.Intent import androidx.work.WorkManager import com.pulseloop.data.PulseLoopDatabase +import com.pulseloop.data.entity.ActivityEventEntity import com.pulseloop.data.entity.ActivitySessionEntity import com.pulseloop.ui.components.ActivityMeta import kotlinx.coroutines.* @@ -89,9 +90,15 @@ class LiveWorkoutManager( suspend fun pause(session: ActivitySessionEntity) { val now = System.currentTimeMillis() - // endedAt doubles as the pausedAt marker — resume() turns it into a pause span and - // clears it. iOS keeps a `paused` ActivityEvent instead; the marker column is the - // Android equivalent since there is no event table here. Do NOT add to + // Mirror iOS PulseServices.pause (PulseServices.swift): write the `paused` + + // `gps_stopped` ActivityEvents so the Strava TCX build can pair this pause with its + // later `resumed` and drop the trackpoints recorded while paused. Both events share + // this one `now` timestamp, exactly like iOS's single Date() stamp. + // The endedAt column STILL doubles as the pausedAt marker alongside the events: + // resume() turns it into the pause span and clears it, and it is what + // totalPauseSeconds is computed from — the tick clock, the finish carry, and the TCX + // TotalTimeSeconds all key on it. iOS computes the span from the last `paused` event + // instead; keeping the marker means all that existing math is untouched. Do NOT add to // totalPauseSeconds here: the pause span isn't known until resume/finish, and the old // code added the entire elapsed-since-start, corrupting every downstream duration. val updated = session.copy( @@ -100,6 +107,10 @@ class LiveWorkoutManager( updatedAt = now, ) db.activitySessionDao().upsert(updated) + // The pause markers themselves (why: see the comment above). The TCX builder reacts + // only to `paused`/`resumed`; `gps_stopped` documents the GPS stop, for parity with iOS. + db.activityEventDao().insert(ActivityEventEntity(sessionId = session.id, kind = "paused", timestamp = now)) + db.activityEventDao().insert(ActivityEventEntity(sessionId = session.id, kind = "gps_stopped", timestamp = now)) gps.stop() polling.pause() tickJob?.cancel() @@ -109,7 +120,8 @@ class LiveWorkoutManager( suspend fun resume(session: ActivitySessionEntity) { val now = System.currentTimeMillis() - // Only the actual pause span joins the total (iOS: `now - lastPause.timestamp` at resume). + // Only the actual pause span joins the total (iOS: `now - lastPause.timestamp` at resume + // — the span from the last `paused` event; the endedAt marker holds the same value here). val pausedAt = session.endedAt ?: now val updated = session.copy( statusRaw = "recording", @@ -118,6 +130,10 @@ class LiveWorkoutManager( updatedAt = now, ) db.activitySessionDao().upsert(updated) + // Mirror iOS PulseServices.resume: `resumed` closes the open pause for the TCX builder; + // `gps_started` documents the GPS restart. Same shared `now` as iOS's single Date(). + db.activityEventDao().insert(ActivityEventEntity(sessionId = session.id, kind = "resumed", timestamp = now)) + db.activityEventDao().insert(ActivityEventEntity(sessionId = session.id, kind = "gps_started", timestamp = now)) if (updated.useGps) gps.start(updated.id, updated.type) polling.resume() startTick(updated) diff --git a/app/src/main/java/com/pulseloop/strava/StravaUploader.kt b/app/src/main/java/com/pulseloop/strava/StravaUploader.kt index 24d4dda8..2a77ba3e 100644 --- a/app/src/main/java/com/pulseloop/strava/StravaUploader.kt +++ b/app/src/main/java/com/pulseloop/strava/StravaUploader.kt @@ -44,10 +44,16 @@ object StravaUploader { val hrEnd = session.endedAt ?: System.currentTimeMillis() val hrSamples = db.measurementDao().range(MeasurementKind.HEART_RATE.name, session.startedAt, hrEnd) - // Pause *intervals* would let us drop trackpoints recorded while paused, but Android never - // writes the activity_events table, so there are none to read. `totalPauseSeconds` is - // maintained, and the builder already subtracts it from TotalTimeSeconds. - val tcx = StravaTCXBuilder.build(session, gpsPoints, hrSamples, pauseIntervals = emptyList()) + // Pause *intervals* let the builder drop trackpoints recorded while paused. The + // live-workout recorder writes `paused`/`resumed` (+ `gps_stopped`/`gps_started`) + // events per session — LiveWorkoutManager.pause/resume, mirroring iOS PulseServices — + // so read them back on the same DAO path as gpsPoints/hrSamples above. A workout + // finished while paused has no closing `resumed`; pauseIntervals closes that trailing + // pause at hrEnd. `totalPauseSeconds` is still subtracted from TotalTimeSeconds by the + // builder (the endedAt marker kept alongside the events is what maintains it). + val events = db.activityEventDao().forSession(session.id) + val pauses = StravaTCXBuilder.pauseIntervals(events, hrEnd) + val tcx = StravaTCXBuilder.build(session, gpsPoints, hrSamples, pauseIntervals = pauses) return if (tcx != null) { uploadTcx(session, tcx, name, tokens, tokenStore) diff --git a/app/src/test/java/com/pulseloop/strava/StravaTCXBuilderTest.kt b/app/src/test/java/com/pulseloop/strava/StravaTCXBuilderTest.kt index 317200f7..4d17cc7a 100644 --- a/app/src/test/java/com/pulseloop/strava/StravaTCXBuilderTest.kt +++ b/app/src/test/java/com/pulseloop/strava/StravaTCXBuilderTest.kt @@ -119,6 +119,26 @@ class StravaTCXBuilderTest { assertEquals(start + 100_000, intervals[1].end) } + @Test + fun `pause intervals pair through the gps_stopped and gps_started markers Android writes`() { + // LiveWorkoutManager.pause/resume write `paused`+`gps_stopped` sharing one timestamp, + // then `resumed`+`gps_started` sharing another (mirrors iOS PulseServices). The pairing + // must react only to `paused`/`resumed` and treat the gps_* markers as transparent. + val pausedAt = start + 15_000L + val resumedAt = start + 45_000L + val events = listOf( + ActivityEventEntity(id = "1", sessionId = "s1", kind = "paused", timestamp = pausedAt), + ActivityEventEntity(id = "2", sessionId = "s1", kind = "gps_stopped", timestamp = pausedAt), + ActivityEventEntity(id = "3", sessionId = "s1", kind = "resumed", timestamp = resumedAt), + ActivityEventEntity(id = "4", sessionId = "s1", kind = "gps_started", timestamp = resumedAt), + ) + val intervals = StravaTCXBuilder.pauseIntervals(events, endedAt = start + 60_000L) + + assertEquals(1, intervals.size) + assertEquals(pausedAt, intervals[0].start) + assertEquals(resumedAt, intervals[0].end) + } + @Test fun `sport attribute uses the three TCX-legal values`() { fun sportOf(type: String) = StravaTCXBuilder From 0882f4836c164586bc652b582a4ea63209a75829 Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Sat, 22 Aug 2026 14:37:35 -0700 Subject: [PATCH 11/22] Ledger: workout pause intervals done (71f251e) LiveWorkoutManager.pause/resume now write the paused/resumed (+ gps_stopped/ gps_started) activity_events and StravaUploader reads them into StravaTCXBuilder.pauseIntervals(), so paused trackpoints drop on upload. ActivityEventDao added; no migration (table already existed). Remove 'Workout pause intervals' from the Outstanding single list (renumber; #96 nutrition is now the top item) and mark the 'Still open' note as ported. Self-review caught an illegal '/' in a backtick-quoted test name and fixed it. --- docs/ios-sync.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/ios-sync.md b/docs/ios-sync.md index b9663182..5963dd0e 100644 --- a/docs/ios-sync.md +++ b/docs/ios-sync.md @@ -31,7 +31,7 @@ the work list, and assembling one from all three is how items get missed. | **Fork baseline (iOS)** | `600c7a8` — Merge PR #6, 2026-06-20 | | **Last triaged iOS commit** | `439ca81` — Merge PR #93 (Colmi R11 CRP driver), 2026-08-09 | | **Last triage date** | 2026-08-22 | -| **Last port date** | 2026-08-22 — PR #94 `CoachNotificationDataTrigger` (`9d43227`) + PR #93 hardening (`c95b6e8`) | +| **Last port date** | 2026-08-22 — Workout pause intervals (`71f251e`) + PR #94 `CoachNotificationDataTrigger` (`9d43227`) + PR #93 hardening (`c95b6e8`) | | **Range covered** | 12 first-parent items since `0d1b965` (2026-07-18): PRs #73, #94–#100, #130, #131, #93 + 1 direct commit (`160c775`) → **11 ported, #130 backed out** (#94's data-trigger feature and #93's 5 hardening fixes both landed this session) | --- @@ -47,12 +47,11 @@ blocked on something outside the code. | # | Item | What is actually left | Size | Ready? | |---|------|----------------------|------|--------| -| 1 | **Workout pause intervals** | `activity_events` is never written on Android, so Strava TCX can't drop paused trackpoints. `totalPauseSeconds` is already honoured — this is the per-interval detail only. | S–M | ✅ start now | -| 2 | **#96 nutrition subset** | `food_products`/`FoodProductDao`/`CachedFoodProductEntity` exist but **nothing can populate them**: no Open Food Facts client, no barcode scanner, no AI photo analysis, no coach `log_meal` tool. The ledger row is corrected to ADAPT (subset); this is the rest of it. | L | ✅ start now | -| 3 | **#130 RWfit — finish the JieLi `0xAB` path** | The vendor rebuild landed on `main`. The legacy `0x7E` path is complete; the JieLi `0xAB` framing is complete but **its history bodies are not decoded yet**. Read `decompiled-rwfit-official/`, never iOS and never guesswork (root `AGENTS.md`). | M | ✅ start now | -| 4 | **#82 YCBT (TK5 + SmartHealth-Colmi)** | Protocol layer is on `main` (`7a941a5`, `849131d`). **No code known to be missing** — what is missing is a live connect against real hardware. If one fails, re-read `BleHelper.java`'s connect sequence: the vendor's MTU/bonding/pacing timing was deliberately *not* copied (see the 2026-07-19 note). | — | ⛔ needs hardware | -| 5 | **#90 LuckRing / TK18** | Protocol layer is on `main` (`57e1e23`). Same position as #82: no known code gap, never validated against a real TK18. | — | ⛔ needs hardware | -| 6 | **#79 Activity Year trends** | Divide the in-progress current month by elapsed days, not a full 30/31. | S | ⛔ blocked — Android has no Activity-trends screen to fix | +| 1 | **#96 nutrition subset** | `food_products`/`FoodProductDao`/`CachedFoodProductEntity` exist but **nothing can populate them**: no Open Food Facts client, no barcode scanner, no AI photo analysis, no coach `log_meal` tool. The ledger row is corrected to ADAPT (subset); this is the rest of it. | L | ✅ start now | +| 2 | **#130 RWfit — finish the JieLi `0xAB` path** | The vendor rebuild landed on `main`. The legacy `0x7E` path is complete; the JieLi `0xAB` framing is complete but **its history bodies are not decoded yet**. Read `decompiled-rwfit-official/`, never iOS and never guesswork (root `AGENTS.md`). | M | ✅ start now | +| 3 | **#82 YCBT (TK5 + SmartHealth-Colmi)** | Protocol layer is on `main` (`7a941a5`, `849131d`). **No code known to be missing** — what is missing is a live connect against real hardware. If one fails, re-read `BleHelper.java`'s connect sequence: the vendor's MTU/bonding/pacing timing was deliberately *not* copied (see the 2026-07-19 note). | — | ⛔ needs hardware | +| 4 | **#90 LuckRing / TK18** | Protocol layer is on `main` (`57e1e23`). Same position as #82: no known code gap, never validated against a real TK18. | — | ⛔ needs hardware | +| 5 | **#79 Activity Year trends** | Divide the in-progress current month by elapsed days, not a full 30/31. | S | ⛔ blocked — Android has no Activity-trends screen to fix | ### Not on this list, and why @@ -1518,8 +1517,10 @@ the pre-fix builder. Suite: 794 → 812. bus subscriber + (dateKey,slotRaw) dedupe + stale-skip). It was an event-bus subscriber, not the window constant that had been mistaken for it. - **#96 subset**: no OFF search, no barcode scan, no AI photo analysis, no coach `log_meal` tool. -- **Pause intervals**: `activity_events` is never written on Android, so TCX can't drop paused - trackpoints yet. `totalPauseSeconds` is honoured. +- ~~**Pause intervals**~~ **now ported in `71f251e`**: `LiveWorkoutManager.pause/resume` + write the `paused`/`resumed` (+ `gps_stopped`/`gps_started`) `activity_events` and + `StravaUploader` reads them into `StravaTCXBuilder.pauseIntervals()`, so paused trackpoints drop. + `totalPauseSeconds` was already honoured. --- From a13238df592722cd02e4de8f241406c35d5e7ea1 Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Sat, 22 Aug 2026 15:36:23 -0700 Subject: [PATCH 12/22] Port iOS #96 Open Food Facts client + cache (nutrition foundation) The food_products table existed but nothing populated it in normal operation. Add the OFF client + LRU cache that does, porting the iOS behavior (grading behavior, not syntax). - com.pulseloop.nutrition package: FoodProduct domain, NutritionMath (kJ->kcal, scaled, sodium g->mg), the wire DTOs (OFFProductResponse, OFFSearchResponse, OFFProductDTO + asFoodProduct() normalization, OFFBrands, OFFNutriments, OFFNumber), and the thin OpenFoodFactsClient (OkHttp). - Faithful to the iOS quirks: fields= trim, custom User-Agent (OFF/ODbL requirement), 15s timeout, 404 -> null (not an error), 429 -> rateLimited (no auto-retry), number-or-string nutrient values, brands string/array duality (v2 product API vs Search-a-licious), the kJ field under either spelling, lossy search decode (one bad community product never fails the response), and the exact per-100g normalization (sodium grams->mg, kJ->kcal fallback, drop rows with no name/energy, brand = first token). - FoodProductCache (byCode/recent/touch/upsertCached) with a 500-row LRU prune over the existing FoodProductDao (adds query-only count() + prune(); no schema change, no migration). Room mappers asCachedProduct()/asFoodProduct(). Tests (29): NutritionMathTest (8), OFFProductDecodeTest (11 -- every normalization path incl. the lossy array and brands duality), OpenFoodFactsClientTest (10 -- MockWebServer already a test dep; real-socket HTTP mapping, UA header, 404/429/500/Network/Decoding). Self-review (compile) fixed: JsonPrimitive has no doubleOrNull (parse content instead), the OFFNumber serializer must wrap in OFFNumber(...), toHttpUrl needs the HttpUrl.Companion import, an override may not restate a default value, the URL builder chain needs a non-null base, and get() needs an explicit return. Reference: android/docs/ios-sync.md #96 (stage 1 of 4). --- .../main/java/com/pulseloop/data/dao/Daos.kt | 16 + .../pulseloop/nutrition/FoodProductCache.kt | 67 +++++ .../nutrition/OpenFoodFactsClient.kt | 195 +++++++++++++ .../pulseloop/nutrition/OpenFoodFactsTypes.kt | 275 ++++++++++++++++++ .../pulseloop/nutrition/NutritionMathTest.kt | 54 ++++ .../nutrition/OFFProductDecodeTest.kt | 187 ++++++++++++ .../nutrition/OpenFoodFactsClientTest.kt | 180 ++++++++++++ 7 files changed, 974 insertions(+) create mode 100644 app/src/main/java/com/pulseloop/nutrition/FoodProductCache.kt create mode 100644 app/src/main/java/com/pulseloop/nutrition/OpenFoodFactsClient.kt create mode 100644 app/src/main/java/com/pulseloop/nutrition/OpenFoodFactsTypes.kt create mode 100644 app/src/test/java/com/pulseloop/nutrition/NutritionMathTest.kt create mode 100644 app/src/test/java/com/pulseloop/nutrition/OFFProductDecodeTest.kt create mode 100644 app/src/test/java/com/pulseloop/nutrition/OpenFoodFactsClientTest.kt diff --git a/app/src/main/java/com/pulseloop/data/dao/Daos.kt b/app/src/main/java/com/pulseloop/data/dao/Daos.kt index 1d5a4336..4fa72ddb 100644 --- a/app/src/main/java/com/pulseloop/data/dao/Daos.kt +++ b/app/src/main/java/com/pulseloop/data/dao/Daos.kt @@ -574,6 +574,22 @@ interface FoodProductDao { @Upsert suspend fun upsert(product: CachedFoodProductEntity) + /** Row count — the cheap probe iOS's `upsertCachedProduct` runs before deciding to prune. */ + @Query("SELECT COUNT(*) FROM food_products") + suspend fun count(): Int + + /** + * Bounded LRU (iOS `pruneProductCache`): keep the [keep] most recently used rows and + * delete the rest in a single statement. The NOT IN subquery is the whole table when the + * cache is at or under the cap, so the statement is a no-op there; `code` only breaks + * `lastUsedAt` ties, deterministically. Query-only — no schema change. + */ + @Query( + "DELETE FROM food_products WHERE code NOT IN " + + "(SELECT code FROM food_products ORDER BY lastUsedAt DESC, code ASC LIMIT :keep)", + ) + suspend fun prune(keep: Int) + @Query("DELETE FROM food_products") suspend fun clear() } diff --git a/app/src/main/java/com/pulseloop/nutrition/FoodProductCache.kt b/app/src/main/java/com/pulseloop/nutrition/FoodProductCache.kt new file mode 100644 index 00000000..fdf7d7aa --- /dev/null +++ b/app/src/main/java/com/pulseloop/nutrition/FoodProductCache.kt @@ -0,0 +1,67 @@ +package com.pulseloop.nutrition + +import com.pulseloop.data.dao.FoodProductDao +import com.pulseloop.data.entity.CachedFoodProductEntity + +/** + * Bounded-LRU helpers over [FoodProductDao] — the port of the "Open Food Facts product + * cache" section of `NutritionRepository` in Services/Repositories.swift (iOS PR #96). + * + * The ODbL usage rules are cache-first: every product lookup that reaches OFF maps to a real + * user action (search submit, barcode scan, coach tool call), so pickers must always consult + * this table before the network. The table is the cache — [OpenFoodFactsClient] caches + * nothing. + */ +object FoodProductCache { + /** LRU cap for the Open Food Facts product cache (iOS `maxCachedProducts`). */ + const val MAX_CACHED_PRODUCTS = 500 + + /** The cached product for an OFF code, or null — check this before any network call. */ + suspend fun byCode(dao: FoodProductDao, code: String): CachedFoodProductEntity? = + dao.byCode(code) + + /** Most recently used cached products for the quick-log "recent foods" list. */ + suspend fun recent(dao: FoodProductDao, limit: Int = 12): List = + dao.recent(limit) + + /** + * Mark a cached product as used (bumps the frequency/recency signals). Re-upserts the + * cache row only — iOS's `touchProduct` mutates the row and lets the caller batch the + * save with the meal insert; Room has no unsaved state, so this writes exactly that one + * row and nothing else. + */ + suspend fun touch(dao: FoodProductDao, entity: CachedFoodProductEntity) { + dao.upsert(entity.copy(useCount = entity.useCount + 1, lastUsedAt = System.currentTimeMillis())) + } + + /** + * Insert or refresh a fetched product in the cache (keyed on its OFF code), keeping the + * LRU bounded. Ported from `upsertCachedProduct`: an existing row has its + * name/brand/nutriments/serving refreshed and its recency restamped, and the prune runs + * only when actually over the cap (cheap count probe — the old iOS + * prune-on-every-insert did a full-table sort per upsert). + */ + suspend fun upsertCached(dao: FoodProductDao, product: FoodProduct): CachedFoodProductEntity { + val now = System.currentTimeMillis() + val row = dao.byCode(product.code) + ?.copy( + name = product.name, + brand = product.brand, + energyKcal100g = product.energyKcal100g, + protein100g = product.protein100g, + carbs100g = product.carbs100g, + fat100g = product.fat100g, + fiber100g = product.fiber100g, + sugars100g = product.sugars100g, + saturatedFat100g = product.saturatedFat100g, + sodiumMg100g = product.sodiumMg100g, + servingSizeText = product.servingSizeText, + servingQuantityG = product.servingQuantityG, + lastUsedAt = now, + ) + ?: product.asCachedProduct().copy(lastUsedAt = now) + dao.upsert(row) + if (dao.count() > MAX_CACHED_PRODUCTS) dao.prune(MAX_CACHED_PRODUCTS) + return row + } +} diff --git a/app/src/main/java/com/pulseloop/nutrition/OpenFoodFactsClient.kt b/app/src/main/java/com/pulseloop/nutrition/OpenFoodFactsClient.kt new file mode 100644 index 00000000..d5597dab --- /dev/null +++ b/app/src/main/java/com/pulseloop/nutrition/OpenFoodFactsClient.kt @@ -0,0 +1,195 @@ +package com.pulseloop.nutrition + +import com.pulseloop.BuildConfig +import com.pulseloop.data.entity.CachedFoodProductEntity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.IOException +import java.util.concurrent.TimeUnit + +/** + * Interface for food-database lookups, so views/tools depend on the protocol and tests + * inject a stub (same pattern as `ResponsesClient`). Ported from `FoodDatabaseClient` + * in OpenFoodFactsClient.swift. + */ +interface FoodDatabaseClient { + /** Product by barcode. null = not found (a valid answer, not an error). */ + suspend fun product(barcode: String): FoodProduct? + + /** Full-text search, best matches first. */ + suspend fun search(query: String, pageSize: Int = 10): List +} + +/** + * OFF failures, kept distinct so the UI can react appropriately. Ported from + * `OpenFoodFactsError` in OpenFoodFactsClient.swift. + */ +sealed class OpenFoodFactsError(message: String) : Exception(message) { + /** The request URL could not be built. */ + data object InvalidUrl : OpenFoodFactsError("invalid URL") + /** HTTP 429 — the caller must back off and offer manual entry; never auto-retry. */ + data object RateLimited : OpenFoodFactsError("rate limited (HTTP 429)") + /** Any other non-2xx the caller does not special-case. */ + data class HttpStatus(val code: Int) : OpenFoodFactsError("HTTP $code") + /** The body was not valid OFF JSON. */ + data class Decoding(val msg: String) : OpenFoodFactsError(msg) + /** The request never completed (timeout, refused connection, ...). */ + data class Network(val msg: String) : OpenFoodFactsError(msg) +} + +/** + * Thin OkHttp client for Open Food Facts (ODbL). Usage rules honored here: + * - Every call maps to a real user action (explicit search submit, barcode scan, coach tool + * call) — callers must check the local `food_products` table first (see + * [FoodProductCache]). + * - A custom User-Agent identifies the app, as OFF requires. + * - `fields=` trims every payload to the nutriments the app actually stores. + * + * Product reads hit world.openfoodfacts.org; full-text search is only served by the newer + * Search-a-licious host (v2 search is structured-filter only). + * + * Ported from `OpenFoodFactsClient` in OpenFoodFactsClient.swift. Like the iOS client it is + * deliberately thin: it maps every HTTP status the same way and caches nothing of its own — + * the Room table is the cache. + */ +class OpenFoodFactsClient( + private val client: OkHttpClient = OkHttpClient.Builder() + .callTimeout(15, TimeUnit.SECONDS) // iOS `request.timeoutInterval = 15` + .build(), + private val json: Json = Json { ignoreUnknownKeys = true }, + // Hosts are injectable so unit tests can point the client at a MockWebServer; the paths + // built in [product]/[search] are the ported contract and are not configurable. + private val productBase: String = "https://world.openfoodfacts.org", + private val searchBase: String = "https://search.openfoodfacts.org", +) : FoodDatabaseClient { + + override suspend fun product(barcode: String): FoodProduct? = withContext(Dispatchers.IO) { + val base = productBase.toHttpUrl() ?: throw OpenFoodFactsError.InvalidUrl + val url = base.newBuilder() + .addPathSegment("api") + .addPathSegment("v2") + .addPathSegment("product") + .addPathSegment(barcode) // percent-encodes the barcode + .addQueryParameter("fields", PRODUCT_FIELDS) + .build() + + // OFF returns 404 for unknown barcodes; map that to null, not an error. + val envelope = try { + get(url) { text -> json.decodeFromString(OFFProductResponse.serializer(), text) } + } catch (error: OpenFoodFactsError.HttpStatus) { + if (error.code == 404) return@withContext null + throw error + } + return@withContext if (envelope.status == 1) envelope.product?.asFoodProduct() else null + } + + override suspend fun search(query: String, pageSize: Int): List = + withContext(Dispatchers.IO) { + val base = searchBase.toHttpUrl() ?: throw OpenFoodFactsError.InvalidUrl + val url = base.newBuilder() + .addPathSegment("search") + .addQueryParameter("q", query) + .addQueryParameter("page_size", pageSize.toString()) + .addQueryParameter("fields", PRODUCT_FIELDS) + .build() + + val envelope = get(url) { text -> OFFSearchResponse.decode(json, text) } + envelope.results.mapNotNull { it.asFoodProduct() } + } + + /** + * One GET with OFF's error contract, mirroring the iOS `get` helper: 429 → + * [OpenFoodFactsError.RateLimited], any other non-2xx → [OpenFoodFactsError.HttpStatus], + * a transport failure → [OpenFoodFactsError.Network], an unparseable body → + * [OpenFoodFactsError.Decoding]. + */ + private fun get(url: HttpUrl, decode: (String) -> T): T { + val request = Request.Builder() + .url(url) + .header("User-Agent", userAgent) + .build() + val response = try { + client.newCall(request).execute() + } catch (e: IOException) { + throw OpenFoodFactsError.Network(e.message ?: e.javaClass.simpleName) + } + return response.use { resp -> + val code = resp.code + if (code !in 200..299) { + if (code == 429) throw OpenFoodFactsError.RateLimited + throw OpenFoodFactsError.HttpStatus(code) + } + val text = resp.body?.string() + ?: throw OpenFoodFactsError.Decoding("empty response body") + try { + decode(text) + } catch (e: OpenFoodFactsError) { + throw e + } catch (e: Exception) { + throw OpenFoodFactsError.Decoding(e.message ?: e.javaClass.simpleName) + } + } + } + + companion object { + /** + * OFF-required app identification: AppName/Version (contact). iOS reads + * `CFBundleShortVersionString` with a "1.0" fallback; the Android equivalent is + * `BuildConfig.VERSION_NAME`, which is always generated — `ifBlank` keeps the same + * defensive fallback shape. + */ + val userAgent: String = + "PulseLoop/${BuildConfig.VERSION_NAME.ifBlank { "1.0" }} (sakshambhutani2001@gmail.com)" + + /** `fields=` trims every payload to the nutriments the app actually stores. */ + const val PRODUCT_FIELDS = + "code,product_name,brands,nutriments,serving_size,serving_quantity" + } +} + +// ── Domain ⇄ Room mappers ────────────────────────────────────────────────────────── +// Ported from the `extension FoodProduct` / `extension CachedFoodProduct` at the bottom of +// OpenFoodFactsClient.swift. + +/** Persist into (or refresh) the local cache table form. */ +fun FoodProduct.asCachedProduct(): CachedFoodProductEntity = + CachedFoodProductEntity( + code = code, + name = name, + brand = brand, + energyKcal100g = energyKcal100g, + protein100g = protein100g, + carbs100g = carbs100g, + fat100g = fat100g, + fiber100g = fiber100g, + sugars100g = sugars100g, + saturatedFat100g = saturatedFat100g, + sodiumMg100g = sodiumMg100g, + servingSizeText = servingSizeText, + servingQuantityG = servingQuantityG, + // lastUsedAt / useCount take the entity defaults (now / 0): a freshly fetched row + // counts as just used, matching the iOS CachedFoodProduct initializer defaults. + ) + +/** Back to the value form the pickers work with. */ +fun CachedFoodProductEntity.asFoodProduct(): FoodProduct = + FoodProduct( + code = code, + name = name, + brand = brand, + energyKcal100g = energyKcal100g, + protein100g = protein100g, + carbs100g = carbs100g, + fat100g = fat100g, + fiber100g = fiber100g, + sugars100g = sugars100g, + saturatedFat100g = saturatedFat100g, + sodiumMg100g = sodiumMg100g, + servingSizeText = servingSizeText, + servingQuantityG = servingQuantityG, + ) diff --git a/app/src/main/java/com/pulseloop/nutrition/OpenFoodFactsTypes.kt b/app/src/main/java/com/pulseloop/nutrition/OpenFoodFactsTypes.kt new file mode 100644 index 00000000..3b61d8c5 --- /dev/null +++ b/app/src/main/java/com/pulseloop/nutrition/OpenFoodFactsTypes.kt @@ -0,0 +1,275 @@ +package com.pulseloop.nutrition + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.jsonObject + +// Open Food Facts wire types + the app-facing domain product. OFF data is community-sourced +// and messy: numeric fields arrive as numbers or strings, keys are hyphenated, energy may be +// kJ-only, and sodium is grams. Everything is normalized here (and only here) so the rest of +// the app deals in clean per-100g kcal/grams — with [NutritionMath] holding the pure conversion +// logic, heavily unit-tested. +// +// Ported from PulseLoop/Nutrition/OpenFoodFactsTypes.swift (iOS PR #96). + +/** + * A normalized food product: per-100g values (OFF's canonical shape) plus serving info. + * Per-serving math happens at use time via [NutritionMath.scaled]. + */ +data class FoodProduct( + val code: String, + val name: String, + val brand: String? = null, + val energyKcal100g: Double, + val protein100g: Double = 0.0, + val carbs100g: Double = 0.0, + val fat100g: Double = 0.0, + val fiber100g: Double? = null, + val sugars100g: Double? = null, + val saturatedFat100g: Double? = null, + /** Milligrams per 100g — OFF reports grams; the conversion happens in [OFFProductDTO.asFoodProduct]. */ + val sodiumMg100g: Double? = null, + /** Human serving text, e.g. "30 g" or "1 cup (240 ml)". */ + val servingSizeText: String? = null, + /** Grams per serving when OFF provides a resolvable quantity. */ + val servingQuantityG: Double? = null, +) + +/** + * Pure nutrition conversions — the single place OFF's unit quirks are handled. + * Ported from `NutritionMath` in OpenFoodFactsTypes.swift; unit-tested in NutritionMathTest. + */ +object NutritionMath { + /** kJ → kcal. */ + const val kcalPerKJ = 1.0 / 4.184 + + /** Nutrient totals for [grams] of a product, scaled from its per-100g values. */ + fun scaled(per100g: Double, grams: Double): Double = per100g * grams / 100.0 + + /** + * Resolve energy in kcal from OFF's fields: prefer `energy-kcal_100g`; fall back to + * converting the kJ field. Returns null when neither is present. A present-but-zero kcal + * still wins — 0 is data, not absence (iOS's `if let kcal` guards on presence, not value). + */ + fun energyKcal(kcal: Double?, kJ: Double?): Double? = kcal ?: kJ?.times(kcalPerKJ) + + /** OFF reports sodium in grams per 100g; the app stores milligrams. */ + fun sodiumMg(fromGrams: Double?): Double? = fromGrams?.times(1000.0) +} + +// MARK: - Wire DTOs + +/** `GET /api/v2/product/{code}` envelope. `status == 1` means found. */ +@Serializable +data class OFFProductResponse( + val status: Int? = null, + val product: OFFProductDTO? = null, +) + +/** + * Search envelope. Search-a-licious returns `hits`; the legacy v1/v2 search returns + * `products` — decode both, and decode each hit *independently* (lossy) so one malformed + * community-edited product can never fail the whole response. + * + * Ported from `OFFSearchResponse` + `LossyArray` in OpenFoodFactsTypes.swift. It is not + * itself `@Serializable`: kotlinx can't express "decode each element of this list on its + * own, dropping failures" on a `List` property, so the lossy decode lives in + * [decode] — the exact job of the iOS custom `init(from:)` + `LossyArray`. The caller's + * lenient [Json] is passed in so the per-element decode is as forgiving as the client's. + */ +data class OFFSearchResponse( + val hits: List? = null, + val products: List? = null, +) { + /** iOS's `results` computed property: `hits ?? products ?? []`. */ + val results: List get() = hits ?: products ?: emptyList() + + companion object { + /** + * Lossy-decode a search body. Each element of `hits`/`products` is decoded in its + * own try/catch (mirroring iOS's `LossyArray`) and failures are dropped; a key that + * is present but not an array is treated as absent, as `decodeIfPresent` would. + * Throws only when the body is not a JSON object at all — the client wraps that in + * `OpenFoodFactsError.Decoding`. + */ + fun decode(json: Json, raw: String): OFFSearchResponse { + val envelope = json.parseToJsonElement(raw).jsonObject + return OFFSearchResponse(lossy(json, envelope["hits"]), lossy(json, envelope["products"])) + } + + private fun lossy(json: Json, element: JsonElement?): List? { + val array = element as? JsonArray ?: return null + return array.mapNotNull { item -> + runCatching { json.decodeFromJsonElement(OFFProductDTO.serializer(), item) }.getOrNull() + } + } + } +} + +/** + * One product row on the wire, from either endpoint. Ported from `OFFProductDTO` in + * OpenFoodFactsTypes.swift. + */ +@Serializable +data class OFFProductDTO( + val code: String? = null, + @SerialName("product_name") val productName: String? = null, + /** Comma-joined brand list; see [OFFBrands] for the string/array duality. */ + val brands: OFFBrands? = null, + val nutriments: OFFNutriments? = null, + @SerialName("serving_size") val servingSize: String? = null, + @SerialName("serving_quantity") val servingQuantity: OFFNumber? = null, +) { + /** + * Normalize to the domain product. Returns null for unusable rows (no code, no name, or + * no energy in any form) — better to drop a result than present a food with no numbers. + */ + fun asFoodProduct(): FoodProduct? { + val productCode = code?.takeIf { it.isNotEmpty() } + val name = productName?.trim()?.takeIf { it.isNotEmpty() } + val n = nutriments + val kcal = n?.let { + NutritionMath.energyKcal(kcal = it.energyKcal100g?.value, kJ = it.energyKJ?.value) + } + if (productCode == null || name == null || n == null || kcal == null) return null + // OFF brands is a comma-separated list; show the first. + val brand = brands?.joined?.split(",")?.firstOrNull()?.trim()?.takeIf { it.isNotEmpty() } + return FoodProduct( + code = productCode, + name = name, + brand = brand, + energyKcal100g = kcal, + protein100g = n.proteins100g?.value ?: 0.0, + carbs100g = n.carbohydrates100g?.value ?: 0.0, + fat100g = n.fat100g?.value ?: 0.0, + fiber100g = n.fiber100g?.value, + sugars100g = n.sugars100g?.value, + saturatedFat100g = n.saturatedFat100g?.value, + sodiumMg100g = NutritionMath.sodiumMg(n.sodium100g?.value), + servingSizeText = servingSize, + servingQuantityG = servingQuantity?.value, + ) + } +} + +/** + * OFF's `brands` field, normalized to the comma-joined string the v2 product API returns. + * + * `brands` is a comma-separated STRING on the v2 product API but an ARRAY of strings on + * Search-a-licious — this mismatch used to fail every search (see the comment on + * `OFFProductDTO.init(from:)` in OpenFoodFactsTypes.swift). [OFFBrandsSerializer] accepts + * both — string first, else the array joined with ", " — and yields the "field absent" + * value (the empty string) for anything else, mirroring the iOS `try?` cascade that set + * `brands = nil` there: a malformed brands never fails the product decode. + */ +@Serializable(with = OFFBrandsSerializer::class) +data class OFFBrands(val joined: String) + +/** + * The `nutriments` sub-object, with OFF's exact (hyphenated, per-100g) key names. + * Ported from `OFFNutriments` in OpenFoodFactsTypes.swift. + */ +@Serializable +data class OFFNutriments( + @SerialName("energy-kcal_100g") val energyKcal100g: OFFNumber? = null, + /** kJ spelling on the v2 product API. */ + @SerialName("energy_100g") val energyKJ100g: OFFNumber? = null, + /** Search-a-licious names the kJ field differently from the v2 product API. */ + @SerialName("energy-kj_100g") val energyKJAlt100g: OFFNumber? = null, + @SerialName("proteins_100g") val proteins100g: OFFNumber? = null, + @SerialName("carbohydrates_100g") val carbohydrates100g: OFFNumber? = null, + @SerialName("fat_100g") val fat100g: OFFNumber? = null, + @SerialName("fiber_100g") val fiber100g: OFFNumber? = null, + @SerialName("sugars_100g") val sugars100g: OFFNumber? = null, + @SerialName("saturated-fat_100g") val saturatedFat100g: OFFNumber? = null, + /** Grams per 100g — the app's mg conversion happens in [OFFProductDTO.asFoodProduct]. */ + @SerialName("sodium_100g") val sodium100g: OFFNumber? = null, +) { + /** + * The kJ value under either spelling — v2 uses `energy_100g`, Search-a-licious uses + * `energy-kj_100g` (iOS's `init(from:)` falls back the same way). + */ + val energyKJ: OFFNumber? get() = energyKJ100g ?: energyKJAlt100g +} + +/** + * OFF numeric fields arrive as JSON numbers *or* strings ("12.5") — community editors can + * type anything. Decode either and yield a Double; throw on non-numeric (the lossy search + * decode then drops just that product — one bad value never fails the whole response). + * Ported from `OFFNumber` in OpenFoodFactsTypes.swift. + */ +@Serializable(with = OFFNumberSerializer::class) +data class OFFNumber(val value: Double) + +/** + * Decoder for [OFFNumber]. Goes through [JsonDecoder.decodeJsonElement] so one primitive can + * be inspected: a JSON number is taken as-is, a string is trimmed and parsed (iOS trims + * whitespace the same way), anything else is a data-corruption error. The message matches + * iOS's `dataCorrupted` description. + */ +object OFFNumberSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("OFFNumber", PrimitiveKind.DOUBLE) + + override fun serialize(encoder: Encoder, value: OFFNumber) { + encoder.encodeDouble(value.value) + } + + override fun deserialize(decoder: Decoder): OFFNumber { + val element = (decoder as? JsonDecoder)?.decodeJsonElement() + ?: return OFFNumber(decoder.decodeDouble()) + val primitive = element as? JsonPrimitive + ?: throw SerializationException("Expected number or numeric string") + // A JSON number's [JsonPrimitive.content] is its text form, and a string's content is + // its text too, so one parse covers both (iOS's OFFNumber does the same). + return OFFNumber(primitive.content.trim().toDoubleOrNull() + ?: throw SerializationException("Expected number or numeric string")) + } +} + +/** + * Decoder for [OFFBrands] — try String first (v2 product API), then an array of strings + * joined with ", " (Search-a-licious). Like iOS's `[String]` decode, one non-string + * array element drops the whole field; any other shape does the same. The empty value stands + * in for "absent": [OFFProductDTO.asFoodProduct] treats it exactly like iOS treats `nil` + * (no brand). + */ +object OFFBrandsSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("OFFBrands", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: OFFBrands) { + encoder.encodeString(value.joined) + } + + override fun deserialize(decoder: Decoder): OFFBrands { + val element = (decoder as? JsonDecoder)?.decodeJsonElement() + ?: return OFFBrands(decoder.decodeString()) + return when { + element is JsonPrimitive && element.isString -> OFFBrands(element.content) + element is JsonArray -> { + val parts = mutableListOf() + for (item in element) { + val primitive = item as? JsonPrimitive ?: return OFFBrands("") + if (!primitive.isString) return OFFBrands("") + parts.add(primitive.content) + } + OFFBrands(parts.joinToString(", ")) + } + else -> OFFBrands("") + } + } +} diff --git a/app/src/test/java/com/pulseloop/nutrition/NutritionMathTest.kt b/app/src/test/java/com/pulseloop/nutrition/NutritionMathTest.kt new file mode 100644 index 00000000..1cff9a30 --- /dev/null +++ b/app/src/test/java/com/pulseloop/nutrition/NutritionMathTest.kt @@ -0,0 +1,54 @@ +package com.pulseloop.nutrition + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** Unit tests for the pure conversions in [NutritionMath] — OFF's unit quirks live here. */ +class NutritionMathTest { + @Test + fun kcalPerKJIsTheReciprocalOf4184() { + assertEquals(1.0 / 4.184, NutritionMath.kcalPerKJ, 0.0) + } + + @Test + fun scaledScalesPer100gToGrams() { + assertEquals(100.0, NutritionMath.scaled(per100g = 250.0, grams = 40.0), 1e-9) + assertEquals(539.0, NutritionMath.scaled(per100g = 539.0, grams = 100.0), 1e-9) + assertEquals(0.0, NutritionMath.scaled(per100g = 539.0, grams = 0.0), 1e-9) + } + + @Test + fun energyKcalPrefersKcalWhenBothArePresent() { + // kcal wins even when kJ is also present — 2250 kJ would be ~537.8 kcal, not 539. + assertEquals(539.0, NutritionMath.energyKcal(kcal = 539.0, kJ = 2250.0)!!, 1e-9) + } + + @Test + fun energyKcalFallsBackToKJWhenKcalIsMissing() { + assertEquals(500.0, NutritionMath.energyKcal(kcal = null, kJ = 2092.0)!!, 1e-9) + } + + @Test + fun energyKcalOfZeroKcalStillWins() { + // A present 0.0 kcal is data (a product can legitimately be recorded with 0 kcal), + // not absence — iOS's `if let kcal` guards on presence, not on non-zero. + assertEquals(0.0, NutritionMath.energyKcal(kcal = 0.0, kJ = 4184.0)!!, 1e-9) + } + + @Test + fun energyKcalIsNullWhenNeitherIsPresent() { + assertNull(NutritionMath.energyKcal(kcal = null, kJ = null)) + } + + @Test + fun sodiumMgConvertsGramsToMilligrams() { + assertEquals(42.8, NutritionMath.sodiumMg(fromGrams = 0.0428)!!, 1e-9) + assertEquals(0.0, NutritionMath.sodiumMg(fromGrams = 0.0)!!, 1e-9) + } + + @Test + fun sodiumMgIsNullForMissingGrams() { + assertNull(NutritionMath.sodiumMg(fromGrams = null)) + } +} diff --git a/app/src/test/java/com/pulseloop/nutrition/OFFProductDecodeTest.kt b/app/src/test/java/com/pulseloop/nutrition/OFFProductDecodeTest.kt new file mode 100644 index 00000000..45b426d7 --- /dev/null +++ b/app/src/test/java/com/pulseloop/nutrition/OFFProductDecodeTest.kt @@ -0,0 +1,187 @@ +package com.pulseloop.nutrition + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.fail +import org.junit.Test + +/** + * Decode + normalization tests for the OFF wire DTOs (the port of OpenFoodFactsTypes.swift). + * The fixtures are shaped like real OFF payloads: hyphenated nutriments keys, string + * numbers, kJ-only energy, and both brands shapes. + */ +class OFFProductDecodeTest { + private val json = Json { ignoreUnknownKeys = true } + + /** (a) A valid v2 product decodes; sodium arrives in grams and comes back in mg. */ + @Test + fun validV2ProductDecodesWithSodiumInMilligrams() { + val raw = """ + { + "status": 1, + "product": { + "code": "3017620422003", + "product_name": "Nutella", + "brands": "Ferrero", + "nutriments": { + "energy-kcal_100g": 539, + "energy_100g": 2250, + "proteins_100g": 6.3, + "carbohydrates_100g": "57.5", + "fat_100g": 30.9, + "fiber_100g": 3.4, + "sugars_100g": 56.3, + "saturated-fat_100g": 10.6, + "sodium_100g": 0.0428 + }, + "serving_size": "15 g", + "serving_quantity": 15 + } + } + """.trimIndent() + val response = json.decodeFromString(OFFProductResponse.serializer(), raw) + assertEquals(1, response.status!!) + val product = requireNotNull(response.product?.asFoodProduct()) + assertEquals("3017620422003", product.code) + assertEquals("Nutella", product.name) + assertEquals("Ferrero", product.brand) + // kcal wins over the co-present kJ. + assertEquals(539.0, product.energyKcal100g, 1e-9) + assertEquals(6.3, product.protein100g, 1e-9) + // "57.5" arrived as a string — OFFNumber must decode it. + assertEquals(57.5, product.carbs100g, 1e-9) + assertEquals(30.9, product.fat100g, 1e-9) + assertEquals(3.4, product.fiber100g!!, 1e-9) + assertEquals(56.3, product.sugars100g!!, 1e-9) + assertEquals(10.6, product.saturatedFat100g!!, 1e-9) + // 0.0428 g sodium per 100g -> 42.8 mg. + assertEquals(42.8, product.sodiumMg100g!!, 1e-9) + assertEquals("15 g", product.servingSizeText) + assertEquals(15.0, product.servingQuantityG!!, 1e-9) + } + + /** (b) A row with no name is unusable — dropped, not surfaced. */ + @Test + fun rowWithoutNameIsDropped() { + val dto = json.decodeFromString( + OFFProductDTO.serializer(), + """{"code":"123","nutriments":{"energy-kcal_100g":100}}""", + ) + assertNull(dto.asFoodProduct()) + } + + /** A row with no energy in any form is dropped too. */ + @Test + fun rowWithoutEnergyIsDropped() { + val dto = json.decodeFromString( + OFFProductDTO.serializer(), + """{"code":"123","product_name":"Mystery","nutriments":{"proteins_100g":1}}""", + ) + assertNull(dto.asFoodProduct()) + } + + /** (c) Energy only in kJ converts to kcal — under BOTH kJ key spellings. */ + @Test + fun kJOnlyEnergyConvertsToKcal() { + // v2 product API spelling. + val v2 = json.decodeFromString( + OFFProductDTO.serializer(), + """{"code":"1","product_name":"X","nutriments":{"energy_100g":2092}}""", + ) + assertEquals(500.0, v2.asFoodProduct()!!.energyKcal100g, 1e-9) + + // Search-a-licious spelling. + val searchALicious = json.decodeFromString( + OFFProductDTO.serializer(), + """{"code":"2","product_name":"Y","nutriments":{"energy-kj_100g":2092}}""", + ) + assertEquals(500.0, searchALicious.asFoodProduct()!!.energyKcal100g, 1e-9) + } + + /** (d) v2's comma-separated brands string keeps only the first token. */ + @Test + fun commaSeparatedBrandsYieldFirstToken() { + val dto = json.decodeFromString( + OFFProductDTO.serializer(), + """{"code":"3","product_name":"Z","brands":"Ferrero, Nutella, Unbranded","nutriments":{"energy-kcal_100g":10}}""", + ) + assertEquals("Ferrero", dto.asFoodProduct()!!.brand) + } + + /** (e) Search-a-licious' brands array is accepted and its first token used. */ + @Test + fun brandsArrayIsAccepted() { + val dto = json.decodeFromString( + OFFProductDTO.serializer(), + """{"code":"4","product_name":"W","brands":["Ferrero","Nutella"],"nutriments":{"energy-kcal_100g":10}}""", + ) + assertEquals("Ferrero", dto.asFoodProduct()!!.brand) + } + + /** A malformed brands value must not fail the product decode (iOS's try? cascade). */ + @Test + fun malformedBrandsYieldNoBrandButKeepTheProduct() { + val dto = json.decodeFromString( + OFFProductDTO.serializer(), + """{"code":"5","product_name":"V","brands":42,"nutriments":{"energy-kcal_100g":10}}""", + ) + val product = dto.asFoodProduct() + assertNotNull(product) + assertNull(product!!.brand) + } + + /** (f) The lossy search array drops malformed elements and keeps the good ones. */ + @Test + fun lossySearchArrayDropsMalformedElements() { + val raw = """ + { + "hits": [ + {"code":"a","product_name":"Good One","brands":"B","nutriments":{"energy-kcal_100g":100}}, + "this whole element is not even an object", + {"code":"b","product_name":"Good Two","nutriments":{"energy-kj_100g":4184}}, + {"code":7,"product_name":"Bad code type","nutriments":{"energy-kcal_100g":5}} + ] + } + """.trimIndent() + val response = OFFSearchResponse.decode(json, raw) + val products = response.results.mapNotNull { it.asFoodProduct() } + assertEquals(2, products.size) + assertEquals(listOf("a", "b"), products.map { it.code }) + // The second good row carried kJ-only energy and was converted on the way in. + assertEquals(1000.0, products[1].energyKcal100g, 1e-9) + } + + /** The legacy products key works when hits is absent. */ + @Test + fun legacyProductsKeyIsUsedWhenHitsIsMissing() { + val raw = """{"products":[{"code":"a","product_name":"Good","nutriments":{"energy-kcal_100g":100}}]}""" + val response = OFFSearchResponse.decode(json, raw) + assertEquals(1, response.results.size) + assertEquals("a", response.results[0].code) + } + + /** hits wins when both keys are present (iOS `hits ?? products`). */ + @Test + fun hitsWinsOverProductsWhenBothArePresent() { + val raw = """ + { + "hits": [{"code":"hits","product_name":"H","nutriments":{"energy-kcal_100g":1}}], + "products": [{"code":"products","product_name":"P","nutriments":{"energy-kcal_100g":1}}] + } + """.trimIndent() + assertEquals("hits", OFFSearchResponse.decode(json, raw).results.single().code) + } + + /** A search body whose root is not an object is a decode failure, not an empty list. */ + @Test + fun nonObjectSearchBodyFails() { + try { + OFFSearchResponse.decode(json, "[1,2,3]") + fail("expected a decode failure") + } catch (expected: Exception) { + // parseToJsonElement succeeds (it is valid JSON) but jsonObject throws. + } + } +} diff --git a/app/src/test/java/com/pulseloop/nutrition/OpenFoodFactsClientTest.kt b/app/src/test/java/com/pulseloop/nutrition/OpenFoodFactsClientTest.kt new file mode 100644 index 00000000..90c17237 --- /dev/null +++ b/app/src/test/java/com/pulseloop/nutrition/OpenFoodFactsClientTest.kt @@ -0,0 +1,180 @@ +package com.pulseloop.nutrition + +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test + +/** + * Drives [OpenFoodFactsClient] against a real MockWebServer so the HTTP status mapping, + * the User-Agent requirement, and the fields= trim are tested through a real socket — + * the same approach [com.pulseloop.coach.openai.ResponsesHttpTest] uses for the coach + * client. The injectable host bases keep the production endpoints as the defaults. + */ +class OpenFoodFactsClientTest { + private lateinit var server: MockWebServer + private lateinit var client: OpenFoodFactsClient + + @Before + fun setUp() { + server = MockWebServer() + server.start() + val base = server.url("/").toString() + client = OpenFoodFactsClient(productBase = base, searchBase = base) + } + + @After + fun tearDown() { + server.shutdown() + } + + private fun v2ProductBody(status: Int = 1): String = """ + { + "status": $status, + "product": { + "code": "3017620422003", + "product_name": "Nutella", + "brands": "Ferrero", + "nutriments": {"energy-kcal_100g": 539, "sodium_100g": 0.0428}, + "serving_size": "15 g", + "serving_quantity": 15 + } + } + """.trimIndent() + + @Test + fun productFoundReturnsTheNormalizedProduct() = runBlocking { + server.enqueue(MockResponse().setBody(v2ProductBody())) + val product = client.product("3017620422003") + assertNotNull(product) + assertEquals("3017620422003", product!!.code) + assertEquals(539.0, product.energyKcal100g, 1e-9) + // Sodium arrives in grams on the wire and is stored in mg. + assertEquals(42.8, product.sodiumMg100g!!, 1e-9) + + val request = server.takeRequest() + assertEquals("/api/v2/product/3017620422003", request.requestUrl?.encodedPath) + // fields= trims the payload to what the app actually stores. + assertEquals(OpenFoodFactsClient.PRODUCT_FIELDS, request.requestUrl?.queryParameter("fields")) + // OFF requires a custom User-Agent identifying the app + contact. + assertTrue(request.getHeader("User-Agent")!!.startsWith("PulseLoop/")) + } + + /** OFF answers 404 for unknown barcodes — a valid "not found", not an error. */ + @Test + fun product404ReturnsNull() = runBlocking { + server.enqueue(MockResponse().setResponseCode(404).setBody("""{"status":0}""")) + assertNull(client.product("0000000000000")) + } + + /** status 0 over a 200 also means "not found". */ + @Test + fun productStatusZeroReturnsNull() = runBlocking { + server.enqueue(MockResponse().setBody(v2ProductBody(status = 0))) + assertNull(client.product("3017620422003")) + } + + /** 429 is RateLimited, never retried — the caller must back off and offer manual entry. */ + @Test + fun product429ThrowsRateLimited() = runBlocking { + server.enqueue(MockResponse().setResponseCode(429)) + try { + client.product("3017620422003") + fail("expected OpenFoodFactsError.RateLimited") + } catch (expected: OpenFoodFactsError.RateLimited) { + } + assertEquals("one attempt only — a 429 is an answer, not a retry trigger", 1, server.requestCount) + } + + /** Any other non-2xx is a distinct HttpStatus error. */ + @Test + fun product500ThrowsHttpStatus() = runBlocking { + server.enqueue(MockResponse().setResponseCode(500)) + val thrown = try { + client.product("3017620422003"); null + } catch (e: OpenFoodFactsError) { + e + } + val http = thrown as? OpenFoodFactsError.HttpStatus + assertNotNull("expected OpenFoodFactsError.HttpStatus, got $thrown", http) + assertEquals(500, http!!.code) + } + + /** A transport failure (connection refused) is a Network error, not a Decoding one. */ + @Test + fun unreachableServerSurfacesAsNetworkError() = runBlocking { + val offline = OpenFoodFactsClient(productBase = "http://127.0.0.1:1") + try { + offline.product("123") + fail("expected OpenFoodFactsError.Network") + } catch (expected: OpenFoodFactsError.Network) { + } + } + + /** A 200 whose body is not JSON is a Decoding error. */ + @Test + fun nonJsonBodySurfacesAsDecodingError() = runBlocking { + server.enqueue(MockResponse().setBody("this is not json")) + try { + client.product("123") + fail("expected OpenFoodFactsError.Decoding") + } catch (expected: OpenFoodFactsError.Decoding) { + } + } + + @Test + fun searchReturnsNormalizedResultsAndSendsTheSearchContract() = runBlocking { + server.enqueue( + MockResponse().setBody( + """ + { + "hits": [ + {"code":"a","product_name":"Choc One","brands":["Bar","Brand"],"nutriments":{"energy-kcal_100g":100}}, + "malformed element", + {"code":"b","product_name":"Choc Two","nutriments":{"energy-kj_100g":4184}} + ] + } + """.trimIndent(), + ), + ) + val results = client.search("dark chocolate") + assertEquals(2, results.size) + assertEquals(listOf("a", "b"), results.map { it.code }) + assertEquals("Bar", results[0].brand) + assertEquals(1000.0, results[1].energyKcal100g, 1e-9) + + val request = server.takeRequest() + assertEquals("/search", request.requestUrl?.encodedPath) + // Query parameters are URL-encoded on the wire and come back decoded here. + assertEquals("dark chocolate", request.requestUrl?.queryParameter("q")) + // Default page size is 10 (iOS signature default). + assertEquals("10", request.requestUrl?.queryParameter("page_size")) + assertEquals(OpenFoodFactsClient.PRODUCT_FIELDS, request.requestUrl?.queryParameter("fields")) + assertTrue(request.getHeader("User-Agent")!!.startsWith("PulseLoop/")) + } + + @Test + fun searchRespectsAnExplicitPageSize() = runBlocking { + server.enqueue(MockResponse().setBody("""{"hits":[]}""")) + client.search("oat", pageSize = 3) + assertEquals("3", server.takeRequest().requestUrl?.queryParameter("page_size")) + } + + @Test + fun search429ThrowsRateLimited() = runBlocking { + server.enqueue(MockResponse().setResponseCode(429)) + try { + client.search("chocolate") + fail("expected OpenFoodFactsError.RateLimited") + } catch (expected: OpenFoodFactsError.RateLimited) { + } + assertEquals(1, server.requestCount) + } +} From 05d8833e594ffb2fad6b5e1e9a24e3941f1c6251 Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Sat, 22 Aug 2026 16:19:59 -0700 Subject: [PATCH 13/22] Port iOS #96 coach nutrition tools (search/log/get/update/delete meals) Stage 2 of #96: the coach tools that tie the OFF client (stage 1) to the coach, ported from NutritionTools.swift. - NutritionTools object: search_food_database (cache-first -> OFF -> labeled estimate on failure), get_nutrition_log (day's meals + totals), log_meal (immediate, loggedByCoach), update_meal_entry (today immediate / older via PendingAction), delete_meal_entry (always PendingAction). Names, labels, descriptions, strict JSON schemas, validation, and ToolResult shapes are verbatim from iOS. - foodClient added to ToolExecutionContext (default null) and wired at the single production construction site (PulseLoopApp toolContextFactory -> OpenFoodFactsClient(), one client per composition). - PendingActionKind gains UPDATE_MEAL_ENTRY / DELETE_MEAL_ENTRY (+ a MealUpdates payload mirroring ActivityUpdates) and a PendingActionExecutor meal branch (routed before the session lookup so a meal id never matches an activity id). - MealEntryDao.byId added (query-only, no migration). - ToolRegistry: NutritionTools.all in the read set, writeTools gated by flags.writeToolsEnabled (iOS enableWriteTools). meal_type/source/confidence map to the raw strings the app already stores (breakfast/lunch/dinner/snack; off_search/llm_estimate; known/partial/unknown). Tests (18): the pure logic factored into internal members (resolveTimestamp, source/confidence mapping, limit clamp, query validation, payload builder, applyMealUpdates) is unit-tested directly. Known gap (noted, not fabricated): the Android confirm-card UI is not wired (CoachActionCardView has no call sites; PendingActionExecutor.execute has no production caller), so needs_confirmation results return to the model but no card renders until that pre-existing UI is built; log_meal still inserts the row (the core behavior). Self-review (compile) fixed: a non-exhaustive when after the new PendingAction kinds, a missing Double.roundToLong, resolveTimestamp calling atZone/atTime on Long/ZonedDateTime (rewrote on LocalDate), and the test's JsonPrimitive.double. Reference: android/docs/ios-sync.md #96 (stage 2 of 4). --- .../coach/orchestration/PendingAction.kt | 21 + .../orchestration/PendingActionExecutor.kt | 40 ++ .../com/pulseloop/coach/tools/CoachTool.kt | 7 + .../pulseloop/coach/tools/NutritionTools.kt | 545 ++++++++++++++++++ .../com/pulseloop/coach/tools/ToolRegistry.kt | 7 +- .../main/java/com/pulseloop/data/dao/Daos.kt | 5 + .../java/com/pulseloop/ui/PulseLoopApp.kt | 7 + .../coach/tools/NutritionToolsTest.kt | 221 +++++++ 8 files changed, 851 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/pulseloop/coach/tools/NutritionTools.kt create mode 100644 app/src/test/java/com/pulseloop/coach/tools/NutritionToolsTest.kt diff --git a/app/src/main/java/com/pulseloop/coach/orchestration/PendingAction.kt b/app/src/main/java/com/pulseloop/coach/orchestration/PendingAction.kt index 6ee7e29a..10aba8a7 100644 --- a/app/src/main/java/com/pulseloop/coach/orchestration/PendingAction.kt +++ b/app/src/main/java/com/pulseloop/coach/orchestration/PendingAction.kt @@ -16,6 +16,7 @@ data class PendingAction( val summary: String, // human-readable description for the card val confirmLabel: String, val updates: ActivityUpdates? = null, // only for updateActivitySession + val mealUpdates: MealUpdates? = null, // only for updateMealEntry (iOS #96) ) { fun toJson(): String = Json.encodeToString(serializer(), this) @@ -32,6 +33,11 @@ data class PendingAction( enum class PendingActionKind { DELETE_ACTIVITY_SESSION, UPDATE_ACTIVITY_SESSION, + // Meal actions (iOS #96). The target meal id rides [PendingAction.activityId] — the field + // is named for the original activity actions and meal actions reuse it so older persisted + // cards keep decoding. + DELETE_MEAL_ENTRY, + UPDATE_MEAL_ENTRY, } /** @@ -46,3 +52,18 @@ data class ActivityUpdates( val perceivedEffort: String? = null, val startTime: String? = null, ) + +/** + * Field updates for updateMealEntry (null = leave unchanged). + * Ported from MealUpdates in PendingAction.swift (iOS PR #96). + */ +@Serializable +data class MealUpdates( + val name: String? = null, + val mealType: String? = null, + val calories: Double? = null, + val proteinG: Double? = null, + val carbsG: Double? = null, + val fatG: Double? = null, + val notes: String? = null, +) diff --git a/app/src/main/java/com/pulseloop/coach/orchestration/PendingActionExecutor.kt b/app/src/main/java/com/pulseloop/coach/orchestration/PendingActionExecutor.kt index 25f888fb..e87da0de 100644 --- a/app/src/main/java/com/pulseloop/coach/orchestration/PendingActionExecutor.kt +++ b/app/src/main/java/com/pulseloop/coach/orchestration/PendingActionExecutor.kt @@ -18,6 +18,14 @@ object PendingActionExecutor { ) suspend fun execute(action: PendingAction, db: PulseLoopDatabase): String { + // Meal actions (iOS #96) target a MealEntry, not an ActivitySession — branch before + // the session lookup so a meal id is never matched against activity ids. + if (action.kind == PendingActionKind.UPDATE_MEAL_ENTRY || + action.kind == PendingActionKind.DELETE_MEAL_ENTRY + ) { + return executeMeal(action, db) + } + val sessions = db.activitySessionDao().recent(200) val session = sessions.firstOrNull { it.id == action.activityId } ?: return "That workout no longer exists." @@ -25,6 +33,10 @@ object PendingActionExecutor { val typeLabel = activityLabels[session.type] ?: session.type return when (action.kind) { + // Meal kinds are handled by the early return above (before the session lookup, since + // a meal id is never an activity id); these keep the when exhaustive. + PendingActionKind.DELETE_MEAL_ENTRY, + PendingActionKind.UPDATE_MEAL_ENTRY -> executeMeal(action, db) PendingActionKind.DELETE_ACTIVITY_SESSION -> { com.pulseloop.service.ActivityRollup.reverse(db, session) db.activitySessionDao().upsert( @@ -43,6 +55,34 @@ object PendingActionExecutor { } } + /** + * Ported from executeMeal in PendingActionExecutor.swift (iOS PR #96). The meal id rides + * [PendingAction.activityId] — the field is named for the original activity actions and + * meal actions reuse it (older persisted cards keep decoding). Updates apply through + * [com.pulseloop.coach.tools.NutritionTools.applyMealUpdates], the same pure function the + * today-path of the update_meal_entry tool uses, so both paths behave identically. + */ + private suspend fun executeMeal(action: PendingAction, db: PulseLoopDatabase): String { + val entry = db.mealEntryDao().byId(action.activityId) + ?: return "That meal no longer exists." + return when (action.kind) { + PendingActionKind.DELETE_MEAL_ENTRY -> { + val name = entry.name + db.mealEntryDao().deleteById(entry.id) + "Deleted \"$name\"." + } + PendingActionKind.UPDATE_MEAL_ENTRY -> { + val updated = action.mealUpdates?.let { + com.pulseloop.coach.tools.NutritionTools.applyMealUpdates(it, entry) + } ?: entry + db.mealEntryDao().upsert(updated) + "Updated \"${updated.name}\"." + } + // Unreachable: execute() routes only the two meal kinds here. + else -> "" + } + } + /** * What an [ActivityUpdates] resolves to against a specific session: the target type/start/end * plus whether that window actually differs from the session's current one. Pure — no DB — diff --git a/app/src/main/java/com/pulseloop/coach/tools/CoachTool.kt b/app/src/main/java/com/pulseloop/coach/tools/CoachTool.kt index bce21e58..287fb2ce 100644 --- a/app/src/main/java/com/pulseloop/coach/tools/CoachTool.kt +++ b/app/src/main/java/com/pulseloop/coach/tools/CoachTool.kt @@ -35,6 +35,13 @@ data class ToolExecutionContext( val flags: CoachFeatureFlags = CoachFeatureFlags(), val coordinator: com.pulseloop.service.RingSyncCoordinator? = null, // for live measurements val pendingActions: MutableList = mutableListOf(), + /** + * Open Food Facts lookup for the nutrition tools (iOS #96 `search_food_database`). + * Null = the search tool reports `database_unavailable` and the model must fall back to a + * labeled estimate; defaults to null so test harnesses and pre-existing construction sites + * compile unchanged (iOS's ToolExecutionContext has the same optional foodClient). + */ + val foodClient: com.pulseloop.nutrition.FoodDatabaseClient? = null, ) data class CoachFeatureFlags( diff --git a/app/src/main/java/com/pulseloop/coach/tools/NutritionTools.kt b/app/src/main/java/com/pulseloop/coach/tools/NutritionTools.kt new file mode 100644 index 00000000..c1c6348d --- /dev/null +++ b/app/src/main/java/com/pulseloop/coach/tools/NutritionTools.kt @@ -0,0 +1,545 @@ +package com.pulseloop.coach.tools + +import com.pulseloop.coach.orchestration.MealUpdates +import com.pulseloop.coach.orchestration.PendingAction +import com.pulseloop.coach.orchestration.PendingActionKind +import com.pulseloop.data.entity.MealEntryEntity +import com.pulseloop.nutrition.FoodProduct +import com.pulseloop.nutrition.FoodProductCache +import com.pulseloop.nutrition.asFoodProduct +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.temporal.ChronoUnit + +/** + * Ported from [NutritionTools] in NutritionTools.swift (iOS PR #96). + * + * The read tools are grounding: `search_food_database` is cache-first (the local + * `food_products` table, zero network) and only falls through to Open Food Facts on a cache + * miss; on any lookup failure it returns a structured error telling the model to fall back to a + * *labeled* estimate — numbers are grounded or flagged, never silently invented. + * `get_nutrition_log` reads the day's meals + totals straight off the DAOs. + * + * The write tools carry the same risk model as ActionTools: `log_meal` applies immediately + * (`loggedByCoach = true`), `update_meal_entry` applies immediately for a meal logged today + * but routes older meals through a Confirm/Cancel [PendingAction], and `delete_meal_entry` + * always goes through a [PendingAction]. Registration keeps the same split — [all] in the + * always-on set, [writeTools] behind `flags.writeToolsEnabled` (iOS `enableWriteTools`). + */ + +/** Rounds to the nearest whole number (iOS `.rounded()`) for the integer tool payloads. */ +private fun Double.roundToLong(): Long = Math.round(this) + +object NutritionTools { + /** + * Meal types as the raw strings [MealEntryEntity.mealTypeRaw] stores — the same four + * values MealLogDialog's picker offers (iOS `MealType` rawValues: breakfast/lunch/dinner/ + * snack). The entity has no enum column, so validation is against this set. + */ + internal val mealTypeRawValues = setOf("breakfast", "lunch", "dinner", "snack") + + /** + * The sourceRaw values a coach-logged row can carry (iOS `MealEntrySource` rawValues — + * "Raw values are persisted; append, never rename"). The app's own meal-logging UI writes + * only the entity default ("manual"), so the database/estimate split below uses iOS's + * canonical strings; the archive and Health Connect exporters round-trip any of them. + */ + internal const val sourceRawOffSearch = "off_search" // OFF text-search pick + internal const val sourceRawLlmEstimate = "llm_estimate" // coach estimate, no grounding + internal const val sourceRawManual = "manual" // user typed the numbers + + val all: List = listOf(makeSearchFoodDatabase(), makeGetNutritionLog()) + val writeTools: List = + listOf(makeLogMeal(), makeUpdateMealEntry(), makeDeleteMealEntry()) + + // ── search_food_database ─────────────────────────────────────────── + + /** + * Grounding tool (iOS #96): cache-first, then Open Food Facts. On rate-limit or network + * failure it returns a structured error instructing the model to fall back to a *labeled* + * estimate — numbers are grounded or flagged, never silently invented. + */ + private fun makeSearchFoodDatabase() = CoachToolDef( + name = "search_food_database", + publicLabel = "Checking the food database", + description = "Search Open Food Facts for a food's verified nutrition (per 100 g). " + + "Use before logging any nameable or packaged food. " + + "If it errors, estimate instead and say so, with source 'estimate'.", + parameters = JsonObject(mapOf( + "type" to JsonPrimitive("object"), + "properties" to JsonObject(mapOf( + "query" to JsonObject(mapOf("type" to JsonPrimitive("string"))), + "max_results" to nullableNumber(), + )), + "required" to JsonArray(listOf(JsonPrimitive("query"), JsonPrimitive("max_results"))), + "additionalProperties" to JsonPrimitive(false), + )), + ) { args, ctx -> + val params = parseArgs(args) ?: return@CoachToolDef ToolResult("""{"error":"invalid arguments"}""", isError = true) + val rawQuery = params["query"]?.jsonPrimitive?.contentOrNull + ?: return@CoachToolDef ToolResult("""{"error":"missing 'query' argument"}""", isError = true) + val query = rawQuery.trim() + // iOS: min(5, max(1, Int(maxResults ?? 5))) — clamp, not trust. + val limit = clampSearchLimit(params["max_results"]?.jsonPrimitive?.doubleOrNull) + searchQueryError(query)?.let { err -> + return@CoachToolDef ToolResult("""{"error":"$err"}""", isError = true) + } + val db = ctx.db + // Without a database the Room product cache is unreachable, and fetched products must + // be cached back into it — so db == null degrades to the same "unavailable" path as a + // missing client (iOS's modelContext is never null; this keeps the Android null-db + // contract identical to the other tools). + val client = ctx.foodClient + if (db == null || client == null) { + return@CoachToolDef ToolResult( + """{"ok":false,"error":"database_unavailable","instruction":"Food database unavailable. Estimate nutrition yourself, set source to 'estimate', and tell the user the numbers are estimated."}""" + ) + } + val dao = db.foodProductDao() + val result = kotlinx.coroutines.runBlocking { + // Cache-first: substring match against locally cached products (zero network). + val cached = FoodProductCache.recent(dao, 100) + .filter { it.name.contains(query, ignoreCase = true) } + .take(limit) + if (cached.isNotEmpty()) { + resultsJson(cached.map { it.asFoodProduct() }, "local_cache") + } else { + try { + val results = client.search(query, limit) + // Every fetched product is cached so repeat lookups stay off the rate-limited API. + results.forEach { FoodProductCache.upsertCached(dao, it) } + resultsJson(results, "open_food_facts") + } catch (_: Exception) { + // Rate limit (HTTP 429) or any network/decode failure: the same honest fallback. + """{"ok":false,"error":"lookup_failed","instruction":"Food database lookup failed. Estimate nutrition yourself, set source to 'estimate', and tell the user the numbers are estimated."}""" + } + } + } + ToolResult(result) + } + + /** The `results` envelope shared by the cache and network paths. */ + private fun resultsJson(results: List, source: String): String = buildJsonObject { + put("ok", true) + put("source", source) + put("results", JsonArray(results.map { foodProductPayload(it) })) + }.toString() + + // ── get_nutrition_log ────────────────────────────────────────────── + + /** + * Read tool (iOS #96): the day's logged meals + totals, so the coach can answer + * "what did I eat" for any day without the context packet carrying history. + */ + private fun makeGetNutritionLog() = CoachToolDef( + name = "get_nutrition_log", + publicLabel = "Reading your food log", + description = "Get the user's logged meals and calorie/macro totals for a date (YYYY-MM-DD).", + parameters = JsonObject(mapOf( + "type" to JsonPrimitive("object"), + "properties" to JsonObject(mapOf("date" to JsonObject(mapOf("type" to JsonPrimitive("string"))))), + "required" to JsonArray(listOf(JsonPrimitive("date"))), + "additionalProperties" to JsonPrimitive(false), + )), + ) { args, ctx -> + val db = ctx.db ?: return@CoachToolDef ToolResult("""{"error":"database not available"}""", isError = true) + val params = parseArgs(args) ?: return@CoachToolDef ToolResult("""{"error":"invalid arguments"}""", isError = true) + val date = params["date"]?.jsonPrimitive?.contentOrNull + ?: return@CoachToolDef ToolResult("""{"error":"missing 'date' argument"}""", isError = true) + // Same helper the retrieval tools use (iOS CoachDataAccess.parseLocalDate). + val day = CoachDataAccess.parseLocalDate(date) + ?: return@CoachToolDef ToolResult("""{"error":"invalid date '$date' — use YYYY-MM-DD"}""", isError = true) + val result = kotlinx.coroutines.runBlocking { + val entries = db.mealEntryDao().byDay(day) + // iOS totals(of:) sums at read time — no stored daily row to keep in sync. + val kcal = entries.sumOf { it.calories } + val protein = entries.sumOf { it.proteinG } + val carbs = entries.sumOf { it.carbsG } + val fat = entries.sumOf { it.fatG } + val meals = entries.map { e -> + buildJsonObject { + put("meal_id", e.id) + put("name", e.name) + put("meal_type", e.mealTypeRaw) + put("time", localTimeString(e.timestamp)) + put("kcal", e.calories.roundToLong()) + put("protein_g", e.proteinG) + put("carbs_g", e.carbsG) + put("fat_g", e.fatG) + put("source", e.sourceRaw) + } + } + buildJsonObject { + put("ok", true) + put("date", date) + put("entry_count", entries.size) + putJsonObject("totals") { + put("kcal", kcal.roundToLong()) + put("protein_g", protein) + put("carbs_g", carbs) + put("fat_g", fat) + } + put("meals", JsonArray(meals)) + }.toString() + } + ToolResult(result) + } + + // ── log_meal ─────────────────────────────────────────────────────── + + /** + * Write tool (iOS #96): applies immediately — logging a meal is the low-risk write. + * Values are TOTALS for what was eaten. The honesty core is the source mapping: + * "database" + an OFF product code is the only way a row becomes database-verified + * (sourceRaw off_search); everything else is llm_estimate, with the model instructed + * to state portion assumptions and confidence. + */ + private fun makeLogMeal() = CoachToolDef( + name = "log_meal", + publicLabel = "Logging your meal", + description = "Log a meal/food/drink the user ate. Values are TOTALS for what was eaten. " + + "Ground nameable foods via search_food_database first (source 'database'); " + + "for home-cooked or unverifiable food use source 'estimate' with honest confidence " + + "and stated portion assumptions. One call per meal.", + parameters = JsonObject(mapOf( + "type" to JsonPrimitive("object"), + "properties" to JsonObject(mapOf( + "name" to JsonObject(mapOf("type" to JsonPrimitive("string"))), + "meal_type" to JsonObject(mapOf("type" to JsonPrimitive("string"), "enum" to mealTypeEnumArray())), + "date" to JsonObject(mapOf("type" to JsonPrimitive("string"))), + "time" to nullableString(), + "calories" to JsonObject(mapOf("type" to JsonPrimitive("number"))), + "protein_g" to nullableNumber(), + "carbs_g" to nullableNumber(), + "fat_g" to nullableNumber(), + "fiber_g" to nullableNumber(), + "sugar_g" to nullableNumber(), + "sodium_mg" to nullableNumber(), + "quantity" to nullableNumber(), + "serving_description" to nullableString(), + "source" to JsonObject(mapOf( + "type" to JsonPrimitive("string"), + "enum" to JsonArray(listOf(JsonPrimitive("database"), JsonPrimitive("estimate"))), + )), + "off_product_code" to nullableString(), + "confidence" to JsonObject(mapOf( + "type" to JsonPrimitive("string"), + "enum" to JsonArray(listOf(JsonPrimitive("low"), JsonPrimitive("medium"), JsonPrimitive("high"))), + )), + "notes" to nullableString(), + )), + // Strict schema (as on iOS): every argument is required, optional values sent as JSON null. + "required" to JsonArray(listOf( + JsonPrimitive("name"), JsonPrimitive("meal_type"), JsonPrimitive("date"), + JsonPrimitive("time"), JsonPrimitive("calories"), JsonPrimitive("protein_g"), + JsonPrimitive("carbs_g"), JsonPrimitive("fat_g"), JsonPrimitive("fiber_g"), + JsonPrimitive("sugar_g"), JsonPrimitive("sodium_mg"), JsonPrimitive("quantity"), + JsonPrimitive("serving_description"), JsonPrimitive("source"), + JsonPrimitive("off_product_code"), JsonPrimitive("confidence"), JsonPrimitive("notes"), + )), + "additionalProperties" to JsonPrimitive(false), + )), + ) { args, ctx -> + val db = ctx.db ?: return@CoachToolDef ToolResult("""{"error":"database not available"}""", isError = true) + val params = parseArgs(args) ?: return@CoachToolDef ToolResult("""{"error":"invalid arguments"}""", isError = true) + val name = params["name"]?.jsonPrimitive?.contentOrNull + ?: return@CoachToolDef ToolResult("""{"error":"missing 'name' argument"}""", isError = true) + val mealType = params["meal_type"]?.jsonPrimitive?.contentOrNull + ?: return@CoachToolDef ToolResult("""{"error":"missing 'meal_type' argument"}""", isError = true) + // iOS validates the raw string against MealType.allCases before touching the clock or DB. + if (mealType !in mealTypeRawValues) { + return@CoachToolDef ToolResult("""{"error":"invalid meal_type '$mealType'"}""", isError = true) + } + val date = params["date"]?.jsonPrimitive?.contentOrNull + ?: return@CoachToolDef ToolResult("""{"error":"missing 'date' argument"}""", isError = true) + val time = params["time"]?.jsonPrimitive?.contentOrNull + val calories = params["calories"]?.jsonPrimitive?.doubleOrNull + ?: return@CoachToolDef ToolResult("""{"error":"missing 'calories' argument"}""", isError = true) + // Plausibility gate (iOS: 0...6000) — a typo'd value is a tool error, not a stored row. + if (calories < 0.0 || calories > 6000.0) { + return@CoachToolDef ToolResult("""{"error":"calories out of plausible range"}""", isError = true) + } + val timestamp = resolveTimestamp(date, time) + val sourceRaw = resolveSourceRaw( + params["source"]?.jsonPrimitive?.contentOrNull, + params["off_product_code"]?.jsonPrimitive?.contentOrNull, + ) + val entry = MealEntryEntity( + date = CoachDataAccess.startOfDay(timestamp), + timestamp = timestamp, + name = name, + mealTypeRaw = mealType, + calories = calories, + proteinG = params["protein_g"]?.jsonPrimitive?.doubleOrNull ?: 0.0, + carbsG = params["carbs_g"]?.jsonPrimitive?.doubleOrNull ?: 0.0, + fatG = params["fat_g"]?.jsonPrimitive?.doubleOrNull ?: 0.0, + // Nil must stay distinct from 0 (unknown vs measured zero) — the entity's nullable + // columns keep that, matching iOS's "nil means unknown" comment. + fiberG = params["fiber_g"]?.jsonPrimitive?.doubleOrNull, + sugarG = params["sugar_g"]?.jsonPrimitive?.doubleOrNull, + sodiumMg = params["sodium_mg"]?.jsonPrimitive?.doubleOrNull, + sourceRaw = sourceRaw, + offProductCode = params["off_product_code"]?.jsonPrimitive?.contentOrNull, + servingDescription = params["serving_description"]?.jsonPrimitive?.contentOrNull, + quantity = params["quantity"]?.jsonPrimitive?.doubleOrNull ?: 1.0, + confidenceRaw = decodeConfidenceRaw(params["confidence"]?.jsonPrimitive?.contentOrNull), + notes = params["notes"]?.jsonPrimitive?.contentOrNull, + loggedByCoach = true, + ) + kotlinx.coroutines.runBlocking { db.mealEntryDao().upsert(entry) } + // iOS appends entry.id to ctx.loggedMealIds so the chat renders a tappable meal card. + // Android has no logged-meal card mechanism yet (nothing in the coach chat consumes + // one), so the insert IS the core behavior here; the in-chat tappable card is a known + // gap on Android rather than a fabricated feature. + ToolResult(buildJsonObject { + put("ok", true) + put("meal_id", entry.id) + put("name", name) + put("kcal", calories.roundToLong()) + put("source", sourceRaw) + put("note", "Logged. The user can adjust it in the food log.") + }.toString()) + } + + // ── update_meal_entry ────────────────────────────────────────────── + + /** + * Write tool (iOS #96): today's meals edit in place immediately; older meals are + * higher-stakes (they can shift historical totals and exports) and go through a + * Confirm/Cancel [PendingAction] — the same risk split as update_activity_session. + */ + private fun makeUpdateMealEntry() = CoachToolDef( + name = "update_meal_entry", + publicLabel = "Updating that meal", + description = "Edit a logged meal. Applies immediately for a meal logged today; " + + "for an older meal, returns needs_confirmation and shows a Confirm card.", + parameters = JsonObject(mapOf( + "type" to JsonPrimitive("object"), + "properties" to JsonObject(mapOf( + "meal_id" to JsonObject(mapOf("type" to JsonPrimitive("string"))), + "name" to nullableString(), + // iOS: ["string", "null"] plus the meal-type enum with null appended. + "meal_type" to JsonObject(mapOf( + "type" to JsonArray(listOf(JsonPrimitive("string"), JsonPrimitive("null"))), + "enum" to JsonArray(mealTypeRawValues.map { JsonPrimitive(it) } + JsonNull), + )), + "calories" to nullableNumber(), + "protein_g" to nullableNumber(), + "carbs_g" to nullableNumber(), + "fat_g" to nullableNumber(), + "notes" to nullableString(), + "reason" to JsonObject(mapOf("type" to JsonPrimitive("string"))), + )), + "required" to JsonArray(listOf( + JsonPrimitive("meal_id"), JsonPrimitive("name"), JsonPrimitive("meal_type"), + JsonPrimitive("calories"), JsonPrimitive("protein_g"), JsonPrimitive("carbs_g"), + JsonPrimitive("fat_g"), JsonPrimitive("notes"), JsonPrimitive("reason"), + )), + "additionalProperties" to JsonPrimitive(false), + )), + ) { args, ctx -> + val db = ctx.db ?: return@CoachToolDef ToolResult("""{"error":"database not available"}""", isError = true) + val params = parseArgs(args) ?: return@CoachToolDef ToolResult("""{"error":"invalid arguments"}""", isError = true) + val mealId = params["meal_id"]?.jsonPrimitive?.contentOrNull + ?: return@CoachToolDef ToolResult("""{"error":"missing 'meal_id' argument"}""", isError = true) + val entry = kotlinx.coroutines.runBlocking { db.mealEntryDao().byId(mealId) } + ?: return@CoachToolDef ToolResult("""{"error":"meal '$mealId' not found"}""", isError = true) + + val updates = MealUpdates( + name = params["name"]?.jsonPrimitive?.contentOrNull, + mealType = params["meal_type"]?.jsonPrimitive?.contentOrNull, + calories = params["calories"]?.jsonPrimitive?.doubleOrNull, + proteinG = params["protein_g"]?.jsonPrimitive?.doubleOrNull, + carbsG = params["carbs_g"]?.jsonPrimitive?.doubleOrNull, + fatG = params["fat_g"]?.jsonPrimitive?.doubleOrNull, + notes = params["notes"]?.jsonPrimitive?.contentOrNull, + ) + + // Today = same local calendar day as the entry's timestamp (iOS isDateInToday). + val todayStart = CoachDataAccess.startOfDay(System.currentTimeMillis()) + if (CoachDataAccess.startOfDay(entry.timestamp) == todayStart) { + kotlinx.coroutines.runBlocking { db.mealEntryDao().upsert(applyMealUpdates(updates, entry)) } + // iOS also appends to ctx.loggedMealIds (tappable card refresh); no Android + // equivalent yet — see log_meal's note. + ToolResult("""{"ok":true,"updated":true,"meal_id":"$mealId"}""") + } else { + ctx.pendingActions.add(PendingAction( + kind = PendingActionKind.UPDATE_MEAL_ENTRY, + activityId = mealId, + summary = "Update \"${entry.name}\" from ${CoachDataAccess.localDateString(entry.timestamp)}?", + confirmLabel = "Save changes", + mealUpdates = updates, + )) + ToolResult("""{"ok":true,"needs_confirmation":true,"summary":"Awaiting your confirmation to edit that meal."}""") + } + } + + // ── delete_meal_entry ────────────────────────────────────────────── + + /** + * Write tool (iOS #96): ALWAYS confirms — a deletion is the one meal write that can't be + * re-derived from the conversation, so it never applies in-turn. + */ + private fun makeDeleteMealEntry() = CoachToolDef( + name = "delete_meal_entry", + publicLabel = "Removing that meal", + description = "Delete a logged meal. Always returns needs_confirmation and shows a " + + "Confirm card; the deletion only happens after the user taps Confirm.", + parameters = JsonObject(mapOf( + "type" to JsonPrimitive("object"), + "properties" to JsonObject(mapOf( + "meal_id" to JsonObject(mapOf("type" to JsonPrimitive("string"))), + "reason" to JsonObject(mapOf("type" to JsonPrimitive("string"))), + )), + "required" to JsonArray(listOf(JsonPrimitive("meal_id"), JsonPrimitive("reason"))), + "additionalProperties" to JsonPrimitive(false), + )), + ) { args, ctx -> + val db = ctx.db ?: return@CoachToolDef ToolResult("""{"error":"database not available"}""", isError = true) + val params = parseArgs(args) ?: return@CoachToolDef ToolResult("""{"error":"invalid arguments"}""", isError = true) + val mealId = params["meal_id"]?.jsonPrimitive?.contentOrNull + ?: return@CoachToolDef ToolResult("""{"error":"missing 'meal_id' argument"}""", isError = true) + val entry = kotlinx.coroutines.runBlocking { db.mealEntryDao().byId(mealId) } + ?: return@CoachToolDef ToolResult("""{"error":"meal '$mealId' not found"}""", isError = true) + ctx.pendingActions.add(PendingAction( + kind = PendingActionKind.DELETE_MEAL_ENTRY, + activityId = mealId, + summary = "Delete \"${entry.name}\" (${entry.calories.roundToLong()} kcal) from ${CoachDataAccess.localDateString(entry.timestamp)}?", + confirmLabel = "Delete", + )) + ToolResult("""{"ok":true,"needs_confirmation":true,"summary":"Awaiting your confirmation to delete that meal."}""") + } + + // ── shared ───────────────────────────────────────────────────────── + + private fun parseArgs(args: String): JsonObject? = + try { Json { ignoreUnknownKeys = true }.decodeFromString(args) } catch (_: Exception) { null } + + private fun nullableNumber() = JsonObject(mapOf( + "type" to JsonArray(listOf(JsonPrimitive("number"), JsonPrimitive("null"))), + )) + + private fun nullableString() = JsonObject(mapOf( + "type" to JsonArray(listOf(JsonPrimitive("string"), JsonPrimitive("null"))), + )) + + private fun mealTypeEnumArray() = JsonArray(mealTypeRawValues.map { JsonPrimitive(it) }) + + // ── pure logic (unit-tested in NutritionToolsTest) ───────────────── + // The tool bodies need a concrete Room db (no in-memory harness in the repo), so the + // behavior that can drift — timestamp resolution, the source/confidence raw mapping, the + // search clamp, the payload shape, and the update application — lives in these pure + // members, exactly as the private statics live on iOS's NutritionTools. + + /** + * Ported from resolveTimestamp in NutritionTools.swift. Combines a YYYY-MM-DD date with an + * optional HH:mm time; an unstated time on a past day lands at noon, today at the current + * clock time. `now`/`zone` are injectable so the branching is unit-testable. + */ + internal fun resolveTimestamp( + date: String, + time: String?, + now: Long = System.currentTimeMillis(), + zone: ZoneId = ZoneId.systemDefault(), + ): Long { + val day: LocalDate = CoachDataAccess.parseLocalDate(date) + ?.let { Instant.ofEpochMilli(it).atZone(zone).toLocalDate() } + ?: Instant.ofEpochMilli(now).atZone(zone).toLocalDate() + if (time != null) { + // iOS splits on ":" and Int-parses; a time that can't be stamped (e.g. 25:99) + // falls through to the noon/now default instead of erroring. + val parts = time.split(":").mapNotNull { it.toIntOrNull() } + if (parts.size >= 2 && parts[0] in 0..23 && parts[1] in 0..59) { + return day.atTime(parts[0], parts[1]).atZone(zone).toInstant().toEpochMilli() + } + } + if (day == Instant.ofEpochMilli(now).atZone(zone).toLocalDate()) return now + return day.atTime(12, 0).atZone(zone).toInstant().toEpochMilli() + } + + /** iOS: min(5, max(1, Int(maxResults ?? 5))) — truncate, then clamp to 1...5. */ + internal fun clampSearchLimit(maxResults: Double?): Int = + minOf(5, maxOf(1, (maxResults?.toInt() ?: 5))) + + /** iOS: the trimmed query must be at least 2 chars, else the tool errors "query too short". */ + internal fun searchQueryError(query: String): String? = + if (query.trim().length < 2) "query too short" else null + + /** + * iOS log_meal source mapping: "database" + an OFF product code → off_search (the only + * coach path to a database-verified row); anything else → llm_estimate. Numbers are + * grounded or flagged, never silently invented. + */ + internal fun resolveSourceRaw(source: String?, offProductCode: String?): String = + if (source == "database" && offProductCode != null) sourceRawOffSearch else sourceRawLlmEstimate + + /** + * iOS decodeConfidence: "high" → known, "medium" → partial, anything else → unknown. + * The stored strings are iOS DecodeConfidence's rawValues (known/partial/unknown) — + * the raw values the iOS entity persists. + */ + internal fun decodeConfidenceRaw(raw: String?): String = when (raw) { + "high" -> "known" + "medium" -> "partial" + else -> "unknown" + } + + /** + * Per-product payload for search_food_database results — mirrors iOS `payload(_)`: + * code/name/per_100g always; brand/serving/serving_g only when present. + */ + internal fun foodProductPayload(product: FoodProduct): JsonObject = buildJsonObject { + put("code", product.code) + put("name", product.name) + putJsonObject("per_100g") { + put("kcal", product.energyKcal100g.roundToLong()) + put("protein_g", product.protein100g) + put("carbs_g", product.carbs100g) + put("fat_g", product.fat100g) + } + product.brand?.let { put("brand", it) } + product.servingSizeText?.let { put("serving", it) } + product.servingQuantityG?.let { put("serving_g", it) } + } + + /** + * Ported from NutritionTools.apply in NutritionTools.swift (iOS #96). Pure over the Room + * entity (copy-based, no DAO): applies the non-null fields, marks the row edited when a + * number changed on a non-manual row, and bumps updatedAt. Both the today-path of + * update_meal_entry and the [PendingActionExecutor] confirm path persist the returned copy, + * so an immediate edit and a confirmed older-edit behave identically. + */ + internal fun applyMealUpdates(updates: MealUpdates, entry: MealEntryEntity): MealEntryEntity { + var numbersChanged = false + var next = entry + updates.name?.let { next = next.copy(name = it) } + // iOS guards with MealType(rawValue:) — an unknown type is ignored, not an error. + updates.mealType?.let { if (it in mealTypeRawValues) next = next.copy(mealTypeRaw = it) } + updates.calories?.let { next = next.copy(calories = it); numbersChanged = true } + updates.proteinG?.let { next = next.copy(proteinG = it); numbersChanged = true } + updates.carbsG?.let { next = next.copy(carbsG = it); numbersChanged = true } + updates.fatG?.let { next = next.copy(fatG = it); numbersChanged = true } + updates.notes?.let { next = next.copy(notes = it) } + // A user-requested correction to a database/estimate row marks it edited. + if (numbersChanged && next.sourceRaw != sourceRawManual) next = next.copy(userEdited = true) + return next.copy(updatedAt = System.currentTimeMillis()) + } + + /** iOS CoachDataAccess.localTimeString: "HH:mm" in the (injectable) zone. */ + internal fun localTimeString(ts: Long, zone: ZoneId = ZoneId.systemDefault()): String { + val t = Instant.ofEpochMilli(ts).atZone(zone) + return "${t.hour.toString().padStart(2, '0')}:${t.minute.toString().padStart(2, '0')}" + } +} diff --git a/app/src/main/java/com/pulseloop/coach/tools/ToolRegistry.kt b/app/src/main/java/com/pulseloop/coach/tools/ToolRegistry.kt index 604455ea..464e6ffb 100644 --- a/app/src/main/java/com/pulseloop/coach/tools/ToolRegistry.kt +++ b/app/src/main/java/com/pulseloop/coach/tools/ToolRegistry.kt @@ -10,8 +10,11 @@ class ToolRegistry(private val flags: CoachFeatureFlags) { private val tools: Map init { - val all = RetrievalTools.all + AnalysisTools.all + ChartTools.all - val writable = if (flags.writeToolsEnabled) MemoryTools.all + ActionTools.writeTools else emptyList() + // Nutrition read tools are grounding (always available, like the other read tools); + // the write tools are gated on flags.writeToolsEnabled, mirroring iOS's + // enableWriteTools split in NutritionTools.swift (iOS PR #96). + val all = RetrievalTools.all + AnalysisTools.all + ChartTools.all + NutritionTools.all + val writable = if (flags.writeToolsEnabled) MemoryTools.all + ActionTools.writeTools + NutritionTools.writeTools else emptyList() val live = if (flags.liveMeasurementsEnabled) ActionTools.measurementTools else emptyList() tools = (all + writable + live).associateBy { it.name } } diff --git a/app/src/main/java/com/pulseloop/data/dao/Daos.kt b/app/src/main/java/com/pulseloop/data/dao/Daos.kt index 4fa72ddb..2e058048 100644 --- a/app/src/main/java/com/pulseloop/data/dao/Daos.kt +++ b/app/src/main/java/com/pulseloop/data/dao/Daos.kt @@ -545,6 +545,11 @@ interface MealEntryDao { @Query("SELECT * FROM meal_entries WHERE updatedAt > :watermark AND sourceRaw NOT IN ('demo','mock') ORDER BY updatedAt ASC") suspend fun updatedSince(watermark: Long): List + /** Coach update/delete meal tools (iOS #96): single-entry lookup by primary key. + * Query-only — no schema change. (Deletion already exists as [deleteById].) */ + @Query("SELECT * FROM meal_entries WHERE id = :id LIMIT 1") + suspend fun byId(id: String): MealEntryEntity? + @Upsert suspend fun upsert(entry: MealEntryEntity) diff --git a/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt b/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt index 05c51527..7b4cb38f 100644 --- a/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt +++ b/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt @@ -103,6 +103,12 @@ fun PulseLoopApp() { // without rebuilding the orchestrator — a frozen flags snapshot would // keep coachEnabled=false after a key is pasted, or send a stale model // slug to a newly selected provider, until process restart. + // One Open Food Facts client for the lifetime of composition: the nutrition tools + // (iOS #96 `search_food_database`) reach it through the per-turn ToolExecutionContext. + // toolContextFactory is called once per turn, so remembering the client keeps the + // OkHttp stack off the per-turn rebuild path. + val foodClient = remember { com.pulseloop.nutrition.OpenFoodFactsClient() } + val coachOrchestrator = remember { CoachOrchestrator( com.pulseloop.coach.config.CoachClientResolver.clientFactory(providerStore, apiKeyStore), @@ -126,6 +132,7 @@ fun PulseLoopApp() { db = db, flags = flags, coordinator = coordinator, + foodClient = foodClient, ) }, ) diff --git a/app/src/test/java/com/pulseloop/coach/tools/NutritionToolsTest.kt b/app/src/test/java/com/pulseloop/coach/tools/NutritionToolsTest.kt new file mode 100644 index 00000000..da6b515f --- /dev/null +++ b/app/src/test/java/com/pulseloop/coach/tools/NutritionToolsTest.kt @@ -0,0 +1,221 @@ +package com.pulseloop.coach.tools + +import com.pulseloop.coach.orchestration.MealUpdates +import com.pulseloop.data.entity.MealEntryEntity +import com.pulseloop.nutrition.FoodProduct +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +/** + * Unit tests for the pure logic factored out of [NutritionTools] (iOS PR #96 port). + * The tool bodies take a concrete Room db (the repo has no in-memory harness) and can't be + * exercised end-to-end here, so the behavior that can drift — timestamp resolution, the + * source/confidence raw-string mapping, the search clamp and query validation, the per-product + * payload shape, and the meal-update application — lives in pure [NutritionTools] members and + * is tested directly, with an injected clock/zone wherever time matters. + */ +class NutritionToolsTest { + /** A fixed "now": 2026-08-22 15:04:05 UTC — afternoon, so local "today" is 2026-08-22/23. */ + private val now = Instant.parse("2026-08-22T15:04:05Z").toEpochMilli() + + // resolveTimestamp goes through CoachDataAccess.parseLocalDate, which anchors to the system + // default zone — so these tests run in that same zone (like CoachActionTest does) and + // compute their expectations in it, staying valid on any CI machine's timezone. + private val zone = ZoneId.systemDefault() + + private fun dayOf(instantMs: Long): String = + Instant.ofEpochMilli(instantMs).atZone(zone).toLocalDate().toString() + + // ── resolveTimestamp ─────────────────────────────────────────────── + + @Test + fun todayWithoutTimeLandsAtNow() { + assertEquals(now, NutritionTools.resolveTimestamp(dayOf(now), null, now, zone)) + } + + @Test + fun pastDayWithoutTimeLandsAtNoon() { + // "2026-08-20" can never be the local day of [now] (max zone offset ±14 keeps [now] + // on 2026-08-22/23 local), so this deterministically takes the noon branch. + val expected = LocalDate.parse("2026-08-20").atTime(12, 0).atZone(zone).toInstant().toEpochMilli() + assertEquals(expected, NutritionTools.resolveTimestamp("2026-08-20", null, now, zone)) + } + + @Test + fun explicitTimeIsHonoredOnAPastDay() { + val expected = LocalDate.parse("2026-08-20").atTime(14, 30).atZone(zone).toInstant().toEpochMilli() + assertEquals(expected, NutritionTools.resolveTimestamp("2026-08-20", "14:30", now, zone)) + } + + @Test + fun explicitTimeIsHonoredOnTodayToo() { + // An explicit time wins even on today (iOS: the stamped time short-circuits the + // isDateInToday fallback). + val startOfDay = Instant.ofEpochMilli(now).atZone(zone).toLocalDate() + .atStartOfDay(zone).toInstant().toEpochMilli() + assertEquals(startOfDay + 8 * 3600_000L, NutritionTools.resolveTimestamp(dayOf(now), "08:00", now, zone)) + } + + @Test + fun unstampableTimeFallsBackToNoonOrNow() { + // 25:99 can't be stamped (iOS's bySettingHour returns null) — a past day falls to + // noon, today falls to the current clock time. + val expectedNoon = LocalDate.parse("2026-08-20").atTime(12, 0).atZone(zone).toInstant().toEpochMilli() + assertEquals(expectedNoon, NutritionTools.resolveTimestamp("2026-08-20", "25:99", now, zone)) + assertEquals(now, NutritionTools.resolveTimestamp(dayOf(now), "25:99", now, zone)) + } + + @Test + fun invalidDateFallsBackToToday() { + // iOS: parseLocalDate(date) ?? startOfDay(now) — and today with no time is now. + assertEquals(now, NutritionTools.resolveTimestamp("not-a-date", null, now, zone)) + } + + // ── source / confidence / meal_type raw mapping ──────────────────── + + @Test + fun sourceIsOffSearchOnlyForDatabaseWithAProductCode() { + // The honesty core: a row is database-verified only when grounded in a real OFF code. + assertEquals("off_search", NutritionTools.resolveSourceRaw("database", "3017620422003")) + assertEquals("llm_estimate", NutritionTools.resolveSourceRaw("database", null)) + assertEquals("llm_estimate", NutritionTools.resolveSourceRaw("estimate", "3017620422003")) + assertEquals("llm_estimate", NutritionTools.resolveSourceRaw("estimate", null)) + } + + @Test + fun confidenceMapsToTheIosRawValues() { + assertEquals("known", NutritionTools.decodeConfidenceRaw("high")) + assertEquals("partial", NutritionTools.decodeConfidenceRaw("medium")) + assertEquals("unknown", NutritionTools.decodeConfidenceRaw("low")) + assertEquals("unknown", NutritionTools.decodeConfidenceRaw("bogus")) + assertEquals("unknown", NutritionTools.decodeConfidenceRaw(null)) + } + + @Test + fun mealTypeSetMatchesTheAppPickers() { + // The same four raw strings MealLogDialog's chips offer (iOS MealType rawValues). + for (t in listOf("breakfast", "lunch", "dinner", "snack")) { + assertTrue(t in NutritionTools.mealTypeRawValues) + } + assertFalse("brunch" in NutritionTools.mealTypeRawValues) + } + + // ── search limit clamp + query validation ────────────────────────── + + @Test + fun searchLimitClampsTo1Through5() { + // iOS: min(5, max(1, Int(maxResults ?? 5))) + assertEquals(1, NutritionTools.clampSearchLimit(0.0)) + assertEquals(1, NutritionTools.clampSearchLimit(-3.0)) + assertEquals(5, NutritionTools.clampSearchLimit(99.0)) + assertEquals(3, NutritionTools.clampSearchLimit(3.0)) + assertEquals(2, NutritionTools.clampSearchLimit(2.9)) // Int() truncates + assertEquals(5, NutritionTools.clampSearchLimit(null)) + } + + @Test + fun searchQueryRequiresTwoCharsAfterTrim() { + assertEquals("query too short", NutritionTools.searchQueryError("a")) + assertEquals("query too short", NutritionTools.searchQueryError(" ")) + assertNull(NutritionTools.searchQueryError("ab")) + assertNull(NutritionTools.searchQueryError(" apples ")) + } + + // ── per-product payload ──────────────────────────────────────────── + + @Test + fun payloadCarriesOptionalsOnlyWhenPresent() { + val full = FoodProduct( + code = "301", name = "Yogurt", brand = "Acme", energyKcal100g = 97.4, + protein100g = 10.0, carbs100g = 12.0, fat100g = 3.0, + servingSizeText = "1 cup (240 ml)", servingQuantityG = 240.0, + ) + val obj = NutritionTools.foodProductPayload(full).jsonObject + assertEquals("301", obj["code"]!!.jsonPrimitive.content) + assertEquals("Yogurt", obj["name"]!!.jsonPrimitive.content) + assertEquals("Acme", obj["brand"]!!.jsonPrimitive.content) + assertEquals("1 cup (240 ml)", obj["serving"]!!.jsonPrimitive.content) + assertEquals(240.0, obj["serving_g"]!!.jsonPrimitive.content.toDoubleOrNull()!!, 1e-9) + val per100 = obj["per_100g"]!!.jsonObject + // kcal is rounded to an integer, like iOS's energyKcal100g.rounded(). + assertEquals(97.0, per100["kcal"]!!.jsonPrimitive.content.toDoubleOrNull()!!, 1e-9) + assertEquals(10.0, per100["protein_g"]!!.jsonPrimitive.content.toDoubleOrNull()!!, 1e-9) + assertEquals(12.0, per100["carbs_g"]!!.jsonPrimitive.content.toDoubleOrNull()!!, 1e-9) + assertEquals(3.0, per100["fat_g"]!!.jsonPrimitive.content.toDoubleOrNull()!!, 1e-9) + + val bare = FoodProduct(code = "302", name = "Plain", energyKcal100g = 100.6) + val bareObj = NutritionTools.foodProductPayload(bare).jsonObject + assertFalse(bareObj.containsKey("brand")) + assertFalse(bareObj.containsKey("serving")) + assertFalse(bareObj.containsKey("serving_g")) + assertEquals(101.0, bareObj["per_100g"]!!.jsonObject["kcal"]!!.jsonPrimitive.content.toDoubleOrNull()!!, 1e-9) + } + + // ── applyMealUpdates ─────────────────────────────────────────────── + + private fun entry(sourceRaw: String) = MealEntryEntity( + date = 0L, timestamp = 0L, name = "Oatmeal", mealTypeRaw = "breakfast", + calories = 300.0, sourceRaw = sourceRaw, + ) + + @Test + fun numericChangeMarksADatabaseRowEdited() { + // iOS: a user-requested correction to a database/estimate row marks it edited. + val updated = NutritionTools.applyMealUpdates(MealUpdates(calories = 320.0), entry("off_search")) + assertEquals(320.0, updated.calories, 1e-9) + assertTrue(updated.userEdited) + } + + @Test + fun numericChangeDoesNotMarkAManualRowEdited() { + val updated = NutritionTools.applyMealUpdates(MealUpdates(calories = 320.0), entry("manual")) + assertEquals(320.0, updated.calories, 1e-9) + assertFalse(updated.userEdited) + } + + @Test + fun nonNumericChangeDoesNotMarkEdited() { + val updated = NutritionTools.applyMealUpdates( + MealUpdates(name = "Oats", notes = "had berries"), entry("off_search")) + assertEquals("Oats", updated.name) + assertEquals("had berries", updated.notes) + assertFalse(updated.userEdited) + } + + @Test + fun unknownMealTypeIsIgnoredNotAnError() { + // iOS guards with MealType(rawValue:) — an invalid type is simply not applied. + val updated = NutritionTools.applyMealUpdates(MealUpdates(mealType = "brunch"), entry("off_search")) + assertEquals("breakfast", updated.mealTypeRaw) + assertFalse(updated.userEdited) + } + + @Test + fun allNullUpdatesOnlyBumpUpdatedAt() { + val e = entry("llm_estimate") + val updated = NutritionTools.applyMealUpdates(MealUpdates(), e) + assertEquals(e.copy(updatedAt = updated.updatedAt), updated) + } + + @Test + fun localTimeStringFormatsHourAndMinute() { + // Pure conversion — no parseLocalDate involved — so a fixed zone is safe. + val utc = ZoneId.of("UTC") + assertEquals( + "08:05", + NutritionTools.localTimeString(Instant.parse("2026-08-22T08:05:00Z").toEpochMilli(), utc), + ) + assertEquals( + "23:59", + NutritionTools.localTimeString(Instant.parse("2026-08-22T23:59:00Z").toEpochMilli(), utc), + ) + } +} From ce6218c5c24a9c56ff7000b0a6f2880a630cc77d Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Sat, 22 Aug 2026 16:29:33 -0700 Subject: [PATCH 14/22] Record #96 nutrition: OFF client + coach tools landed (a13238d, 05d8833); barcode + AI photo deferred (needs camera + device) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 1 (a13238d): Open Food Facts client + 500-row LRU cache — food_products now populates. Stage 2 (05d8833): the five coach nutrition tools (search/log/get/ update/delete meals), gated by flags.writeToolsEnabled. Stages 3/4 (barcode scanner + AI photo analysis) are camera features that need a build dependency + a real device to port and verify; deferred rather than ported blind (hardware guidance, see #82/#90). --- docs/ios-sync.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/ios-sync.md b/docs/ios-sync.md index 5963dd0e..e09fbbbf 100644 --- a/docs/ios-sync.md +++ b/docs/ios-sync.md @@ -31,7 +31,7 @@ the work list, and assembling one from all three is how items get missed. | **Fork baseline (iOS)** | `600c7a8` — Merge PR #6, 2026-06-20 | | **Last triaged iOS commit** | `439ca81` — Merge PR #93 (Colmi R11 CRP driver), 2026-08-09 | | **Last triage date** | 2026-08-22 | -| **Last port date** | 2026-08-22 — Workout pause intervals (`71f251e`) + PR #94 `CoachNotificationDataTrigger` (`9d43227`) + PR #93 hardening (`c95b6e8`) | +| **Last port date** | 2026-08-22 — PR #96 nutrition OFF client + cache (`a13238d`) + five coach tools (`05d8833`, barcode/AI-photo deferred as needs-hardware) + Workout pause intervals (`71f251e`) + PR #94 `CoachNotificationDataTrigger` (`9d43227`) + PR #93 hardening (`c95b6e8`) | | **Range covered** | 12 first-parent items since `0d1b965` (2026-07-18): PRs #73, #94–#100, #130, #131, #93 + 1 direct commit (`160c775`) → **11 ported, #130 backed out** (#94's data-trigger feature and #93's 5 hardening fixes both landed this session) | --- @@ -47,7 +47,7 @@ blocked on something outside the code. | # | Item | What is actually left | Size | Ready? | |---|------|----------------------|------|--------| -| 1 | **#96 nutrition subset** | `food_products`/`FoodProductDao`/`CachedFoodProductEntity` exist but **nothing can populate them**: no Open Food Facts client, no barcode scanner, no AI photo analysis, no coach `log_meal` tool. The ledger row is corrected to ADAPT (subset); this is the rest of it. | L | ✅ start now | +| 1 | **#96 nutrition subset** | **2 of 4 parts done this session** (`a13238d` + `05d8833`): the Open Food Facts client + 500-row LRU cache (so `food_products` now populates) and the five coach tools (`search_food_database`/`get_nutrition_log`/`log_meal`/`update_meal_entry`/`delete_meal_entry`). **Remaining: barcode scanner + AI photo analysis** — both camera features (iOS `BarcodeScannerSheet` VisionKit; `MealAnalysisSheet` 423-line photo + vision-LLM), the build has no camera/barcode/vision dependency, and there is no device here, so they are **deferred, not ported blind** (hardware guidance — see #82/#90). | L→M | ⛔ needs hardware (camera) for the remaining 2 parts | | 2 | **#130 RWfit — finish the JieLi `0xAB` path** | The vendor rebuild landed on `main`. The legacy `0x7E` path is complete; the JieLi `0xAB` framing is complete but **its history bodies are not decoded yet**. Read `decompiled-rwfit-official/`, never iOS and never guesswork (root `AGENTS.md`). | M | ✅ start now | | 3 | **#82 YCBT (TK5 + SmartHealth-Colmi)** | Protocol layer is on `main` (`7a941a5`, `849131d`). **No code known to be missing** — what is missing is a live connect against real hardware. If one fails, re-read `BleHelper.java`'s connect sequence: the vendor's MTU/bonding/pacing timing was deliberately *not* copied (see the 2026-07-19 note). | — | ⛔ needs hardware | | 4 | **#90 LuckRing / TK18** | Protocol layer is on `main` (`57e1e23`). Same position as #82: no known code gap, never validated against a real TK18. | — | ⛔ needs hardware | @@ -155,7 +155,7 @@ seeded-data mode). **SKIP** — no portable behavior, Android has its own indepe | ☑ | [#94](https://github.com/saksham2001/PulseLoopiOS/pull/94) `459f7f1` | ~07-21 | Background syncs + `StaleDataPolicy` + data-gated coach notifications | **ADAPT** | M | `0ca53a1` + `c4aab74` (CR fix: wire STALE_DATA_WINDOW_MS) + **`9d43227`** (the data-trigger feature itself — the bus subscriber + (dateKey,slotRaw) dedupe + stale-skip — was the one part of #94 never ported) | | ☑ | [#95](https://github.com/saksham2001/PulseLoopiOS/pull/95) `dae95ab` | ~07-22 | HR zone colors/thresholds (evidence-based defaults + Standard/Auto/Custom modes + resting-HR baseline learning) | **PORT** | M–L | `0ca53a1` | | ☑ | [#97](https://github.com/saksham2001/PulseLoopiOS/pull/97) `cb8e1cd` | ~07-23 | LittleMeatball R10M YCBT support + 9 shared YCBT bugfixes | **ALREADY-HAVE** | — | iOS PR is itself a port of PulseLoopAndroid#31 | -| ☑ | [#96](https://github.com/saksham2001/PulseLoopiOS/pull/96) `c0def0f` | ~07-24 | Calorie + macro nutrition tracking (meal logging, barcode scan, OFF search, AI photo analysis, coach `log_meal` tool, intake goals, provenance tags) | **ADAPT (subset — manual meal logging + goals only; no OFF search, barcode, AI photo or coach `log_meal`)** | XL | `4084671` + `c4aab74` (CR fix: null-goal guard, dead button wired) | +| ☑ | [#96](https://github.com/saksham2001/PulseLoopiOS/pull/96) `c0def0f` | ~07-24 | Calorie + macro nutrition tracking (meal logging, barcode scan, OFF search, AI photo analysis, coach `log_meal` tool, intake goals, provenance tags) | **ADAPT (subset)** — manual meal logging + goals (`4084671`+`c4aab74`); this session added the OFF client + cache (`a13238d`) and the five coach tools (`05d8833`). **Barcode + AI photo remain** (camera features; need a build dependency + a real device — deferred, not ported blind). | XL | `4084671` + `c4aab74` + `a13238d` + `05d8833` | | ☑ | [#99](https://github.com/saksham2001/PulseLoopiOS/pull/99) `f06be51` | ~07-25 | Full-data JSON export/import (all models → single JSON file, atomic wipe-and-restore on import) | **PORT** | M | `802789d` + `c4aab74` (CR fix: atomic transaction, wearableLogs roundtrip, BuildConfig appVersion) | | ☑ | [#100](https://github.com/saksham2001/PulseLoopiOS/pull/100) `4947628` | ~07-26 | Strava OAuth connect + TCX upload (GPS-HR merge, auto-dedup, token refresh) + shareable PNG stat cards | **ADAPT** | L | `4ce34dc` + `c4aab74` (CR fix: mobile endpoint, intent-filter, redirect handler, pollUntilDone, BuildConfig secrets, shared OkHttpClient) | | ☑ | — `160c775` | ~07-26 | Set version to 2.5.0 + read About version from bundle | **ALREADY-HAVE** | — | `68c9788` (versionName → 2.5.0 to match iOS MARKETING_VERSION) | From c9be84842b630550adc2ecf1ca9352284f80b7ed Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Sat, 22 Aug 2026 22:07:36 -0700 Subject: [PATCH 15/22] Port RWfit JieLi 0xAB 05-group history bodies (finish the JieLi path) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #130's remaining gap: framing was complete but every history payload was logged, not decoded, so the JieLi path never synced. All layouts below were re-derived from decompiled-rwfit-official (the anti-fabrication rule; PR #45 invented its constants and was backed out) — each carries a vendor file:line citation. - New RWfitJLHistory: per-type decoders for steps (a0, 16-byte records, 3-byte count, distance raw/10 m), heart rate / SpO2 / HRV / stress (6-byte records, value @+4, zero slots dropped), blood pressure (sp/dp @+4/+5), temperature (u16 BE /10 degC — no legacy +200 offset on this wire) and blood sugar (u16 BE /10 mmol/L -> mg/dL via the standard 18.016 factor, same convention as YCBTHealthRecords.bloodSugarMgdl). Sleep (Z) is a stage-transition stream: {ts, model} pairs the port reconstructs into sessions using the vendor's own consumer semantics (s1.java): 0x11 opens, 0x22 closes, segment = gap to next record, stage bytes 1 deep / 2 light / 0,3 awake / 4 REM / 17 light — NOT the legacy 0x7E map. Timestamps are epoch-2000 seconds minus getOffset(now) (utils/b.java i()) — a different correction from the legacy flat-DST quirk; the two helpers deliberately stay separate. - Requests are the bare {5, type, 0x10} triple with no payload (vendor senders: blesdk/service/y.java:345-537, TRingHeartRateStatisticsActivity.java:545). RWfitSyncEngine fires the whole ported catalog once per connection after the handshake — no manifest exists on JieLi; reset() re-arms it. - Driver cmd==5 dispatch routes by key to the decoders; unported keys (sport, Muslim count, contact-file/vaper) still log. The keyFlag 0x30 variants have no parser anywhere in x5/b.java and are never sent. Tests +25 (suite 1166 -> 1191): hand-assembled vendor-layout oracles in RWfitJLHistoryTest (17) and rewritten driver tests asserting the exact wire triples of the connect burst and its once-per-connection gate. No hardware validation — nothing here has talked to a real RWfit ring. Reference: android/docs/ios-sync.md #130 (JieLi scope section). --- .../java/com/pulseloop/ring/RWfitDriver.kt | 15 +- .../java/com/pulseloop/ring/RWfitEncoder.kt | 6 +- .../java/com/pulseloop/ring/RWfitJLHistory.kt | 378 ++++++++++++++++++ .../com/pulseloop/ring/RWfitSyncEngine.kt | 70 +++- .../com/pulseloop/ring/RWfitDriverTest.kt | 113 +++++- .../com/pulseloop/ring/RWfitJLHistoryTest.kt | 323 +++++++++++++++ 6 files changed, 875 insertions(+), 30 deletions(-) create mode 100644 app/src/main/java/com/pulseloop/ring/RWfitJLHistory.kt create mode 100644 app/src/test/java/com/pulseloop/ring/RWfitJLHistoryTest.kt diff --git a/app/src/main/java/com/pulseloop/ring/RWfitDriver.kt b/app/src/main/java/com/pulseloop/ring/RWfitDriver.kt index a8599115..5b1027a3 100644 --- a/app/src/main/java/com/pulseloop/ring/RWfitDriver.kt +++ b/app/src/main/java/com/pulseloop/ring/RWfitDriver.kt @@ -166,12 +166,15 @@ class RWfitDriver(private val writer: RingCommandWriter?) : WearableDriver { listOf(RingDecodedEvent.Status(address = null)) } - // The 05-group history bodies have their own per-type layouts which have NOT been - // extracted from the vendor yet. Logged, not guessed — this is exactly the gap that - // made the first version of this driver worthless. RWfitSyncEngine does not request - // history on a JieLi link for the same reason. - t.cmd == 0x05.toByte() -> { - Log.i(TAG, "JieLi history frame ${t.key} (${frame.payload.size}B) — decoder not yet ported") + // The 05-group history bodies, decoded per-type from the vendor parsers + // (`x5/b.java` a0/V/T/Z/U/S/W/Y/R — see RWfitJLHistory for the layouts). Steps come + // up as ActivityBuckets (the records are per-interval deltas the vendor sums per + // date), everything else as the same HistoryMeasurement/SleepTimeline events the + // legacy path emits. Still unported: sport {5,14,16}, Muslim count {5,23,16} and the + // other non-metric 05 keys, plus the {5,x,0x30} delete variants the vendor sends + // after sync (no parser for them exists in the vendor either — x5/b.java dispatch). + t.cmd == 0x05.toByte() -> RWfitJLHistory.decode(t.key, frame.payload) ?: run { + Log.i(TAG, "JieLi history frame key 0x${"%02X".format(t.key)} (${frame.payload.size}B) — decoder not yet ported") emptyList() } diff --git a/app/src/main/java/com/pulseloop/ring/RWfitEncoder.kt b/app/src/main/java/com/pulseloop/ring/RWfitEncoder.kt index 5945382d..5d5cf0b0 100644 --- a/app/src/main/java/com/pulseloop/ring/RWfitEncoder.kt +++ b/app/src/main/java/com/pulseloop/ring/RWfitEncoder.kt @@ -66,7 +66,11 @@ class RWfitEncoder( /** * One history stream. Legacy requests carry an **empty payload** — the ring replies with - * everything it holds for that stream (`blesdk/service/l.java`). + * everything it holds for that stream (`blesdk/service/l.java`). JieLi requests are the bare + * `{5, type, 0x10}` triple with **no payload at all** — the vendor's own senders are exactly + * that (`blesdk/service/y.java:345-537`, one per stream; UI screens likewise, e.g. + * `TRingHeartRateStatisticsActivity.java:545`), and the reply is a single (possibly + * multi-packet) frame holding the stream's full record list. * * Returns null when the stream doesn't exist on this framing (HRV/stress/blood sugar are * JieLi-only; breathe is legacy-only). diff --git a/app/src/main/java/com/pulseloop/ring/RWfitJLHistory.kt b/app/src/main/java/com/pulseloop/ring/RWfitJLHistory.kt new file mode 100644 index 00000000..eb7d0404 --- /dev/null +++ b/app/src/main/java/com/pulseloop/ring/RWfitJLHistory.kt @@ -0,0 +1,378 @@ +package com.pulseloop.ring + +import java.time.Duration +import java.time.Instant +import java.util.TimeZone + +/** + * Payload decoders for the RWfit **JieLi (`0xAB`) `05`-group history bodies**, ported from the + * per-type parsers in `x5/b.java` (`decompiled-rwfit-official/sources/`). Framing is handled + * upstream by [RWfitJLCodec]; this turns one reassembled `{5, key, 0x10}` reply body into + * PulseLoop events. Multi-packet reassembly already happens in the codec, so each decoder here + * sees the ring's complete body for its stream in one array. + * + * **Every layout below was re-derived from the decompile** (the anti-fabrication rule in the root + * `AGENTS.md` — this family's first port, PR #45, invented its constants and was backed out). The + * reply body still carries the `{cmd, key, keyFlag}` triple in bytes 0..2; the vendor parsers + * start at byte 3, which is exactly what [RWfitJLInbound.Frame.payload] hands over, so every + * `records` argument below is vendor index 3-based. Dispatch: the reply's triple is looked up in + * the `y5/c.java` table (`x5/b.java z()`, lines 3678-3684) and the 05-group ids map onto the + * parsers at `x5/b.java:3852-3899` — `-60`→`a0` steps, `-62`→`V` HR, `-64`→`T` BP, `-66`→`Z` + * sleep, `-68`→`U` temp, `-70`→`S` SpO2, `-72`→`W` HRV, `-74`→`Y` stress, `-112`→`R` blood sugar + * (table: `y5/c.java:74-91`). + * + * **Not ported, on purpose:** + * - The `05 xx 30` (keyFlag `0x30`) variants — `y5/c.java:75,77,79,81,83,85,87,89,91` maps them + * to ids `-61..-75/-111`, but **no parser for any of those ids exists in `x5/b.java`** (the + * dispatch at 3852-3932 has no arm for them; verified by search). The vendor only *sends* them + * as post-sync delete-acks that erase the ring's records (`t.java:63`, `o.java:63`, …). We never + * request them and decode nothing for them. + * - The remaining `05`-group streams with no PulseLoop metric: sport `{5,14,16}` (`Q` @1011), + * Muslim count `{5,23,16}` (`X` @1405), and the contact-file/vaper types. [decode] returns null + * for them so the driver logs them as unported. + * + * **Timestamps.** Every JieLi parser stamps a record as local-wall-clock seconds counted from + * **2000-01-01T00:00:00Z** — the `+ 946684800` that appears in all of them (e.g. `a0` @1562, + * `Z` @1533, `V` @1306) — and corrects on the way in by subtracting + * `com.example.baselibrary.utils.b.i() / 1000`, which is `TimeZone.getDefault().getOffset(now)` + * (`utils/b.java:250-252`). `Z` inlines that same expression verbatim (`x5/b.java:1533`), so all + * nine streams share one correction — there is no per-parser split. + * + * Note the JieLi correction is a **different function from the legacy one**: the `0x7E` parsers + * subtract `rawOffset + (useDaylightTime() ? 1h : 0)` (the quirk [RWfitDecoder.tzCorrectionSeconds] + * replicates; vendor side `utils/b.java:268-270`, e.g. `x5/b.java:406`), which is an hour off for + * half the year in DST zones. Do not "unify" the two helpers — each matches its own framing. + */ +object RWfitJLHistory { + + /** Seconds from the Unix epoch to 2000-01-01T00:00:00Z — the JieLi record-epoch base. */ + const val JIELI_EPOCH_SECONDS = 946_684_800L + + /** mg/dL per mmol/L of glucose — the unit the app's BLOOD_SUGAR kind speaks everywhere. */ + private const val MGDL_PER_MMOL = 18.016 + + /** + * The JieLi record-time correction, `utils.b.i() / 1000` = `getOffset(now)` in seconds + * (`utils/b.java:250-252`, inlined at `x5/b.java:1533` in `Z`). Read at decode time, exactly + * as the vendor does — not the legacy `rawOffset + DST?1h:0` quirk. (The iOS port instead + * latches the offset at timeSync; a deliberate documented divergence there, see + * `RWfitClock` in `RWfitProtocol.swift`.) + */ + private fun tzCorrectionSeconds(): Long = + TimeZone.getDefault().getOffset(System.currentTimeMillis()) / 1000L + + private fun u16(p: ByteArray, i: Int) = ((p[i].toInt() and 0xFF) shl 8) or (p[i + 1].toInt() and 0xFF) + + private fun u24(p: ByteArray, i: Int) = + ((p[i].toInt() and 0xFF) shl 16) or ((p[i + 1].toInt() and 0xFF) shl 8) or (p[i + 2].toInt() and 0xFF) + + private fun u32(p: ByteArray, i: Int) = + ((p[i].toLong() and 0xFF) shl 24) or ((p[i + 1].toLong() and 0xFF) shl 16) or + ((p[i + 2].toLong() and 0xFF) shl 8) or (p[i + 3].toLong() and 0xFF) + + /** Vendor index `3 + i` in the parser's `bArr` → a true instant (epoch-2000 + correction). */ + private fun instantAt(p: ByteArray, i: Int): Instant = + Instant.ofEpochSecond(u32(p, i) + JIELI_EPOCH_SECONDS - tzCorrectionSeconds()) + + /** + * One `05`-group reply body. `key` is the triple's key byte; `records` is the body after the + * triple (vendor parser offset 3). Returns null for keys this port does not decode, so the + * driver can keep its "not yet ported" log for them. + */ + fun decode(key: Byte, records: ByteArray): List? = when (key) { + RWfitProtocol.JLDataType.STEPS -> decodeSteps(records) + RWfitProtocol.JLDataType.HEART_RATE -> decodeHeartRate(records) + RWfitProtocol.JLDataType.BLOOD_PRESSURE -> decodeBloodPressure(records) + RWfitProtocol.JLDataType.SLEEP -> decodeSleep(records) + RWfitProtocol.JLDataType.TEMPERATURE -> decodeTemperature(records) + RWfitProtocol.JLDataType.SPO2 -> decodeSpo2(records) + RWfitProtocol.JLDataType.HRV -> decodeHrv(records) + RWfitProtocol.JLDataType.STRESS -> decodeStress(records) + RWfitProtocol.JLDataType.BLOOD_SUGAR -> decodeBloodSugar(records) + else -> null + } + + // ── The shared record loop ─────────────────────────────────────────────────── + // + // All nine `05`-group parsers are the same `while (i10 >= bArr.length) break` loop from + // offset 3 with a fixed stride (`x5/b.java:1552-1557` for `a0`, 1296-1300 for `V`, …). The + // vendor's condition only tests the record *start*; a partial tail would AIOOBE in the vendor + // (the field reads sit outside its try/catch). The boundary guard `i + stride <= size` below + // is byte-for-byte identical to the vendor on well-formed bodies and drops a torn tail here. + + private inline fun series6( + records: ByteArray, + decodeItem: (Instant, Int) -> List, + ): List { + val events = mutableListOf() + var i = 0 + while (i + 6 <= records.size) { + events.addAll(decodeItem(instantAt(records, i), i)) + i += 6 + } + return events + } + + // ── Steps (`a0()`, id -60) ─────────────────────────────────────────────────── + + /** + * 16-byte records from offset 3 (`x5/b.java a0()` @1549-1575): + * `[ts2000 u32 @i][pad @i+4][steps u24 @i+5..7][calorie×10 u32 @i+8..11][distance u32 @i+12..15]` + * — `d(i+5,i+7)` @1570 (the 3-byte read is INCLUSIVE, `y5/b.java:61-66`), `d(i+8,i+11)/10` + * @1571, `d(i+12,i+15)/10000` @1572, stride `i10 += 16` @1573. + * + * The vendor's `d()` parses 4-byte slices as *signed* ints (`y5/b.java j()` @149-164); the + * unsigned reads here agree for every realistic value (steps ≤ 16.7M, distance raw well under + * 2³¹ decimetres). + * + * Records are **per-interval deltas, not daily totals**: the vendor sums them per date when it + * stores the sync (`m.java:147-157` — `stepByDate.setTotalStep(getSteps() + getTotalStep())`), + * so each record is published as one [RingDecodedEvent.ActivityBucket]; persistence upserts the + * bucket by its start time and recomputes the day as the sum of distinct buckets, which is the + * same per-date sum. (An [RingDecodedEvent.ActivityUpdate] would be wrong: its persistence + * path keeps a running daily *max*, which only makes sense for cumulative daily totals like + * the legacy stream's.) + * + * Distance: the vendor renders `raw / 10000` as kilometres, so the raw unit is decimetres and + * `raw / 10` is metres. Calories (÷10 kcal) have no field in [RingDecodedEvent.ActivityBucket] + * and persistence leaves bucket calories untouched — dropped, as on the legacy steps path. + */ + fun decodeSteps(records: ByteArray): List { + val events = mutableListOf() + var i = 0 + while (i + 16 <= records.size) { + val steps = u24(records, i + 5) + if (steps > 0) { + events.add( + RingDecodedEvent.ActivityBucket( + _timestamp = instantAt(records, i), + steps = steps, + distanceMeters = (u32(records, i + 12) / 10).toInt(), + ) + ) + } + i += 16 + } + return events + } + + // ── Heart rate (`V()`, id -62) ─────────────────────────────────────────────── + + /** + * 6-byte records from offset 3 (`x5/b.java V()` @1291-1321): `[ts2000 u32 @i][hr @i+4][pad]`. + * `hr` is `bArr[i+4] & 255` @1314-1316, stride `i10 += 6` @1317, and items with `hr == 0` are + * **dropped by the vendor itself** (`if (getHr() > 0)` @1318-1320) — replicated, not clamped. + */ + fun decodeHeartRate(records: ByteArray): List = + series6(records) { ts, i -> + val hr = records[i + 4].toInt() and 0xFF + if (hr > 0) { + listOf(RingDecodedEvent.HistoryMeasurement(MeasurementKind.HEART_RATE, hr.toDouble(), ts)) + } else emptyList() + } + + // ── Blood pressure (`T()`, id -64) ────────────────────────────────────────── + + /** + * 6-byte records from offset 3 (`x5/b.java T()` @1182-1210): `[ts2000 u32 @i][systolic @i+4] + * [diastolic @i+5]` — `sp = bArr[i+4] & 255` @1205-1207, `dp = bArr[i+5] & 255` @1208, stride + * `i10 += 6` @1209. The vendor adds every record unconditionally; a zero in either field is a + * "no sample" slot, so both must be non-zero (the bridge's plausibility window does the rest). + * Emits one [RingDecodedEvent.HistoryMeasurement] per field, same as the legacy BP decoder. + */ + fun decodeBloodPressure(records: ByteArray): List = + series6(records) { ts, i -> + val sys = records[i + 4].toInt() and 0xFF + val dia = records[i + 5].toInt() and 0xFF + if (sys > 0 && dia > 0) { + listOf( + RingDecodedEvent.HistoryMeasurement(MeasurementKind.BLOOD_PRESSURE_SYSTOLIC, sys.toDouble(), ts), + RingDecodedEvent.HistoryMeasurement(MeasurementKind.BLOOD_PRESSURE_DIASTOLIC, dia.toDouble(), ts), + ) + } else emptyList() + } + + // ── Sleep (`Z()`, id -66) ──────────────────────────────────────────────────── + + /** + * Session-start marker byte. The vendor's sleep *consumer* (not the `Z` parser — which just + * decodes `{time, model}` pairs) treats model `17` as the moment the subject fell asleep: + * `s1.java:1004-1006` (resets the per-session buffer) and `s1.java:1093-1098,1114` + * (`asleepTime = first 17 record's timestamp`). + */ + private const val SLEEP_SESSION_START = 0x11 + + /** + * Session-end (wakeup) marker byte: `s1.java:1008-1018` (date fix-up between the markers) and + * `s1.java:1101-1116` (`wakeupTime = first 34 record's timestamp`). + */ + private const val SLEEP_SESSION_END = 0x22 + + /** + * 7-byte records from offset 3 (`x5/b.java Z()` @1520-1540): `[ts2000 u32 @i][model @i+4] + * [2 unused bytes]` — `setSleepModel(bArr[i+4])` @1537, stride `i10 += 7` @1538. NOT grouped: + * the parser emits one flat list of `{time, model}` pairs, and the ring sends a + * **stage-transition stream** — one record per stage *change* (plus the 17/34 session + * markers), the way the vendor reconstructs sessions in `s1.java:1127-1177`: segment N runs + * from record N's timestamp to record N+1's, `duration = (t[n+1] − t[n]) / 60` minutes + * (`s1.java:1134-1135`). + * + * Stage values, from that same reconstruction (`s1.java:1139-1157`): + * - `1` → **deep** (`sleepType 2`, deepTime @1139-1141) + * - `2` → **light** (`sleepType 1`, lightTime @1142-1144) + * - `3` or `0` → **awake** (`sleepType 0`, wakeup count @1145-1147) + * - `4` → **REM** (`sleepType 3`, rapidTime @1153-1156) + * - `17` (the start marker) counts as the first **light** segment (@1149-1151) + * + * NOTE: this is **not** the legacy 0/1/2/3 = awake/light/deep/REM map (that one belongs to the + * `0x7E` sleep items, `s1.java:1636-1645`). The JieLi model bytes were verified independently + * from the JieLi consumer above. + * + * A session is emitted when its `34` marker arrives with at least one minute of stages + * ([RingDecodedEvent.SleepTimeline], `completeSession = true`, timestamped at the `17` marker + * — the same asleep-anchor the legacy decoder uses). A stream that ends without its `34`, + * or a back-to-back `17`/`34` pair with no minutes between them, produces nothing — the + * vendor likewise stores no `DataSleep` without both markers (`s1.java:1113`). Note the + * marker's own segment always counts as light (`s1.java:1149-1151`), so a genuine session + * always carries at least a light minute — there is no all-awake session to filter out here, + * unlike the legacy stream. + */ + fun decodeSleep(records: ByteArray): List { + data class Record(val time: Instant, val model: Int) + + val recs = mutableListOf() + var i = 0 + while (i + 7 <= records.size) { + recs.add(Record(instantAt(records, i), records[i + 4].toInt() and 0xFF)) + i += 7 + } + + val events = mutableListOf() + var sessionStart: Instant? = null + val stages = mutableListOf() + + for (index in recs.indices) { + val rec = recs[index] + if (rec.model == SLEEP_SESSION_START) { + sessionStart = rec.time + stages.clear() + } + val start = sessionStart ?: continue + if (rec.model == SLEEP_SESSION_END) { + if (stages.isNotEmpty()) { + // toList(): the event must outlive the loop's buffer, which the next session + // reuses (the vendor builds a fresh list per session, s1.java:1119). + events.add(RingDecodedEvent.SleepTimeline(start, stages.toList(), completeSession = true)) + } + sessionStart = null + stages.clear() + continue + } + // Segment length = gap to the next record (`s1.java:1134-1135`); the final record has + // no following boundary and contributes nothing, exactly like the vendor's + // `for (i22 < size4)` loop that skips the last element. + if (index + 1 >= recs.size) continue + val minutes = Duration.between(rec.time, recs[index + 1].time).seconds / 60 + if (minutes in 1..(24 * 60 - 1)) { + repeat(minutes.toInt()) { stages.add(sleepStage(rec.model)) } + } + } + return events + } + + private fun sleepStage(model: Int): SleepStage = when (model) { + 1 -> SleepStage.DEEP + 2 -> SleepStage.LIGHT + 0, 3 -> SleepStage.AWAKE + 4 -> SleepStage.REM + SLEEP_SESSION_START -> SleepStage.LIGHT // start marker doubles as the first light segment + else -> SleepStage.UNKNOWN + } + + // ── Body temperature (`U()`, id -68) ──────────────────────────────────────── + + /** + * 6-byte records from offset 3 (`x5/b.java U()` @1238-1263): `[ts2000 u32 @i][temp u16 BE + * @i+4..5][pad]` — `setTemp(d(i+4, i+5) / 10.0f)` @1261, stride `i10 += 6` @1262. The value is + * already °C×10 — **no legacy `+200` offset** (that belongs to the `0x7E` stream's + * `u0()`); raw 0 is a "no sample" slot and is dropped. + */ + fun decodeTemperature(records: ByteArray): List = + series6(records) { ts, i -> + val raw = u16(records, i + 4) + if (raw > 0) { + listOf(RingDecodedEvent.HistoryMeasurement(MeasurementKind.TEMPERATURE, raw / 10.0, ts)) + } else emptyList() + } + + // ── SpO2 (`S()`, id -70) ──────────────────────────────────────────────────── + + /** + * 6-byte records from offset 3 (`x5/b.java S()` @1127-1155): `[ts2000 u32 @i][spo2 @i+4][pad]` + * — `setBloodOxy(bArr[i+4] & 255)` @1150-1152, stride `i10 += 6` @1153. The vendor adds every + * record; a zero byte is "no sample", so it is dropped (the bridge's 70..100 window does the + * rest). + */ + fun decodeSpo2(records: ByteArray): List = + series6(records) { ts, i -> + val spo2 = records[i + 4].toInt() and 0xFF + if (spo2 > 0) { + listOf(RingDecodedEvent.HistoryMeasurement(MeasurementKind.SPO2, spo2.toDouble(), ts)) + } else emptyList() + } + + // ── HRV (`W()`, id -72) ───────────────────────────────────────────────────── + + /** + * 6-byte records from offset 3 (`x5/b.java W()` @1348-1375): `[ts2000 u32 @i][hrv ms @i+4] + * [pad]` — `setHrv(bArr[i+4] & 255)` @1371-1373, stride `i10 += 6` @1374. Zero = "no sample", + * dropped; the vendor itself drops nothing here, the bridge's 1..300 ms window is the guard. + */ + fun decodeHrv(records: ByteArray): List = + series6(records) { ts, i -> + val hrv = records[i + 4].toInt() and 0xFF + if (hrv > 0) { + listOf(RingDecodedEvent.HistoryMeasurement(MeasurementKind.HRV, hrv.toDouble(), ts)) + } else emptyList() + } + + // ── Stress (`Y()`, id -74) ────────────────────────────────────────────────── + + /** + * 6-byte records from offset 3 (`x5/b.java Y()` @1462-1492): `[ts2000 u32 @i][stress @i+4] + * [pad]` — `setPressure(bArr[i+4] & 255)` @1486-1488, stride `i10 += 6` @1489, and items with + * value `0` are **dropped by the vendor itself** (`if (getPressure() > 0)` @1490-1492). + */ + fun decodeStress(records: ByteArray): List = + series6(records) { ts, i -> + val stress = records[i + 4].toInt() and 0xFF + if (stress > 0) { + listOf(RingDecodedEvent.HistoryMeasurement(MeasurementKind.STRESS, stress.toDouble(), ts)) + } else emptyList() + } + + // ── Blood sugar (`R()`, id -112) ──────────────────────────────────────────── + + /** + * 6-byte records from offset 3 (`x5/b.java R()` @1073-1100): `[ts2000 u32 @i][sugar u16 BE + * @i+4..5][pad]` — `setSugar(d(i+4, i+5) / 10.0f)` @1097, stride `i10 += 6` @1098. The vendor + * displays the value in **mmol/L** (`SugarStatisticsFragment.java:439-442` — `"-- mmol/L"`, + * reference range 3.9-6.1 in `b0.java:445`), so raw ÷ 10 is mmol/L; the app's BLOOD_SUGAR kind + * speaks mg/dL everywhere (unit string, demo data, bridge window), so convert at the boundary + * with the standard glucose factor (the same convention the YCBT history uses via + * [com.pulseloop.ring.YCBTHealthRecords.bloodSugarMgdl]). Raw 0 = "no sample", dropped. + */ + fun decodeBloodSugar(records: ByteArray): List = + series6(records) { ts, i -> + val raw = u16(records, i + 4) + if (raw > 0) { + listOf( + RingDecodedEvent.HistoryMeasurement( + MeasurementKind.BLOOD_SUGAR, + (raw / 10.0) * MGDL_PER_MMOL, + ts, + ) + ) + } else emptyList() + } +} diff --git a/app/src/main/java/com/pulseloop/ring/RWfitSyncEngine.kt b/app/src/main/java/com/pulseloop/ring/RWfitSyncEngine.kt index 71481bad..a60c42ad 100644 --- a/app/src/main/java/com/pulseloop/ring/RWfitSyncEngine.kt +++ b/app/src/main/java/com/pulseloop/ring/RWfitSyncEngine.kt @@ -5,13 +5,23 @@ import android.util.Log /** * Drives the RWfit connect handshake and history sync. * - * Mirrors the vendor's own order (`u1.java g()` → `blesdk/service/l.java`'s cascade): ask for device - * info, set the clock, ask what history the ring is holding, then pull **only** the streams the - * manifest claims — one at a time, each request fired when the previous stream's reply lands. - * - * The manifest gate matters. Every per-stream method in `l.java` opens with an - * `isHasXData()` check and falls through to the next stream when it's false, so a ring with no + * **Legacy (`0x7E`)** mirrors the vendor's own order (`u1.java g()` → `blesdk/service/l.java`'s + * cascade): ask for device info, set the clock, ask what history the ring is holding, then pull + * **only** the streams the manifest claims — one at a time, each request fired when the previous + * stream's reply lands. The manifest gate matters: every per-stream method in `l.java` opens with + * an `isHasXData()` check and falls through to the next stream when it's false, so a ring with no * temperature sensor is never asked for temperature history. + * + * **JieLi (`0xAB`) has no manifest.** The vendor requests each `05`-group stream directly with a + * **bare `{5, type, 0x10}` triple — no payload** (`blesdk/service/y.java:345-537`, one per stream; + * the UI screens do the same, e.g. `TRingHeartRateStatisticsActivity.java:545`), and each reply is + * a single (possibly multi-packet) frame holding **all** of that stream's records — the vendor's + * per-type parsers (`x5/b.java`) just loop over the whole body. PulseLoop therefore fires the whole + * ported catalog at once, once per connection, right after the handshake: no manifest, no cascade + * (there is nothing to wait for — a type the ring has no records for simply answers with the bare + * triple, which decodes to nothing). [runStartup] doubles as the ~30-minute background sync pass, + * so the burst is gated by [jieliHistoryRequested] rather than re-firing on every pass — the + * ring's buffer is re-sent in full each time, and persistence upserts it idempotently. */ class RWfitSyncEngine( private val writer: RingCommandWriter?, @@ -20,13 +30,22 @@ class RWfitSyncEngine( var framing: RWfitFraming = RWfitFraming.LEGACY - /** Streams still to request this pass, in the vendor's cascade order. */ + /** Streams still to request this pass, in the vendor's cascade order (legacy path). */ private var pending = ArrayDeque() private var handshakeDone = false + /** + * JieLi history has been requested on this connection. The engine instance outlives a single + * link (the driver builds it once and resets it in [connectionDidStart]/[connectionDidEnd], + * which both call [reset]), so this flag — not a fresh-engine-per-connection trick — is what + * makes the burst once-per-connection across [runStartup]'s foreground and background passes. + */ + private var jieliHistoryRequested = false + fun reset() { pending.clear() handshakeDone = false + jieliHistoryRequested = false } private fun send(command: ByteArray?) { @@ -37,26 +56,39 @@ class RWfitSyncEngine( // ── Startup ───────────────────────────────────────────────────────────────── /** - * Also the ~30-minute background sync pass, so it stays lean: device info + clock + manifest. - * The manifest reply is what starts the history cascade. + * Also the ~30-minute background sync pass, so it stays lean: device info + clock + battery, + * then the history entry point for the active framing — the manifest on legacy (whose reply + * starts the cascade), the once-per-connection `05`-group burst on JieLi. */ override fun runStartup() { send(encoder.deviceInfo()) send(encoder.timeSync()) send(encoder.battery()) - requestManifest() + if (framing == RWfitFraming.LEGACY) { + send(encoder.syncManifest()) + } else { + requestJieliHistory() + } } - private fun requestManifest() { - if (framing != RWfitFraming.LEGACY) { - // JieLi has no manifest command, and its 05-group history bodies aren't decodable yet - // (see RWfitDriver.decodeJieLiFrame). Requesting them would spend the link on frames we - // would only log — so the JieLi path is live/battery only until those layouts are read - // out of the vendor app. - Log.i(TAG, "JieLi link — history sync not enabled yet") - return + /** + * The JieLi history burst: every `05`-group stream this port decodes, as a bare + * `{5, type, 0x10}` triple with no payload — the exact request shape the vendor sends + * (`y.java:345-537`, `TRingHeartRateStatisticsActivity.java:545`). [encoder.history] builds + * it via [RWfitProtocol.JieLi.historySync]; it returns null for [RWfitProtocol.HistoryType] + * without a JieLi type (breathe), which is dropped here for free. + * + * Fired **once per connection**: the burst is the connect backfill (the ring re-sends its + * whole buffer for each stream, and persistence upserts idempotently), and [runStartup] is + * also the ~30-minute background pass — re-firing would spend the link on frames whose data we + * already hold. [reset] re-arms it for the next link. + */ + private fun requestJieliHistory() { + if (jieliHistoryRequested) return + jieliHistoryRequested = true + for (type in RWfitProtocol.HistoryType.entries) { + send(encoder.history(type)) } - send(encoder.syncManifest()) } // ── Driver callbacks ──────────────────────────────────────────────────────── diff --git a/app/src/test/java/com/pulseloop/ring/RWfitDriverTest.kt b/app/src/test/java/com/pulseloop/ring/RWfitDriverTest.kt index ef5e8748..8d46ecfa 100644 --- a/app/src/test/java/com/pulseloop/ring/RWfitDriverTest.kt +++ b/app/src/test/java/com/pulseloop/ring/RWfitDriverTest.kt @@ -193,7 +193,11 @@ class RWfitDriverTest { } @Test - fun `a JieLi link does not request legacy history`() { + fun `a JieLi link requests its history streams as bare triples after the handshake`() { + // JieLi has no manifest: the vendor requests each 05-group stream directly with the bare + // {5, type, 0x10} triple, no payload (blesdk/service/y.java:345-537, e.g. + // TRingHeartRateStatisticsActivity.java:545). The burst covers every stream RWfitJLHistory + // decodes, in HistoryType order (breathe has no JieLi type and drops out). val writer = RecordingWriter() val driver = RWfitDriver(writer) driver.connectionDidStart() @@ -201,9 +205,110 @@ class RWfitDriverTest { driver.makeSyncEngine().runStartup() - // Device info, time and battery only — the 05-group history bodies aren't decodable yet, so - // requesting them would spend the link on frames we could only log. - assertEquals(3, writer.frames.size) + assertTrue("expected 0xAB frames", writer.frames.all { it[0] == 0xAB.toByte() }) + assertEquals(12, writer.frames.size) // device info + time + battery + 9 history streams + val triples = writer.frames.map { Triple(it[6].toInt() and 0xFF, it[7].toInt() and 0xFF, it[8].toInt() and 0xFF) } + assertEquals( + listOf( + Triple(2, 4, 0x10), // device info + Triple(2, 1, 0), // time sync + Triple(2, 3, 0x10), // battery + Triple(5, 2, 0x10), // steps + Triple(5, 5, 0x10), // sleep + Triple(5, 3, 0x10), // heart rate + Triple(5, 4, 0x10), // blood pressure + Triple(5, 9, 0x10), // SpO2 + Triple(5, 8, 0x10), // temperature + Triple(5, 10, 0x10), // HRV + Triple(5, 13, 0x10), // stress + Triple(5, 16, 0x10), // blood sugar + ), + triples, + ) + } + + @Test + fun `JieLi history is requested once per connection, not per poll pass`() { + // runStartup doubles as the ~30-minute background sync: the handshake frames go out again, + // but the 05-group burst must not — the ring would just re-send buffers we already hold. + val writer = RecordingWriter() + val driver = RWfitDriver(writer) + driver.connectionDidStart() + driver.servicesDiscovered(listOf(RWfitProtocol.SERVICE_UUID, RWfitProtocol.JIELI_SERVICE_UUID)) + fun historyFrames() = writer.frames.count { it[0] == 0xAB.toByte() && (it[6].toInt() and 0xFF) == 5 } + + driver.makeSyncEngine().runStartup() + assertEquals(9, historyFrames()) + driver.makeSyncEngine().runStartup() + assertEquals(9, historyFrames()) + } + + @Test + fun `a reconnected JieLi link re-requests its history`() { + // reset() runs on connectionDidEnd/Start, re-arming the once-per-connection gate. A real + // reconnect re-runs GATT discovery (servicesDiscovered) before runStartup, as in the + // production connect sequence. + val writer = RecordingWriter() + val driver = RWfitDriver(writer) + driver.connectionDidStart() + driver.servicesDiscovered(listOf(RWfitProtocol.SERVICE_UUID, RWfitProtocol.JIELI_SERVICE_UUID)) + driver.makeSyncEngine().runStartup() + + driver.connectionDidEnd() + driver.connectionDidStart() + driver.servicesDiscovered(listOf(RWfitProtocol.SERVICE_UUID, RWfitProtocol.JIELI_SERVICE_UUID)) + writer.clear() + driver.makeSyncEngine().runStartup() + + assertEquals(12, writer.frames.size) + assertEquals(9, writer.frames.count { (it[6].toInt() and 0xFF) == 5 }) + } + + @Test + fun `a JieLi history reply decodes through the driver and is acked`() { + // {5,3,16} heart-rate reply: two 6-byte records, the second a zero-bpm "no reading" slot + // the vendor drops (x5/b.java V @1318-1320). The frame is app-ACKed (flag 0x11) before the + // decode result is produced, as on every other JieLi inbound. + val writer = RecordingWriter() + val driver = RWfitDriver(writer) + driver.connectionDidStart() + driver.servicesDiscovered(listOf(RWfitProtocol.SERVICE_UUID, RWfitProtocol.JIELI_SERVICE_UUID)) + + fun be32(v: Long) = byteArrayOf( + ((v shr 24) and 0xFF).toByte(), ((v shr 16) and 0xFF).toByte(), + ((v shr 8) and 0xFF).toByte(), (v and 0xFF).toByte(), + ) + val records = be32(0x00_0B_C0_00) + byteArrayOf(72, 0x00) + + be32(0x00_0B_C0_3C) + byteArrayOf(0x00, 0x00) + val frame = RWfitJLCodec().encode(RWfitProtocol.JLTriple(0x05, 0x03, 0x10), records) + + val events = driver.ingest(frame, "n") + + val measurements = events.filterIsInstance() + assertEquals(1, measurements.size) + assertEquals(MeasurementKind.HEART_RATE, measurements[0].kind_field) + assertEquals(72.0, measurements[0].value, 0.0) + val ack = writer.frames.single() + assertEquals(0xAB.toByte(), ack[0]) + assertEquals(0x11, ack[1].toInt() and 0xFF) // FLAG_ACK + assertArrayEquals(byteArrayOf(0x05, 0x03, 0x10), ack.copyOfRange(6, 9)) + } + + @Test + fun `an unported JieLi history key still decodes to nothing and the frame is still acked`() { + // e.g. sport {5,14,16}: no PulseLoop metric, so the driver logs and drops the records + // (the frame itself is still ACKed — ACK-before-decode is a link discipline, not a + // parse verdict). + val writer = RecordingWriter() + val driver = RWfitDriver(writer) + driver.connectionDidStart() + driver.servicesDiscovered(listOf(RWfitProtocol.SERVICE_UUID, RWfitProtocol.JIELI_SERVICE_UUID)) + + val frame = RWfitJLCodec().encode(RWfitProtocol.JLTriple(0x05, 0x14, 0x10), ByteArray(16)) + + assertTrue(driver.ingest(frame, "n").isEmpty()) + val ack = writer.frames.single() + assertEquals(0x11, ack[1].toInt() and 0xFF) } @Test diff --git a/app/src/test/java/com/pulseloop/ring/RWfitJLHistoryTest.kt b/app/src/test/java/com/pulseloop/ring/RWfitJLHistoryTest.kt new file mode 100644 index 00000000..64bd7b40 --- /dev/null +++ b/app/src/test/java/com/pulseloop/ring/RWfitJLHistoryTest.kt @@ -0,0 +1,323 @@ +package com.pulseloop.ring + +import java.time.Instant +import java.util.TimeZone +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Vendor-layout oracles for the JieLi (`0xAB`) `05`-group history decoders, hand-assembled from + * the parser offsets in `x5/b.java` (`decompiled-rwfit-official/sources/`) rather than from the + * implementation. Every fixture comment cites the vendor line it was built from. + * + * Timestamps are asserted the same way `RWfitDecoderTest` asserts legacy ones — through the same + * tz correction the decoder applies, not against a hard-coded epoch, because the correction + * (`utils/b.java:250-252`, `getOffset(now)`) is timezone-dependent. [raw2000] inverts the + * decoder's conversion so a fixture stamped with a plain Unix instant comes back as that instant. + */ +class RWfitJLHistoryTest { + + /** The vendor's JieLi correction: `utils.b.i() / 1000` = `getOffset(now)` (`utils/b.java:250-252`). */ + private fun jlTzCorrection(): Long = + TimeZone.getDefault().getOffset(System.currentTimeMillis()) / 1000L + + /** Unix seconds → the ring's epoch-2000 stamp the decoder must turn back into those seconds. */ + private fun raw2000(unix: Long): Long = unix - RWfitJLHistory.JIELI_EPOCH_SECONDS + jlTzCorrection() + + private fun be32(v: Long) = byteArrayOf( + ((v shr 24) and 0xFF).toByte(), ((v shr 16) and 0xFF).toByte(), + ((v shr 8) and 0xFF).toByte(), (v and 0xFF).toByte(), + ) + + private fun be16(v: Int) = byteArrayOf(((v shr 8) and 0xFF).toByte(), (v and 0xFF).toByte()) + private fun be24(v: Int) = byteArrayOf( + ((v shr 16) and 0xFF).toByte(), ((v shr 8) and 0xFF).toByte(), (v and 0xFF).toByte(), + ) + + private val t0 = 1_723_000_000L + + /** One 6-byte series record: `[ts2000 u32][value byte @+4][pad]` (V/S/W/Y at `x5/b.java:1314,1371,1486,1150`). */ + private fun rec6(unix: Long, value: Int): ByteArray = be32(raw2000(unix)) + byteArrayOf(value.toByte(), 0x00) + + /** One 6-byte series record: `[ts2000 u32][value u16 @+4..5]` (U/R at `x5/b.java:1261,1097`) — the stride is exactly 6. */ + private fun rec6u16(unix: Long, value: Int): ByteArray = be32(raw2000(unix)) + be16(value) + + /** One 7-byte sleep record: `[ts2000 u32][model @+4][2 unused]` (Z at `x5/b.java:1537,1538`). */ + private fun recSleep(unix: Long, model: Int): ByteArray = + be32(raw2000(unix)) + byteArrayOf(model.toByte(), 0x00, 0x00) + + // ── Steps (a0, id -60) ─────────────────────────────────────────────────────── + + @Test + fun `steps decodes 16-byte records with 3-byte count and decimetre distance`() { + // a0() @1558-1574: [ts u32][pad @+4][steps d(i+5,i+7) @1570][calorie d(i+8,i+11)/10 @1571] + // [distance d(i+12,i+15)/10000 @1572], stride 16 @1573. Distance raw is decimetres, so + // metres = raw/10 (the vendor renders raw/10000 as km). The middle record has 0 steps and + // is dropped, as in the iOS port's bucket filter. + val rec1 = be32(raw2000(t0)) + byteArrayOf(0x00) + be24(8421) + be32(3100) + be32(124_000) + val rec2 = be32(raw2000(t0 + 3600)) + byteArrayOf(0x00) + be24(0) + be32(0) + be32(0) + val rec3 = be32(raw2000(t0 + 7200)) + byteArrayOf(0x00) + be24(1234) + be32(450) + be32(2_000) + + val events = RWfitJLHistory.decodeSteps(rec1 + rec2 + rec3) + + assertEquals(2, events.size) + val b1 = events[0] as RingDecodedEvent.ActivityBucket + assertEquals(8421, b1.steps) + assertEquals(12_400, b1.distanceMeters) // 124000 raw decimetres / 10 + assertEquals(t0, b1._timestamp.epochSecond) + val b2 = events[1] as RingDecodedEvent.ActivityBucket + assertEquals(1234, b2.steps) + assertEquals(200, b2.distanceMeters) + assertEquals(t0 + 7200, b2._timestamp.epochSecond) + } + + @Test + fun `steps timestamp is epoch-2000 base minus the getOffset correction`() { + // A raw stamp of 0 must land on 2000-01-01T00:00:00Z minus the zone correction — the + // `+ 946684800` at a0() @1562, not the 2001 base the old triage notes carried. + val rec = be32(0) + byteArrayOf(0x00) + be24(1) + be32(0) + be32(0) + val event = (RWfitJLHistory.decodeSteps(rec).single() as RingDecodedEvent.ActivityBucket) + assertEquals( + Instant.ofEpochSecond(RWfitJLHistory.JIELI_EPOCH_SECONDS - jlTzCorrection()), + event._timestamp, + ) + } + + @Test + fun `steps ignores a partial 16-byte tail`() { + // The vendor loop's guard only tests the record start (a0 @1555) and would run off the end + // of a torn body; the port stops at the boundary instead — identical on well-formed bodies. + val full = be32(raw2000(t0)) + byteArrayOf(0x00) + be24(500) + be32(10) + be32(100) + val torn = full + full.copyOfRange(0, 10) + assertEquals(1, RWfitJLHistory.decodeSteps(torn).size) + assertTrue(RWfitJLHistory.decodeSteps(ByteArray(0)).isEmpty()) + } + + // ── Heart rate (V, id -62) ────────────────────────────────────────────────── + + @Test + fun `heart rate decodes 6-byte records and drops zero bpm`() { + // V() @1296-1321: [ts u32][hr @+4 @1314][pad], stride 6 @1317; the vendor drops + // `hr == 0` itself (@1318-1320) — a "no reading" slot, not a 0 bpm sample. + val p = rec6(t0, 72) + rec6(t0 + 60, 0) + rec6(t0 + 120, 88) + byteArrayOf(0xDE.toByte(), 0xAD.toByte(), 0xBE.toByte()) // torn tail + val events = RWfitJLHistory.decodeHeartRate(p).filterIsInstance() + + assertEquals(2, events.size) + assertEquals(MeasurementKind.HEART_RATE, events[0].kind_field) + assertEquals(72.0, events[0].value, 0.0) + assertEquals(t0, events[0]._timestamp.epochSecond) + assertEquals(88.0, events[1].value, 0.0) + assertEquals(t0 + 120, events[1]._timestamp.epochSecond) + } + + // ── Blood pressure (T, id -64) ────────────────────────────────────────────── + + @Test + fun `blood pressure emits systolic and diastolic from a 6-byte record`() { + // T() @1187-1210: [ts u32][sp @+4 @1205-1207][dp @+5 @1208], stride 6 @1209. A zero in + // either field is a "no sample" slot and yields nothing. + val body = + be32(raw2000(t0)) + byteArrayOf(120.toByte(), 78) + + be32(raw2000(t0 + 60)) + byteArrayOf(0, 78) + + be32(raw2000(t0 + 120)) + byteArrayOf(120.toByte(), 0) + + val events = RWfitJLHistory.decodeBloodPressure(body).filterIsInstance() + + assertEquals(2, events.size) + assertEquals(MeasurementKind.BLOOD_PRESSURE_SYSTOLIC, events[0].kind_field) + assertEquals(120.0, events[0].value, 0.0) + assertEquals(MeasurementKind.BLOOD_PRESSURE_DIASTOLIC, events[1].kind_field) + assertEquals(78.0, events[1].value, 0.0) + assertEquals(events[0]._timestamp, events[1]._timestamp) + assertEquals(t0, events[0]._timestamp.epochSecond) + } + + // ── Sleep (Z, id -66) ─────────────────────────────────────────────────────── + + @Test + fun `sleep reconstructs a session from stage-transition records`() { + // Z() @1523-1539 decodes the flat {time, model} stream; the session build is the vendor's + // consumer (s1.java): 0x11 opens (1004-1006), 0x22 closes (1008-1018), segment N spans the + // gap to record N+1 (1134-1135). The 0x11 marker's own segment counts as light (1149-1151). + val p = recSleep(t0, 0x11) + + recSleep(t0 + 600, 1) + // deep segment starts 10 min in + recSleep(t0 + 1200, 2) + // light segment starts 20 min in + recSleep(t0 + 1800, 0x22) // wakeup + + val timeline = (RWfitJLHistory.decodeSleep(p).single() as RingDecodedEvent.SleepTimeline) + + assertEquals(30, timeline.stages.size) + assertEquals(SleepStage.LIGHT, timeline.stages[0]) // the 0x11 marker's segment + assertEquals(SleepStage.DEEP, timeline.stages[10]) + assertEquals(SleepStage.LIGHT, timeline.stages[20]) + assertEquals(t0, timeline._timestamp.epochSecond) // anchored at the 0x11 marker + assertTrue(timeline.completeSession) + } + + @Test + fun `sleep maps the vendor stage bytes 1 deep 2 light 0 and 3 awake 4 rem`() { + // s1.java:1139-1157 — NOT the legacy 0/1/2/3 map (s1.java:1636-1645 is the 0x7E consumer). + val p = recSleep(t0, 0x11) + + recSleep(t0 + 60, 2) + + recSleep(t0 + 120, 1) + + recSleep(t0 + 180, 4) + + recSleep(t0 + 240, 3) + + recSleep(t0 + 300, 0) + + recSleep(t0 + 360, 0x22) + + val stages = (RWfitJLHistory.decodeSleep(p).single() as RingDecodedEvent.SleepTimeline).stages + + assertEquals(listOf(SleepStage.LIGHT, SleepStage.LIGHT, SleepStage.DEEP, SleepStage.REM, + SleepStage.AWAKE, SleepStage.AWAKE), stages) + } + + @Test + fun `sleep counts the session-start marker segment as light like the vendor`() { + // s1.java:1149-1151: the 0x11 marker's own segment (gap to the next record) is tallied + // into lightTime, so a night that is otherwise all awake still carries one light minute. + val p = recSleep(t0, 0x11) + + recSleep(t0 + 60, 0) + + recSleep(t0 + 120, 0x22) + + val stages = (RWfitJLHistory.decodeSleep(p).single() as RingDecodedEvent.SleepTimeline).stages + assertEquals(listOf(SleepStage.LIGHT, SleepStage.AWAKE), stages) + } + + @Test + fun `sleep emits nothing for a marker pair with no minutes and an unclosed tail`() { + // A back-to-back 0x11/0x22 pair has a zero-minute marker segment, so no stages accumulate + // (minutes > 0 guard, s1.java's delta division); a session without its 0x22 marker + // produces no DataSleep in the vendor either (s1.java:1113). + val p = recSleep(t0, 0x11) + + recSleep(t0, 0x22) + + recSleep(t0 + 7200, 0x11) + + recSleep(t0 + 7320, 1) // no wakeup marker follows + + assertTrue(RWfitJLHistory.decodeSleep(p).isEmpty()) + } + + @Test + fun `sleep decodes two closed sessions and drops a torn 7-byte tail`() { + val p = recSleep(t0, 0x11) + + recSleep(t0 + 120, 1) + + recSleep(t0 + 240, 0x22) + + recSleep(t0 + 3600, 0x11) + + recSleep(t0 + 3720, 2) + + recSleep(t0 + 3840, 0x22) + + recSleep(t0 + 7200, 0x11).copyOfRange(0, 4) // torn tail: 4 of 7 bytes + + val timelines = RWfitJLHistory.decodeSleep(p).filterIsInstance() + + assertEquals(2, timelines.size) + assertEquals(t0, timelines[0]._timestamp.epochSecond) + assertEquals(t0 + 3600, timelines[1]._timestamp.epochSecond) + } + + // ── Temperature (U, id -68) ───────────────────────────────────────────────── + + @Test + fun `temperature is raw u16 over 10 with no legacy plus-200 offset`() { + // U() @1243-1263: setTemp(d(i+4, i+5) / 10.0f) @1261 — the value is already °C×10 on the + // JieLi wire (the +200 encoding belongs to the 0x7E stream's u0()). Raw 0 = no sample. + val p = rec6u16(t0, 365) + rec6u16(t0 + 60, 0) + val events = RWfitJLHistory.decodeTemperature(p).filterIsInstance() + + assertEquals(1, events.size) + assertEquals(MeasurementKind.TEMPERATURE, events[0].kind_field) + assertEquals(36.5, events[0].value, 1e-9) + assertEquals(t0, events[0]._timestamp.epochSecond) + } + + // ── SpO2 (S, id -70) ──────────────────────────────────────────────────────── + + @Test + fun `spo2 decodes byte 4 and drops zero`() { + // S() @1132-1154: setBloodOxy(bArr[i+4] & 255) @1150-1152, stride 6 @1153. + val p = rec6(t0, 97) + rec6(t0 + 60, 0) + val events = RWfitJLHistory.decodeSpo2(p).filterIsInstance() + + assertEquals(1, events.size) + assertEquals(MeasurementKind.SPO2, events[0].kind_field) + assertEquals(97.0, events[0].value, 0.0) + assertEquals(t0, events[0]._timestamp.epochSecond) + } + + // ── HRV (W, id -72) ───────────────────────────────────────────────────────── + + @Test + fun `hrv decodes byte 4 and drops zero`() { + // W() @1353-1375: setHrv(bArr[i+4] & 255) @1371-1373, stride 6 @1374. + val p = rec6(t0, 42) + rec6(t0 + 60, 0) + val events = RWfitJLHistory.decodeHrv(p).filterIsInstance() + + assertEquals(1, events.size) + assertEquals(MeasurementKind.HRV, events[0].kind_field) + assertEquals(42.0, events[0].value, 0.0) + } + + // ── Stress (Y, id -74) ────────────────────────────────────────────────────── + + @Test + fun `stress decodes byte 4 and drops zero like the vendor`() { + // Y() @1467-1492: setPressure(bArr[i+4] & 255) @1486-1488, stride 6 @1489, and the vendor + // drops value == 0 itself (@1490-1492). + val p = rec6(t0, 33) + rec6(t0 + 60, 0) + val events = RWfitJLHistory.decodeStress(p).filterIsInstance() + + assertEquals(1, events.size) + assertEquals(MeasurementKind.STRESS, events[0].kind_field) + assertEquals(33.0, events[0].value, 0.0) + } + + // ── Blood sugar (R, id -112) ──────────────────────────────────────────────── + + @Test + fun `blood sugar is u16 over 10 mmol converted to the app unit mg per dL`() { + // R() @1085-1098: setSugar(d(i+4, i+5) / 10.0f) @1097 — the vendor displays mmol/L + // (SugarStatisticsFragment.java:439-442), and this app's BLOOD_SUGAR kind speaks mg/dL + // everywhere, so the port converts with the standard glucose factor (18.016), same + // convention as YCBTHealthRecords.bloodSugarMgdl. Raw 0 = no sample. + val p = rec6u16(t0, 56) + rec6u16(t0 + 60, 0) + val events = RWfitJLHistory.decodeBloodSugar(p).filterIsInstance() + + assertEquals(1, events.size) + assertEquals(MeasurementKind.BLOOD_SUGAR, events[0].kind_field) + assertEquals(5.6 * 18.016, events[0].value, 1e-9) + assertEquals(t0, events[0]._timestamp.epochSecond) + } + + // ── Dispatch ──────────────────────────────────────────────────────────────── + + @Test + fun `unknown 05 keys decode to null so the driver keeps logging them`() { + // Sport {5,14,16} (Q @1011), Muslim count {5,23,16} (X @1405) and the rest of the + // 05-group table (y5/c.java:105-166) have no PulseLoop metric — decode() must say "not + // ported", not fabricate a layout. The {.,.,0x30} delete variants have no vendor parser at + // all, so they land in the same branch. + assertNull(RWfitJLHistory.decode(0x0E, ByteArray(12))) + assertNull(RWfitJLHistory.decode(0x14, ByteArray(12))) + assertNull(RWfitJLHistory.decode(0x17, ByteArray(12))) + } + + @Test + fun `every ported key answers through the shared dispatch`() { + val keys = listOf( + RWfitProtocol.JLDataType.STEPS, + RWfitProtocol.JLDataType.HEART_RATE, + RWfitProtocol.JLDataType.BLOOD_PRESSURE, + RWfitProtocol.JLDataType.SLEEP, + RWfitProtocol.JLDataType.TEMPERATURE, + RWfitProtocol.JLDataType.SPO2, + RWfitProtocol.JLDataType.HRV, + RWfitProtocol.JLDataType.STRESS, + RWfitProtocol.JLDataType.BLOOD_SUGAR, + ) + for (key in keys) { + // An empty body (bare-triple reply from a ring that holds no records) decodes to + // nothing, never an error — the vendor's loops simply don't run (e.g. a0 @1555). + assertTrue("$key", RWfitJLHistory.decode(key, ByteArray(0)).orEmpty().isEmpty()) + } + } +} From 04a6fcd562fb1b7c971a8e82444fb028c50f2a63 Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Sat, 22 Aug 2026 22:07:50 -0700 Subject: [PATCH 16/22] Ledger: #130 RWfit JieLi 0xAB history decode done (c9be848) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single-list row and the rebuild section's scope/testing notes now record: 05-group bodies decoded per-type (steps/HR/BP/sleep/temp/SpO2/HRV/stress/blood sugar, layouts cited to x5/b.java a0/V/T/Z/U/S/W/Y/R), bare-triple requests fired once per connection, 0x30 variants and non-metric keys explicitly unported, suite 1166 -> 1191 with the decoder-oracle + driver burst tests. Still no hardware validation — unchanged caveat. --- docs/ios-sync.md | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/docs/ios-sync.md b/docs/ios-sync.md index e09fbbbf..0cc53a4a 100644 --- a/docs/ios-sync.md +++ b/docs/ios-sync.md @@ -1612,10 +1612,15 @@ on, whereas "fixing" it would put us an hour off theirs. - **Legacy `0x7E`: complete.** Framing, serials, XOR, the `0xFE`/`0xFF` handshake, multi-packet reassembly, all six history streams, battery, manifest-gated cascade. -- **JieLi `0xAB`: framing complete, payloads not.** Handshake, battery, time sync and the ACK - discipline work; the `05`-group history bodies have their own per-type layouts that have **not** - been extracted. `RWfitSyncEngine` therefore does not request history on a JieLi link, and the - driver logs those frames rather than guessing at them. +- **JieLi `0xAB`: framing and history payloads complete (updated 2026-08-22).** Handshake, + battery, time sync and the ACK discipline work; the `05`-group history bodies are now decoded + per-type from the vendor parsers (`RWfitJLHistory.kt`; layouts in `x5/b.java` + `a0`/`V`/`T`/`Z`/`U`/`S`/`W`/`Y`/`R`, each cited), and `RWfitSyncEngine` fires the whole ported + catalog once per connection as bare `{5, type, 0x10}` triples — no payload, the vendor's own + request shape (`y.java:345-537`, `TRingHeartRateStatisticsActivity.java:545`). Remaining gaps: + sport `{5,14,16}` (`Q`), Muslim count `{5,23,16}` (`X`) and the other non-metric `05` keys (the + driver still logs those), and the `{5,x,0x30}` delete variants — which have **no vendor parser** + in `x5/b.java` at all and are never sent here. - **Feature bitmap not decoded** (`x5/b.java i()` → `SupportMenuBean`), so `bitmapGatedCapabilities` is declared but nothing grants from it yet. Manual/realtime measurement and the per-SKU sensors stay ungranted rather than being handed out unconditionally — the vendor has no legacy on-demand @@ -1625,8 +1630,11 @@ on, whereas "fixing" it would put us an hour off theirs. ### Testing -49 unit tests across `RWfitCodecTest` (20), `RWfitDecoderTest` (16) and `RWfitDriverTest` (13), -asserting vendor byte layouts rather than the implementation. Suite: 812 → 866. +Unit tests across `RWfitCodecTest` (20), `RWfitDecoderTest` (17), `RWfitJLHistoryTest` (17) and +`RWfitDriverTest` (21), asserting vendor byte layouts rather than the implementation. The 2026-08-22 +JieLi payload port added `RWfitJLHistoryTest` and replaced the old "JieLi does not request history" +driver test with the burst/once-per-connection/reply-decode set; parent re-ran the full suite after +landing: **1191 tests, 0 failures** (was 1170). **No hardware validation.** Nothing here has talked to a real RWfit ring. Say so on the PR. From d3d1371688128b7606b9e8e92a600e76182b35cb Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Sun, 23 Aug 2026 15:12:57 -0700 Subject: [PATCH 17/22] Honor a caller-supplied JSON schema on the self-hosted provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LocalOpenAICompatClient dropped the caller's `text.format` and substituted the coach chat's own `coach_response` schema for it — in `response_format` and, via CoachResponseSchema.promptInstruction, in the system prompt as well. Every other adapter already translates that field (OpenRouterClient.chatResponseFormat), so this was the outlier. The effect on a guided-decoding backend was not degraded output but impossible output: a caller asking for its own strict schema had the model constrained to a different shape and instructed, in the prompt, to answer in that different shape. Reproduced on a Pixel 10 Pro against vLLM with Response format = Strict schema — the new meal estimator failed every single call with "The AI didn't return a usable estimate" until this fix, then succeeded. CoachSummaryGenerator sends `text.format` the same way and had the same latent bug. A caller schema now supplies both the `response_format` payload and the prompt instruction; with no caller schema the coach path is byte-for-byte unchanged. Response format = OFF still sends no `response_format` at all — that setting means the backend rejects the field, and a caller does not get to override the user's compatibility choice; the schema travels in the prompt instead, which every structured caller here decodes fence-tolerantly. 5 tests covering both directions of the substitution, the OFF case and a malformed text.format. --- .../coach/local/LocalOpenAICompatClient.kt | 58 +++++++++++--- .../coach/LocalOpenAICompatClientTest.kt | 80 +++++++++++++++++++ 2 files changed, 128 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/pulseloop/coach/local/LocalOpenAICompatClient.kt b/app/src/main/java/com/pulseloop/coach/local/LocalOpenAICompatClient.kt index 364666de..f0ec7ac4 100644 --- a/app/src/main/java/com/pulseloop/coach/local/LocalOpenAICompatClient.kt +++ b/app/src/main/java/com/pulseloop/coach/local/LocalOpenAICompatClient.kt @@ -108,10 +108,25 @@ class LocalOpenAICompatClient( val tools = (req["tools"] as? JsonArray)?.mapNotNull { it as? JsonObject } ?: emptyList() val previousResponseId = (req["previous_response_id"] as? JsonPrimitive)?.contentOrNull - if (previousResponseId == null) setupConversation(input) + // A caller-supplied strict schema (`text.format`) — the contract every other adapter + // already honors (OpenRouterClient.chatResponseFormat). Non-chat callers such as the + // meal estimator and the summary generator ask for their OWN json_schema here; without + // this the coach chat's `coach_response` schema was substituted for theirs, which on a + // guided-decoding backend made their reply impossible to produce, not merely unlikely. + val callerFormat = callerJsonSchema(req) + + if (previousResponseId == null) setupConversation(input, callerFormat != null) else appendContinuation(previousResponseId, input) - return buildChatBody(if (toolCallingEnabled) convertTools(tools) else emptyList()) + return buildChatBody(if (toolCallingEnabled) convertTools(tools) else emptyList(), callerFormat) + } + + /** The caller's `text.format` when it is a usable json_schema block, else null. */ + internal fun callerJsonSchema(req: JsonObject): JsonObject? { + val format = (req["text"] as? JsonObject)?.get("format") as? JsonObject ?: return null + if ((format["type"] as? JsonPrimitive)?.contentOrNull != "json_schema") return null + if (format["schema"] !is JsonObject) return null + return format } // ── Conversation setup ─────────────────────────────────────────────── @@ -122,7 +137,7 @@ class LocalOpenAICompatClient( * joins the system block rather than trailing the conversation (where MiniMax puts it) because * a system turn after a user turn raises in several local chat templates. */ - private fun setupConversation(input: List) { + private fun setupConversation(input: List, callerSuppliedSchema: Boolean = false) { messages = mutableListOf() storedAssistantMessage.clear() @@ -143,8 +158,10 @@ class LocalOpenAICompatClient( } // Only the prompt tells an unconstrained local model what shape to answer in. Even with // `response_format` on, this stays — it's what the orchestrator's JSON-repair loop leans on - // when a small model ignores the grammar. - systemParts.add(CoachResponseSchema.promptInstruction) + // when a small model ignores the grammar. A caller that brought its OWN schema gets the + // instruction for THAT schema instead: injecting `coach_response` there told the model to + // answer in a shape its caller cannot parse. + if (!callerSuppliedSchema) systemParts.add(CoachResponseSchema.promptInstruction) systemPrompt = systemParts.filter { it.isNotBlank() }.joinToString("\n\n") messages.addAll(conversation) } @@ -247,8 +264,11 @@ class LocalOpenAICompatClient( // ── Build request body ─────────────────────────────────────────────── - internal fun buildChatBody(tools: List): JsonObject { + internal fun buildChatBody(tools: List, callerFormat: JsonObject? = null): JsonObject { val allMessages = mutableListOf() + val systemPrompt = if (callerFormat == null) systemPrompt + else listOf(systemPrompt, schemaInstruction(callerFormat)) + .filter { it.isNotBlank() }.joinToString("\n\n") if (systemPrompt.isNotBlank()) { allMessages.add(JsonObject(mapOf( "role" to JsonPrimitive("system"), @@ -264,7 +284,7 @@ class LocalOpenAICompatClient( "messages" to JsonArray(allMessages), ) if (tools.isNotEmpty()) body["tools"] = JsonArray(tools) - responseFormat()?.let { body["response_format"] = it } + responseFormat(callerFormat)?.let { body["response_format"] = it } maxOutputTokens?.takeIf { it > 0 }?.let { body["max_tokens"] = JsonPrimitive(it) } return JsonObject(body) } @@ -276,19 +296,37 @@ class LocalOpenAICompatClient( * LM Studio and recent llama.cpp all accept. `JSON_OBJECT` is the older, weaker mode; LM * Studio doesn't implement it, hence the choice. */ - internal fun responseFormat(): JsonObject? = when (structuredOutput) { + internal fun responseFormat(callerFormat: JsonObject? = null): JsonObject? = when (structuredOutput) { + // The user picked OFF because their backend rejects `response_format` outright. A caller's + // schema does not override that — it travels in the prompt instead, and every structured + // caller here decodes fence-tolerantly. LocalStructuredOutput.OFF -> null LocalStructuredOutput.JSON_OBJECT -> JsonObject(mapOf("type" to JsonPrimitive("json_object"))) LocalStructuredOutput.JSON_SCHEMA -> JsonObject(mapOf( "type" to JsonPrimitive("json_schema"), "json_schema" to JsonObject(mapOf( - "name" to JsonPrimitive("coach_response"), + "name" to JsonPrimitive( + (callerFormat?.get("name") as? JsonPrimitive)?.contentOrNull ?: "coach_response"), "strict" to JsonPrimitive(true), - "schema" to CoachResponseSchema.schema, + "schema" to (callerFormat?.get("schema") as? JsonObject ?: CoachResponseSchema.schema), )), )) } + /** + * The prompt-side statement of a caller-supplied schema, standing in for + * [CoachResponseSchema.promptInstruction]. It is what carries the shape when the user's + * Response format is OFF, and the backup when a small model ignores the grammar. + */ + internal fun schemaInstruction(callerFormat: JsonObject): String { + val name = (callerFormat["name"] as? JsonPrimitive)?.contentOrNull ?: "response" + val schema = callerFormat["schema"] as? JsonObject ?: return "" + return "Your final answer MUST be a single JSON object (no Markdown, no code fences, " + + "no prose before or after) matching this exact `$name` JSON Schema. Every key listed " + + "in \"required\" must be present:\n" + + json.encodeToString(JsonObject.serializer(), schema) + } + // ── Parse Chat Completions response → OpenAIResponse (internal for tests) ─ internal fun ingestResponse(root: JsonObject): OpenAIResponse { diff --git a/app/src/test/java/com/pulseloop/coach/LocalOpenAICompatClientTest.kt b/app/src/test/java/com/pulseloop/coach/LocalOpenAICompatClientTest.kt index 09375ac6..3720901f 100644 --- a/app/src/test/java/com/pulseloop/coach/LocalOpenAICompatClientTest.kt +++ b/app/src/test/java/com/pulseloop/coach/LocalOpenAICompatClientTest.kt @@ -38,13 +38,31 @@ class LocalOpenAICompatClientTest { input: List, tools: List = emptyList(), previousResponseId: String? = null, + textFormat: JsonObject? = null, ) = JsonObject(buildMap { put("model", JsonPrimitive("qwen3:8b")) put("input", JsonArray(input)) put("tools", JsonArray(tools)) previousResponseId?.let { put("previous_response_id", JsonPrimitive(it)) } + textFormat?.let { put("text", JsonObject(mapOf("format" to it))) } }) + /** A caller-supplied strict schema, shaped like MealEstimator's `meal_estimate`. */ + private val mealFormat = JsonObject(mapOf( + "type" to JsonPrimitive("json_schema"), + "name" to JsonPrimitive("meal_estimate"), + "strict" to JsonPrimitive(true), + "schema" to JsonObject(mapOf( + "type" to JsonPrimitive("object"), + "properties" to JsonObject(mapOf( + "name" to JsonObject(mapOf("type" to JsonPrimitive("string"))), + "calories" to JsonObject(mapOf("type" to JsonPrimitive("number"))), + )), + "required" to JsonArray(listOf(JsonPrimitive("name"), JsonPrimitive("calories"))), + "additionalProperties" to JsonPrimitive(false), + )), + )) + private val functionTool = JsonObject(mapOf( "type" to JsonPrimitive("function"), "name" to JsonPrimitive("get_hr"), @@ -315,4 +333,66 @@ class LocalOpenAICompatClientTest { assertTrue(assistantIdx in 0 until toolIdx) assertEquals("call_1", m[toolIdx]["tool_call_id"]!!.jsonPrimitive.content) } + + // ── Caller-supplied schema (iOS #96 meal estimator / summary generator) ── + + @Test + fun `a caller schema replaces the coach_response schema in response_format`() { + val body = client(structured = LocalStructuredOutput.JSON_SCHEMA) + .buildRequestBody(request(listOf(msg("user", "hi")), textFormat = mealFormat)) + val schema = body["response_format"]!!.jsonObject["json_schema"]!!.jsonObject + assertEquals("meal_estimate", schema["name"]!!.jsonPrimitive.content) + // The point of the fix: a guided-decoding backend must not be handed the chat schema for + // a meal call — it would force a shape MealAnalysisLogic.decode can never parse. + assertEquals( + mealFormat["schema"]!!.jsonObject, + schema["schema"]!!.jsonObject, + ) + } + + @Test + fun `a caller schema suppresses the coach_response prompt instruction`() { + val body = client(structured = LocalStructuredOutput.JSON_SCHEMA) + .buildRequestBody(request(listOf(msg("system", "SYS"), msg("user", "hi")), textFormat = mealFormat)) + val system = content(messages(body).first { role(it) == "system" }) + assertTrue(system.startsWith("SYS")) + assertFalse(system.contains("coach_response")) + assertFalse(system.contains("response_type")) + // …and states the caller's schema instead, which is what carries the shape when the + // user's Response format is OFF. + assertTrue(system.contains("meal_estimate")) + assertTrue(system.contains("calories")) + } + + @Test + fun `no caller schema keeps the coach_response instruction and schema`() { + val body = client(structured = LocalStructuredOutput.JSON_SCHEMA) + .buildRequestBody(request(listOf(msg("user", "hi")))) + assertEquals( + "coach_response", + body["response_format"]!!.jsonObject["json_schema"]!!.jsonObject["name"]!!.jsonPrimitive.content, + ) + assertTrue(content(messages(body).first { role(it) == "system" }).contains("coach_response")) + } + + @Test + fun `response format off still sends no response_format even with a caller schema`() { + // OFF means "my backend rejects response_format"; a caller's schema does not override + // that — it travels in the prompt, and structured callers decode fence-tolerantly. + val body = client(structured = LocalStructuredOutput.OFF) + .buildRequestBody(request(listOf(msg("user", "hi")), textFormat = mealFormat)) + assertNull(body["response_format"]) + assertTrue(content(messages(body).first { role(it) == "system" }).contains("meal_estimate")) + } + + @Test + fun `a malformed text format is ignored rather than replacing the coach schema`() { + val notJsonSchema = JsonObject(mapOf("type" to JsonPrimitive("text"))) + val body = client(structured = LocalStructuredOutput.JSON_SCHEMA) + .buildRequestBody(request(listOf(msg("user", "hi")), textFormat = notJsonSchema)) + assertEquals( + "coach_response", + body["response_format"]!!.jsonObject["json_schema"]!!.jsonObject["name"]!!.jsonPrimitive.content, + ) + } } From e80c76c0c649125719512e30880d47ab85502705 Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Sun, 23 Aug 2026 15:13:25 -0700 Subject: [PATCH 18/22] Port iOS #96 barcode scanner + AI meal analysis (finish the nutrition subset) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two camera features deferred when the OFF client and coach tools landed. BarcodeScannerScreen — CameraX preview + ImageAnalysis feeding ML Kit's bundled-model scanner, restricted to the four symbologies Open Food Facts is keyed on (EAN-13/EAN-8/UPC-E/Code-128, BarcodeScannerSheet.swift:56). First non-empty payload delivered exactly once, then unbind (:77-87). Missing camera or denied permission shows iOS's fallback copy verbatim (:26-39). A scan drives FoodDatabaseClient.product(barcode) cache-first and prefills the meal-log dialog, which now records off_barcode provenance and flips userEdited when the numbers are changed after the prefill (MealLogSheet.swift:596-611). MealAnalysisSheet — the four-phase sheet (input/analyzing/review/failed) with camera capture or PickVisualMedia, meal-type picker, provenance badge, confidence caption below high, assumptions block and editable macros. MealEstimator is one structured single-shot call through the existing coach provider stack: verbatim system prompt, verbatim strict meal_estimate schema, images through the CoachAttachmentStore downscale/encode pipeline, fence-tolerant decode. Saves with source llm_estimate, confidence high/medium/else -> known/partial/unknown, notes = assumptions, timestamp via NutritionTools.resolveTimestamp. Two deliberate divergences, both recorded in ios-sync.md: - MealEntryEntity has no photo-ref column, so the photo feeds the analysis call only and is not persisted. No schema migration. - Android has no photo-analysis sub-toggle and no on-device provider mode, so iOS's three-part entry gate (NutritionView.swift:36-48) collapses to the coach master toggle alone. Also carries reasoningEffort into the request, which iOS passes at :401, and records confidence "known" on a barcode row to match iOS's MealEntry default (NutritionModels.swift:99) — Android's entity default "medium" is outside the known/partial/unknown vocabulary, left alone here as it predates this work. 20 unit tests over the pure logic: enable predicates, fence-tolerant decode, confidence mapping, meal-type inference, accepted symbology set. Suite 1191 -> 1211, 0 failures. --- app/build.gradle.kts | 17 + app/src/main/AndroidManifest.xml | 6 + .../pulseloop/coach/tools/NutritionTools.kt | 1 + .../ui/screens/BarcodeScannerScreen.kt | 289 +++++++ .../pulseloop/ui/screens/MealAnalysisSheet.kt | 736 ++++++++++++++++++ .../pulseloop/ui/screens/NutritionScreen.kt | 252 +++++- app/src/main/res/xml/file_paths.xml | 3 + .../ui/screens/BarcodeSymbologiesTest.kt | 41 + .../ui/screens/MealAnalysisLogicTest.kt | 130 ++++ 9 files changed, 1468 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/pulseloop/ui/screens/BarcodeScannerScreen.kt create mode 100644 app/src/main/java/com/pulseloop/ui/screens/MealAnalysisSheet.kt create mode 100644 app/src/test/java/com/pulseloop/ui/screens/BarcodeSymbologiesTest.kt create mode 100644 app/src/test/java/com/pulseloop/ui/screens/MealAnalysisLogicTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 104f1c5c..4618c16d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -172,6 +172,23 @@ dependencies { // series, so it is a faithful API reference for this pin. implementation("androidx.health.connect:connect-client:1.1.0") + // Phase 9 (iOS #96 stage A): barcode scanner. ML Kit's bundled-model artifact runs + // regardless of Play Services state (the -play-services variant would dead-end on + // devices without the updated services). CameraX 1.4.x for preview + analysis. + implementation("com.google.mlkit:barcode-scanning:17.3.0") + val cameraXVersion = "1.4.2" + implementation("androidx.camera:camera-core:$cameraXVersion") + // The CameraX camera2 implementation artifact is "camera-camera2" (the partial's + // "camera2" coordinate does not exist on Google Maven). + implementation("androidx.camera:camera-camera2:$cameraXVersion") + implementation("androidx.camera:camera-lifecycle:$cameraXVersion") + implementation("androidx.camera:camera-view:$cameraXVersion") + + // CameraX's ProcessCameraProvider.getInstance() exposes Guava ListenableFuture in its + // signature, but the graph also carries Google's "9999.0-empty-to-avoid-conflict-with-guava" + // stub, which strips the class at compile time. Full guava restores it. + implementation("com.google.guava:guava:33.3.1-android") + debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-test-manifest") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cd672893..b2fd07bf 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -21,6 +21,12 @@ + + + + + diff --git a/app/src/test/java/com/pulseloop/ui/screens/BarcodeSymbologiesTest.kt b/app/src/test/java/com/pulseloop/ui/screens/BarcodeSymbologiesTest.kt new file mode 100644 index 00000000..1961edb6 --- /dev/null +++ b/app/src/test/java/com/pulseloop/ui/screens/BarcodeSymbologiesTest.kt @@ -0,0 +1,41 @@ +package com.pulseloop.ui.screens + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The scanner's accepted symbology set — iOS restricts the VisionKit data scanner to + * [.ean13, .ean8, .upce, .code128] (BarcodeScannerSheet.swift:56), the four Open Food Facts + * is keyed on. Anything else (QR, PDF417, Data Matrix, …) must stay out of the set so a + * poster QR code in frame can never be delivered as a "barcode". + */ +class BarcodeSymbologiesTest { + + @Test + fun acceptsExactlyTheFourOpenFoodFactsSymbologies() { + assertEquals( + setOf("EAN-13", "EAN-8", "UPC-E", "Code-128"), + BarcodeSymbologies.accepted, + ) + } + + @Test + fun acceptsEachOfTheFour() { + assertTrue(BarcodeSymbologies.isAccepted(BarcodeSymbologies.EAN13)) + assertTrue(BarcodeSymbologies.isAccepted(BarcodeSymbologies.EAN8)) + assertTrue(BarcodeSymbologies.isAccepted(BarcodeSymbologies.UPCE)) + assertTrue(BarcodeSymbologies.isAccepted(BarcodeSymbologies.CODE128)) + } + + @Test + fun rejectsSymbologiesIosDoesNotScan() { + assertFalse(BarcodeSymbologies.isAccepted("QR")) + assertFalse(BarcodeSymbologies.isAccepted("PDF417")) + assertFalse(BarcodeSymbologies.isAccepted("Data Matrix")) + // UPC-A rides in as an EAN-13 with a leading zero; it is not a separate accepted name. + assertFalse(BarcodeSymbologies.isAccepted("UPC-A")) + assertFalse(BarcodeSymbologies.isAccepted("")) + } +} diff --git a/app/src/test/java/com/pulseloop/ui/screens/MealAnalysisLogicTest.kt b/app/src/test/java/com/pulseloop/ui/screens/MealAnalysisLogicTest.kt new file mode 100644 index 00000000..2f786180 --- /dev/null +++ b/app/src/test/java/com/pulseloop/ui/screens/MealAnalysisLogicTest.kt @@ -0,0 +1,130 @@ +package com.pulseloop.ui.screens + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for the pure decision logic ported from iOS's MealEstimator + * (MealAnalysisSheet.swift) — the enable predicates, the fence-tolerant decode, the + * confidence-to-provenance mapping, and the meal-type inference. + */ +class MealAnalysisLogicTest { + + // ── canAnalyze — iOS MealAnalysisSheet.swift:39-41 ───────────────── + + @Test + fun canAnalyzeWithAnImageAlone() { + assertTrue(MealAnalysisLogic.canAnalyze(hasImage = true, description = "")) + } + + @Test + fun canAnalyzeWithThreeTrimmedCharacters() { + // iOS trims whitespace BEFORE counting, so padding never qualifies a too-short + // description. + assertTrue(MealAnalysisLogic.canAnalyze(hasImage = false, description = "two eggs")) + assertTrue(MealAnalysisLogic.canAnalyze(hasImage = false, description = " abc ")) + } + + @Test + fun cannotAnalyzeShortOrBlankDescriptionsWithoutImage() { + assertFalse(MealAnalysisLogic.canAnalyze(hasImage = false, description = "")) + // "ab" padded to 4 raw characters still trims to 2. + assertFalse(MealAnalysisLogic.canAnalyze(hasImage = false, description = " ab ")) + } + + // ── canSave — iOS MealAnalysisSheet.swift:43-45 ──────────────────── + + @Test + fun canSavesWithNumericCalories() { + assertTrue(MealAnalysisLogic.canSave(name = "Omelette", calories = "520")) + assertTrue(MealAnalysisLogic.canSave(name = " Omelette ", calories = "520.5")) + } + + @Test + fun cannotSaveWithoutANameOrANumber() { + assertFalse(MealAnalysisLogic.canSave(name = "", calories = "520")) + assertFalse(MealAnalysisLogic.canSave(name = " ", calories = "520")) + // iOS Double(calories) == nil — any non-numeric string fails. + assertFalse(MealAnalysisLogic.canSave(name = "Omelette", calories = "")) + assertFalse(MealAnalysisLogic.canSave(name = "Omelette", calories = "about 500")) + } + + // ── confidence mapping — iOS save(), MealAnalysisSheet.swift:306 ─── + + @Test + fun confidenceMapsToProvenanceKnownPartialUnknown() { + assertEquals("known", MealAnalysisLogic.confidenceRaw("high")) + assertEquals("partial", MealAnalysisLogic.confidenceRaw("medium")) + assertEquals("unknown", MealAnalysisLogic.confidenceRaw("low")) + // Anything else (missing, garbage) lands on unknown, like iOS's else branch. + assertEquals("unknown", MealAnalysisLogic.confidenceRaw(null)) + assertEquals("unknown", MealAnalysisLogic.confidenceRaw("HIGH")) + } + + // ── inferred meal type — iOS NutritionModels.swift:20-27 ────────── + + @Test + fun inferredMealTypeFollowsTheClockBuckets() { + assertEquals("breakfast", MealAnalysisLogic.inferredMealType(4)) + assertEquals("breakfast", MealAnalysisLogic.inferredMealType(10)) + assertEquals("lunch", MealAnalysisLogic.inferredMealType(11)) + assertEquals("lunch", MealAnalysisLogic.inferredMealType(14)) + assertEquals("snack", MealAnalysisLogic.inferredMealType(15)) + assertEquals("dinner", MealAnalysisLogic.inferredMealType(17)) + assertEquals("dinner", MealAnalysisLogic.inferredMealType(21)) + assertEquals("snack", MealAnalysisLogic.inferredMealType(22)) + assertEquals("snack", MealAnalysisLogic.inferredMealType(3)) + } + + // ── fence-tolerant decode — iOS MealEstimator.decode :413-422 ───── + + private val fullJson = + """{"name":"Rice and beans","calories":450.0,"protein_g":15.0,"carbs_g":80.0,"fat_g":6.0,"assumptions":"1 cup cooked","confidence":"high"}""" + + @Test + fun decodesPlainJsonObject() { + val e = MealAnalysisLogic.decode(fullJson) + assertNotNull(e) + assertEquals("Rice and beans", e!!.name) + assertEquals(450.0, e.calories, 1e-9) + assertEquals(15.0, e.proteinG, 1e-9) + assertEquals(80.0, e.carbsG, 1e-9) + assertEquals(6.0, e.fatG, 1e-9) + assertEquals("high", e.confidence) + } + + @Test + fun decodesInsideMarkdownFences() { + val fenced = "```json\n" + fullJson + "\n```" + assertEquals("Rice and beans", MealAnalysisLogic.decode(fenced)!!.name) + } + + @Test + fun decodesInsideSurroundingProse() { + val prose = "Here is your estimate:\n" + fullJson + "\nHope this helps!" + assertEquals("Rice and beans", MealAnalysisLogic.decode(prose)!!.name) + } + + @Test + fun toleratesUnknownKeysAndMissingAssumptions() { + val text = "{\"name\":\"Soup\",\"calories\":120,\"protein_g\":4,\"carbs_g\":10,\"fat_g\":2,\"confidence\":\"medium\",\"extra\":true}" + val e = MealAnalysisLogic.decode(text)!! + assertEquals("Soup", e.name) + // iOS decodes assumptions as a present String; the Android port defaults it so a + // provider omitting the key still yields a usable estimate. + assertEquals("", e.assumptions) + } + + @Test + fun returnsNullForGarbageAndMissingRequiredFields() { + assertNull(MealAnalysisLogic.decode("")) + assertNull(MealAnalysisLogic.decode("no json here at all")) + // A JSON object missing required fields is unusable — same as iOS's decode failure. + assertNull(MealAnalysisLogic.decode("{\"name\":\"Soup\"}")) + assertNull(MealAnalysisLogic.decode("{}")) + } +} From 5f8e197fefdbb6d1cff4fff634092fb0de40a628 Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Sun, 23 Aug 2026 15:16:00 -0700 Subject: [PATCH 19/22] Ledger: #96 nutrition complete (e80c76c); port queue now empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops #96 from Outstanding — all four parts are on main, with the barcode scanner and AI meal analysis device-verified on a Pixel 10 Pro against the user's own vLLM server. Renumbers the remaining three rows. The session note records what was verified at runtime versus what needed human hands: barcode->OFF->save and describe->LLM->save were driven end to end and read back out of pulseloop.db, while the photo->vision->save path was handed to the user to shoot an actual plate of food. It also records the self-hosted-provider bug that finishing this exposed (d3d1371) and the two deliberate iOS divergences — no photo persistence, single-gate entry buttons. Two stale entries corrected while here. Row 2 still described #130's JieLi history bodies as undecoded and marked it "start now" even though c9be848 finished them the day before — 04a6fcd's message claimed it had updated the single-list row, but its diff only touched the rebuild section, so the work list would have sent the next reader to redo finished work. "Range covered" likewise still read "11 ported, #130 backed out", predating the rebuild. Sync state now records a live git fetch: origin/main is 439ca81 and local main is 0 commits behind, so upstream is fully triaged and ported. No row on the list can be started — two need ring hardware to validate code already written, one needs an Android Activity-trends screen that does not exist. The resume block says so directly rather than implying a top item. --- docs/ios-sync.md | 86 ++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 72 insertions(+), 14 deletions(-) diff --git a/docs/ios-sync.md b/docs/ios-sync.md index 0cc53a4a..2559c30c 100644 --- a/docs/ios-sync.md +++ b/docs/ios-sync.md @@ -31,8 +31,8 @@ the work list, and assembling one from all three is how items get missed. | **Fork baseline (iOS)** | `600c7a8` — Merge PR #6, 2026-06-20 | | **Last triaged iOS commit** | `439ca81` — Merge PR #93 (Colmi R11 CRP driver), 2026-08-09 | | **Last triage date** | 2026-08-22 | -| **Last port date** | 2026-08-22 — PR #96 nutrition OFF client + cache (`a13238d`) + five coach tools (`05d8833`, barcode/AI-photo deferred as needs-hardware) + Workout pause intervals (`71f251e`) + PR #94 `CoachNotificationDataTrigger` (`9d43227`) + PR #93 hardening (`c95b6e8`) | -| **Range covered** | 12 first-parent items since `0d1b965` (2026-07-18): PRs #73, #94–#100, #130, #131, #93 + 1 direct commit (`160c775`) → **11 ported, #130 backed out** (#94's data-trigger feature and #93's 5 hardening fixes both landed this session) | +| **Last port date** | 2026-08-23 — PR #96 nutrition **complete**: barcode scanner + AI meal analysis (`e80c76c`) on top of the OFF client (`a13238d`) and five coach tools (`05d8833`); plus the self-hosted-provider schema fix they exposed (`d3d1371`). 2026-08-22 — #130 RWfit JieLi history (`c9be848`), Workout pause intervals (`71f251e`), PR #94 `CoachNotificationDataTrigger` (`9d43227`), PR #93 hardening (`c95b6e8`) | +| **Range covered** | 12 first-parent items since `0d1b965` (2026-07-18): PRs #73, #94–#100, #130, #131, #93 + 1 direct commit (`160c775`) → **all 12 ported** (#130 was backed out as fabricated, then rebuilt from the vendor decompile — see its row below). Verified against a live `git fetch` on 2026-08-23: `origin/main` is `439ca81` and local `main` is 0 commits behind, so upstream is fully triaged and ported | --- @@ -42,16 +42,15 @@ Everything upstream that is **not yet on Android `main`**, in one place. This re port queue, the resume block and the session notes to assemble the picture yourself. The port queue below is the per-PR audit trail; **this table is the work list.** -Ordered by readiness, not size: the top rows can be started immediately, the bottom rows are -blocked on something outside the code. +Ordered by readiness, not size. **As of 2026-08-23 no row can be started** — the port queue is +empty. Two rows need ring hardware to verify code that is already written; the third needs an +Android screen that does not exist yet. Nothing here is waiting on someone to finish a port. | # | Item | What is actually left | Size | Ready? | |---|------|----------------------|------|--------| -| 1 | **#96 nutrition subset** | **2 of 4 parts done this session** (`a13238d` + `05d8833`): the Open Food Facts client + 500-row LRU cache (so `food_products` now populates) and the five coach tools (`search_food_database`/`get_nutrition_log`/`log_meal`/`update_meal_entry`/`delete_meal_entry`). **Remaining: barcode scanner + AI photo analysis** — both camera features (iOS `BarcodeScannerSheet` VisionKit; `MealAnalysisSheet` 423-line photo + vision-LLM), the build has no camera/barcode/vision dependency, and there is no device here, so they are **deferred, not ported blind** (hardware guidance — see #82/#90). | L→M | ⛔ needs hardware (camera) for the remaining 2 parts | -| 2 | **#130 RWfit — finish the JieLi `0xAB` path** | The vendor rebuild landed on `main`. The legacy `0x7E` path is complete; the JieLi `0xAB` framing is complete but **its history bodies are not decoded yet**. Read `decompiled-rwfit-official/`, never iOS and never guesswork (root `AGENTS.md`). | M | ✅ start now | -| 3 | **#82 YCBT (TK5 + SmartHealth-Colmi)** | Protocol layer is on `main` (`7a941a5`, `849131d`). **No code known to be missing** — what is missing is a live connect against real hardware. If one fails, re-read `BleHelper.java`'s connect sequence: the vendor's MTU/bonding/pacing timing was deliberately *not* copied (see the 2026-07-19 note). | — | ⛔ needs hardware | -| 4 | **#90 LuckRing / TK18** | Protocol layer is on `main` (`57e1e23`). Same position as #82: no known code gap, never validated against a real TK18. | — | ⛔ needs hardware | -| 5 | **#79 Activity Year trends** | Divide the in-progress current month by elapsed days, not a full 30/31. | S | ⛔ blocked — Android has no Activity-trends screen to fix | +| 1 | **#82 YCBT (TK5 + SmartHealth-Colmi)** | Protocol layer is on `main` (`7a941a5`, `849131d`). **No code known to be missing** — what is missing is a live connect against real hardware. If one fails, re-read `BleHelper.java`'s connect sequence: the vendor's MTU/bonding/pacing timing was deliberately *not* copied (see the 2026-07-19 note). | — | ⛔ needs hardware | +| 2 | **#90 LuckRing / TK18** | Protocol layer is on `main` (`57e1e23`). Same position as #82: no known code gap, never validated against a real TK18. | — | ⛔ needs hardware | +| 3 | **#79 Activity Year trends** | Divide the in-progress current month by elapsed days, not a full 30/31. The `S` is the *iOS* fix; Android has no Activity-trends screen at all, so the real scope is building the screen first. This is the one remaining **feature** gap — it is not blocked on hardware. | S (iOS) / L (Android) | ⛔ blocked — Android has no Activity-trends screen to fix | ### Not on this list, and why @@ -60,8 +59,12 @@ blocked on something outside the code. `origin`, but it is fully contained in `main` — read `main`, not the branch. - **PR #45 review remediation** — done. The 2026-08-09 review's parity bugs in #95/#98/#99/#100 and the #94 regression were all fixed in `8df67b1` + `8f81c40`. Only rows 2–4 above survive from it. -- **#130 RWfit rebuild** — the *rebuild* is done and on `main` (the ledger's - `feat/rwfit-vendor-rebuild` is stale); only row 5 remains. +- **#130 RWfit rebuild** — done, including the JieLi `0xAB` history bodies (`c9be848`, + 2026-08-22). The rebuild is on `main` (the ledger's `feat/rwfit-vendor-rebuild` is stale). + **Never hardware-validated** — see the rebuild section's own caveat before shipping it. +- **#96 nutrition subset** — done, all four parts. The OFF client + cache (`a13238d`) and five + coach tools (`05d8833`) landed 2026-08-22; the barcode scanner + AI meal analysis landed + 2026-08-23 (`e80c76c`). Device-verified end to end — see the session note below. - Everything else in the port queue is `☑` or `⊘`. ### Branch note @@ -155,7 +158,7 @@ seeded-data mode). **SKIP** — no portable behavior, Android has its own indepe | ☑ | [#94](https://github.com/saksham2001/PulseLoopiOS/pull/94) `459f7f1` | ~07-21 | Background syncs + `StaleDataPolicy` + data-gated coach notifications | **ADAPT** | M | `0ca53a1` + `c4aab74` (CR fix: wire STALE_DATA_WINDOW_MS) + **`9d43227`** (the data-trigger feature itself — the bus subscriber + (dateKey,slotRaw) dedupe + stale-skip — was the one part of #94 never ported) | | ☑ | [#95](https://github.com/saksham2001/PulseLoopiOS/pull/95) `dae95ab` | ~07-22 | HR zone colors/thresholds (evidence-based defaults + Standard/Auto/Custom modes + resting-HR baseline learning) | **PORT** | M–L | `0ca53a1` | | ☑ | [#97](https://github.com/saksham2001/PulseLoopiOS/pull/97) `cb8e1cd` | ~07-23 | LittleMeatball R10M YCBT support + 9 shared YCBT bugfixes | **ALREADY-HAVE** | — | iOS PR is itself a port of PulseLoopAndroid#31 | -| ☑ | [#96](https://github.com/saksham2001/PulseLoopiOS/pull/96) `c0def0f` | ~07-24 | Calorie + macro nutrition tracking (meal logging, barcode scan, OFF search, AI photo analysis, coach `log_meal` tool, intake goals, provenance tags) | **ADAPT (subset)** — manual meal logging + goals (`4084671`+`c4aab74`); this session added the OFF client + cache (`a13238d`) and the five coach tools (`05d8833`). **Barcode + AI photo remain** (camera features; need a build dependency + a real device — deferred, not ported blind). | XL | `4084671` + `c4aab74` + `a13238d` + `05d8833` | +| ☑ | [#96](https://github.com/saksham2001/PulseLoopiOS/pull/96) `c0def0f` | ~07-24 | Calorie + macro nutrition tracking (meal logging, barcode scan, OFF search, AI photo analysis, coach `log_meal` tool, intake goals, provenance tags) | **ADAPT — complete 2026-08-23.** Manual meal logging + goals (`4084671`+`c4aab74`), OFF client + 500-row cache (`a13238d`), five coach tools (`05d8833`), and finally both camera features (`e80c76c`): ML Kit + CameraX barcode scanner restricted to OFF's four symbologies, and the four-phase AI meal-analysis sheet running one structured single-shot call through the existing coach provider stack. Two divergences, both deliberate: the photo is **not persisted** (`MealEntryEntity` has no photo-ref column and this port ships no migration), and the entry buttons gate on the coach master toggle alone (Android has neither iOS's photo-analysis sub-toggle nor an on-device provider mode). Landing it also exposed and fixed a self-hosted-provider bug (`d3d1371`) — see the session note. | XL | `4084671` + `c4aab74` + `a13238d` + `05d8833` + `e80c76c` | | ☑ | [#99](https://github.com/saksham2001/PulseLoopiOS/pull/99) `f06be51` | ~07-25 | Full-data JSON export/import (all models → single JSON file, atomic wipe-and-restore on import) | **PORT** | M | `802789d` + `c4aab74` (CR fix: atomic transaction, wearableLogs roundtrip, BuildConfig appVersion) | | ☑ | [#100](https://github.com/saksham2001/PulseLoopiOS/pull/100) `4947628` | ~07-26 | Strava OAuth connect + TCX upload (GPS-HR merge, auto-dedup, token refresh) + shareable PNG stat cards | **ADAPT** | L | `4ce34dc` + `c4aab74` (CR fix: mobile endpoint, intent-filter, redirect handler, pollUntilDone, BuildConfig secrets, shared OkHttpClient) | | ☑ | — `160c775` | ~07-26 | Set version to 2.5.0 + read About version from bundle | **ALREADY-HAVE** | — | `68c9788` (versionName → 2.5.0 to match iOS MARKETING_VERSION) | @@ -175,8 +178,11 @@ seeded-data mode). **SKIP** — no portable behavior, Android has its own indepe > **▶ RESUME HERE:** see [**Outstanding — the single list**](#outstanding--the-single-list) above. > It consolidates every open thread that used to be split across this block, the port queue and the -> session notes. **#93 CRP hardening is now done** (`c95b6e8`, 2026-08-22); top of the list is now -> **#94 `CoachNotificationDataTrigger`**. +> session notes. **As of 2026-08-23 the port queue is empty** — a live `git fetch` puts +> `origin/main` at `439ca81` with local `main` 0 commits behind, and every first-parent item since +> `0d1b965` is ported. The three remaining rows are all blocked: #82 and #90 need ring hardware to +> validate code that is already written, #79 needs an Android Activity-trends screen that does not +> exist. **Do not start a port from this block — there is none to start.** > > Next triage after those: `git -C log --first-parent --oneline 439ca81..main`. > @@ -486,6 +492,58 @@ their own M-sized item and drop to Tier 2/3; only #61d/#61e are Tier-1-sized. - **#79 Activity Year-trends** (S) — blocked: no Activity-trends screen on Android yet (not created by #57's redesign either). - ~~**#74 Measurement-Frequency relocation**~~ ✅ **DONE** `368a3f2` (2026-07-19) — see the session note below. +### 2026-08-23 session — #96 camera features, on a real device + +Closes #96. Both camera features landed in `e80c76c`, plus a coach-provider fix in `d3d1371` +that finishing them exposed. + +**Runtime-verified on a Pixel 10 Pro (API 37, arm64), debug build, against the user's own +vLLM server** — not emulated, not inferred from tests: + +- **Barcode → OFF → prefill → save.** A real packaged-food scan resolved through Open Food + Facts and persisted: `sourceRaw=off_barcode`, `offProductCode=0010878850577`. Read back out + of `pulseloop.db`, not just off the screen. +- **Describe → LLM → review → save.** Text-only path: 317 kcal / P18 C29 F15, persisted with + `sourceRaw=llm_estimate`, `confidenceRaw=partial` (medium→partial), `notes` = the assumptions + string, meal type inferred `lunch` at 13:00. +- **Photo → vision → review → save.** *Needed human hands — the phone was handed back for + this one.* A photographed plate came back as "Spaghetti with Meatballs, Basil & Parmesan", + 880 kcal, with assumptions describing detail only visible in the image ("5 medium pan-fried + meatballs… ~20g shaved parmesan"). That text is the proof the `CoachAttachmentStore` + downscale → base64 `input_image` pipeline actually reached the model. Row persisted correctly. +- **Failed phase + retry** rendered correctly — observed for real, before the fix below. +- ML Kit initialized on device (its prefs file exists); empty crash buffer, no app-level + error or warning lines throughout. + +**The bug this exposed — worth reading before touching the local provider.** +`LocalOpenAICompatClient` ignored the caller's `text.format` entirely and substituted the coach +chat's own `coach_response` schema, in `response_format` *and* in the system prompt via +`CoachResponseSchema.promptInstruction`. On a guided-decoding backend that is not degradation, +it is impossibility: the model was constrained to one shape and instructed to produce that same +wrong shape, so `MealAnalysisLogic.decode` could never parse it. The meal estimator failed +**every** call with "The AI didn't return a usable estimate" until fixed. Every other adapter +already translated that field (`OpenRouterClient.chatResponseFormat`), so the local client was +the outlier — and `CoachSummaryGenerator` sends `text.format` the same way and had the same +latent bug. `Response format = OFF` still sends no `response_format`: that setting means the +backend rejects the field, and a caller does not get to override the user's compatibility choice. + +**Two deliberate divergences from iOS**, both as instructed: + +- The photo is **not persisted**. `MealEntryEntity` genuinely has no photo-ref column + (`NutritionEntities.kt:6-36`), and this port ships no schema migration, so the image feeds + the analysis call and is then discarded. iOS stores it via `CoachAttachmentStore` into + `photoRefJSON` (`MealAnalysisSheet.swift:296-307`). +- The entry buttons gate on **coach-enabled alone**. iOS gates on coach + cloud provider + a + nutrition photo-analysis sub-toggle (`NutritionView.swift:36-48`); Android has neither that + pref nor an on-device provider mode, so the three-part gate collapses to one. + +**One smaller parity fix:** a barcode row now records confidence `known`, matching iOS's +`MealEntry.init` default (`NutritionModels.swift:99`). Android's `MealEntryEntity` defaults to +`"medium"`, which is not in the known/partial/unknown vocabulary at all — left alone here +because it predates this work and other writers depend on it. **Worth fixing separately.** + +Suite 1191 → 1211, 0 failures. + ### 2026-08-22 triage (since `88c0f6b` → `439ca81`, 12 commits / 1 first-parent) Exactly **one** untriaged first-parent item: **PR #93, the Colmi R11 CRP driver** (25 files, From 0765b3725ca147c9603ed202fa7a0679681a0666 Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Sun, 23 Aug 2026 15:26:35 -0700 Subject: [PATCH 20/22] Default meal confidence to "known" like iOS, and normalize the rows that missed it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MealEntryEntity.confidenceRaw defaulted to "medium", which is not a value in the known/partial/unknown vocabulary the rest of the app uses — every deliberate writer maps onto those three (NutritionTools.decodeConfidenceRaw, MealAnalysisLogic.confidenceRaw, MeasurementModal, MetricsService), and every other entity in the schema already defaults to "known". So a stored "medium" was never anyone's intent, only this default leaking through. iOS has no such value: MealEntry.init defaults confidence to .known (NutritionModels.swift:99) and its reader falls back to .known for an unrecognized raw (:147). Entity and the DataArchive DTO it round-trips now default to "known", and MIGRATION_22_23 rewrites the rows that already carry "medium". The rewrite is unconditional because no legitimate row can hold that value. This also removes the MealLogSave.confidenceRaw plumbing added a commit ago to special-case barcode rows: with the default correct there is nothing to override, which is exactly iOS's arrangement — MealLogSheet never passes a confidence. Verified on a Pixel 10 Pro: upgrading in place put user_version at 23 with no crash, and the existing off_barcode row moved medium -> known while the two llm_estimate rows correctly kept partial. No migration unit test — this module sets exportSchema = false, so there is no MigrationTestHelper harness to hang one on. Suite 1211, 0 failures. --- .../main/java/com/pulseloop/data/DataArchive.kt | 2 +- .../com/pulseloop/data/PulseLoopDatabase.kt | 17 ++++++++++++++++- .../pulseloop/data/entity/NutritionEntities.kt | 9 ++++++++- .../com/pulseloop/ui/screens/NutritionScreen.kt | 12 +----------- 4 files changed, 26 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/pulseloop/data/DataArchive.kt b/app/src/main/java/com/pulseloop/data/DataArchive.kt index 094aed65..0987227e 100644 --- a/app/src/main/java/com/pulseloop/data/DataArchive.kt +++ b/app/src/main/java/com/pulseloop/data/DataArchive.kt @@ -228,7 +228,7 @@ data class PulseArchive( val fiberG: Double? = null, val sugarG: Double? = null, val sodiumMg: Double? = null, val sourceRaw: String = "manual", val offProductCode: String? = null, val servingDescription: String? = null, val servingGrams: Double? = null, - val quantity: Double = 1.0, val confidenceRaw: String = "medium", + val quantity: Double = 1.0, val confidenceRaw: String = "known", val userEdited: Boolean = false, val notes: String? = null, val loggedByCoach: Boolean = false, val createdAt: Long, // Phase 6: exported so an in-place-edited meal's updatedAt survives an archive round-trip. diff --git a/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt b/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt index f7938188..3184487a 100644 --- a/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt +++ b/app/src/main/java/com/pulseloop/data/PulseLoopDatabase.kt @@ -43,7 +43,7 @@ import com.pulseloop.data.entity.* MealEntryEntity::class, CachedFoodProductEntity::class, ], - version = 22, + version = 23, exportSchema = false, ) abstract class PulseLoopDatabase : RoomDatabase() { @@ -428,6 +428,20 @@ abstract class PulseLoopDatabase : RoomDatabase() { } } + /** + * Normalizes `meal_entries.confidenceRaw`. The column defaulted to "medium", which is + * not a value in the known/partial/unknown vocabulary the rest of the app (and iOS) + * uses — every deliberate writer maps onto those three, so any stored "medium" is the + * old default leaking through, never a user's or the coach's intent. iOS's own reader + * falls back to `.known` for an unrecognized raw (NutritionModels.swift:147), so this + * just makes the stored bytes agree with how both platforms already read them. + */ + private val MIGRATION_22_23 = object : Migration(22, 23) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("UPDATE `meal_entries` SET `confidenceRaw` = 'known' WHERE `confidenceRaw` = 'medium'") + } + } + private fun adoptStableMeasurementIdentities(db: SupportSQLiteDatabase) { db.execSQL("DROP INDEX IF EXISTS `index_measurements_kindRaw_timestamp_sourceRaw`") db.execSQL( @@ -516,6 +530,7 @@ abstract class PulseLoopDatabase : RoomDatabase() { MIGRATION_19_20, MIGRATION_20_21, MIGRATION_21_22, + MIGRATION_22_23, ) // Downgrades only (sideloading an older APK). A blanket destructive // fallback would silently wipe every measurement, sleep session, and diff --git a/app/src/main/java/com/pulseloop/data/entity/NutritionEntities.kt b/app/src/main/java/com/pulseloop/data/entity/NutritionEntities.kt index 8a233e37..f560581e 100644 --- a/app/src/main/java/com/pulseloop/data/entity/NutritionEntities.kt +++ b/app/src/main/java/com/pulseloop/data/entity/NutritionEntities.kt @@ -21,7 +21,14 @@ data class MealEntryEntity( val servingDescription: String? = null, val servingGrams: Double? = null, val quantity: Double = 1.0, - val confidenceRaw: String = "medium", + /** + * iOS `MealEntry.init` defaults `confidence: DecodeConfidence = .known` + * (NutritionModels.swift:99), and its reader falls back to `.known` for an + * unrecognized raw (:147). "medium" was never in the known/partial/unknown + * vocabulary — nothing ever wrote it deliberately, it only leaked out of this + * default. MIGRATION_22_23 normalizes the rows that got it. + */ + val confidenceRaw: String = "known", val userEdited: Boolean = false, val notes: String? = null, val loggedByCoach: Boolean = false, diff --git a/app/src/main/java/com/pulseloop/ui/screens/NutritionScreen.kt b/app/src/main/java/com/pulseloop/ui/screens/NutritionScreen.kt index 0313449d..cf6515aa 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/NutritionScreen.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/NutritionScreen.kt @@ -221,9 +221,7 @@ fun NutritionScreen(onBack: () -> Unit) { quantity = save.quantity, userEdited = save.userEdited, ) - // null keeps MealEntryEntity's own default — see MealLogSave.confidenceRaw. - db.mealEntryDao().upsert( - save.confidenceRaw?.let { entry.copy(confidenceRaw = it) } ?: entry) + db.mealEntryDao().upsert(entry) reload() } showAddDialog = false @@ -262,13 +260,6 @@ data class MealLogSave( val carbsG: Double, val fatG: Double, val sourceRaw: String = NutritionTools.sourceRawManual, - /** - * iOS MealEntry.init defaults `confidence: DecodeConfidence = .known` and MealLogSheet never - * overrides it (NutritionModels.swift:99), so a label-backed database pick records "known". - * Null leaves MealEntryEntity's own default in place for the manual path, whose pre-existing - * "medium" is outside the known/partial/unknown vocabulary — reported, not changed here. - */ - val confidenceRaw: String? = null, val offProductCode: String? = null, val servingDescription: String? = null, val servingGrams: Double? = null, @@ -433,7 +424,6 @@ fun MealLogDialog( carbsG = carbs.toDoubleOrNull() ?: 0.0, fatG = fat.toDoubleOrNull() ?: 0.0, sourceRaw = NutritionTools.sourceRawOffBarcode, - confidenceRaw = "known", offProductCode = pp.code, servingDescription = pp.servingDescription, servingGrams = pp.gramsBasis, From b57fca79c77fb0c01141806eb40f189b3e8b98ee Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Sun, 23 Aug 2026 15:27:23 -0700 Subject: [PATCH 21/22] Ledger: record the meal-confidence fix and this session's carry-forward rules Updates the 2026-08-23 session note now that MealEntryEntity.confidenceRaw matches iOS (0765b37) rather than being flagged as a known residual, including the device-verified v22 -> v23 upgrade and the fact that exportSchema = false leaves no MigrationTestHelper harness for a migration test. Adds a carry-forward block so the durable rules live in the ledger rather than only in a session's head: test new structured coach callers against the local provider, treat Response format = OFF as a user compatibility choice, three ring families ship unvalidated, #79's S sizes the iOS fix and not the Android work, and local main's f6eb177 is a demo-seed rather than an unported upstream commit. --- docs/ios-sync.md | 42 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/docs/ios-sync.md b/docs/ios-sync.md index 2559c30c..90e2eac0 100644 --- a/docs/ios-sync.md +++ b/docs/ios-sync.md @@ -537,13 +537,47 @@ backend rejects the field, and a caller does not get to override the user's comp nutrition photo-analysis sub-toggle (`NutritionView.swift:36-48`); Android has neither that pref nor an on-device provider mode, so the three-part gate collapses to one. -**One smaller parity fix:** a barcode row now records confidence `known`, matching iOS's -`MealEntry.init` default (`NutritionModels.swift:99`). Android's `MealEntryEntity` defaults to -`"medium"`, which is not in the known/partial/unknown vocabulary at all — left alone here -because it predates this work and other writers depend on it. **Worth fixing separately.** +**Meal confidence now matches iOS** (`0765b37`, DB v22 → v23). `MealEntryEntity.confidenceRaw` +defaulted to `"medium"`, which is not a value in the known/partial/unknown vocabulary anything +else uses — every deliberate writer maps onto those three +(`NutritionTools.decodeConfidenceRaw`, `MealAnalysisLogic.confidenceRaw`, `MeasurementModal`, +`MetricsService`), and every other entity in the schema already defaults to `"known"`. A stored +`"medium"` was therefore never anyone's intent, only the default leaking through. iOS has no +such value at all: `MealEntry.init` defaults to `.known` (`NutritionModels.swift:99`) and its +reader falls back to `.known` for an unrecognized raw (:147). Entity + `DataArchive` DTO now +default to `"known"`, and `MIGRATION_22_23` rewrites the rows that already carry `"medium"` +(unconditional — no legitimate row can hold it). This also let the `MealLogSave.confidenceRaw` +plumbing go: with the default correct there is nothing to override, which is exactly iOS's +arrangement. + +Verified in place on the Pixel: `user_version` 23, no crash on upgrade, the existing +`off_barcode` row moved `medium` → `known` while both `llm_estimate` rows kept `partial`. +**No migration unit test** — this module sets `exportSchema = false`, so there is no +`MigrationTestHelper` harness to hang one on. Worth knowing before you try to add one. Suite 1191 → 1211, 0 failures. +**Carry-forward rules from this session** (the durable bits, so they survive without a memory +store): + +1. **A new structured, non-chat caller on the coach provider stack must be tried against the + *local* provider, not just OpenAI/Gemini.** That is where `text.format` was being silently + discarded, and the failure mode is total, not partial. `d3d1371` fixed the client; it did not + make the class of bug impossible. +2. **`Response format = OFF` is a user compatibility choice, not a capability hint.** It means + the backend rejects `response_format` outright. A caller's schema never overrides it — the + schema goes in the prompt, and every structured caller decodes fence-tolerantly. +3. **Three ring families are shipped-but-unvalidated**: YCBT (#82), LuckRing/TK18 (#90) and + RWfit (#130). #130 in particular was rebuilt entirely from the vendor decompile with no + hardware. Blind-porting *from the decompiled vendor app* is the normal practice here; + porting from iOS parity or guesswork is what got PR #45 backed out. Say "no hardware + validation" on the PR. +4. **#79 is the one remaining feature gap and its `S` is misleading** — that sizes the iOS fix. + Android has no Activity-trends screen at all, so the real work is building the screen. +5. **Upstream is fully triaged as of 2026-08-23** (live `git fetch`: `origin/main` = `439ca81`, + local `main` 0 behind). Local `main` carries one extra commit, `f6eb177`, a demo-seed that is + not upstream — do not try to "port" it. + ### 2026-08-22 triage (since `88c0f6b` → `439ca81`, 12 commits / 1 first-parent) Exactly **one** untriaged first-parent item: **PR #93, the Colmi R11 CRP driver** (25 files, From fb249faa6f5215f884e2c4f11a0b0e5ac280ef24 Mon Sep 17 00:00:00 2001 From: Khoa Truong Date: Sun, 23 Aug 2026 17:47:51 -0700 Subject: [PATCH 22/22] Address PR #53 review findings and bump versionCode to 39 - Local LLM: keep the coach_response prompt instruction when the caller's JSON schema is the coach_response schema itself, instead of dumping the raw schema and losing the orchestrator's repair-loop guidance. - Coach notifications: record the generic fallback delivery so a later sync can't re-run the same slot and notify twice. - Coach notifications: run a due slot as a sibling job so the debounce cancel can't land between the record insert and the delivery. - Nutrition: search the whole food-product cache table instead of only the 100 most recent rows before falling back to Open Food Facts. - Meal analysis: fall through to the {...} slice fallback when a reply that starts with { has trailing prose. - Open Food Facts: use toHttpUrlOrNull so a malformed base URL surfaces as InvalidUrl rather than an unmapped IllegalArgumentException. --- app/build.gradle.kts | 2 +- .../coach/local/LocalOpenAICompatClient.kt | 9 +++++++++ .../pulseloop/coach/tools/NutritionTools.kt | 6 +++--- .../CoachNotificationDataTrigger.kt | 9 ++++++++- .../CoachNotificationSlotRunner.kt | 18 +++++++++++++++++- .../pulseloop/nutrition/FoodProductCache.kt | 10 ++++++++++ .../pulseloop/nutrition/OpenFoodFactsClient.kt | 6 +++--- .../pulseloop/ui/screens/MealAnalysisSheet.kt | 6 +++++- 8 files changed, 56 insertions(+), 10 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4618c16d..02697bdc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -21,7 +21,7 @@ android { // versionCode/versionName are overridable from Gradle properties so the release CI // can drive them straight from the git tag (e.g. -PappVersionCode=5 -PappVersionName=1.0.0). // Local builds fall back to the literals below. - versionCode = (project.findProperty("appVersionCode") as String?)?.toIntOrNull() ?: 38 + versionCode = (project.findProperty("appVersionCode") as String?)?.toIntOrNull() ?: 39 versionName = (project.findProperty("appVersionName") as String?) ?: "2.7.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/java/com/pulseloop/coach/local/LocalOpenAICompatClient.kt b/app/src/main/java/com/pulseloop/coach/local/LocalOpenAICompatClient.kt index f0ec7ac4..e35cef29 100644 --- a/app/src/main/java/com/pulseloop/coach/local/LocalOpenAICompatClient.kt +++ b/app/src/main/java/com/pulseloop/coach/local/LocalOpenAICompatClient.kt @@ -321,6 +321,15 @@ class LocalOpenAICompatClient( internal fun schemaInstruction(callerFormat: JsonObject): String { val name = (callerFormat["name"] as? JsonPrimitive)?.contentOrNull ?: "response" val schema = callerFormat["schema"] as? JsonObject ?: return "" + // The coach chat sends its OWN `coach_response` text.format on every turn + // (CoachOrchestrator.coachResponseTextFormat), so this is the chat's normal path too — + // not just the meal estimator's. Its hand-written instruction says strictly more than a + // schema dump (no "message" key, put the answer in "summary", the length caps) and the + // orchestrator's JSON-repair loop leans on that wording, so keep it for that schema + // instead of degrading the main local-LLM path to raw JSON Schema. + if (name == "coach_response" && schema == CoachResponseSchema.schema) { + return CoachResponseSchema.promptInstruction + } return "Your final answer MUST be a single JSON object (no Markdown, no code fences, " + "no prose before or after) matching this exact `$name` JSON Schema. Every key listed " + "in \"required\" must be present:\n" + diff --git a/app/src/main/java/com/pulseloop/coach/tools/NutritionTools.kt b/app/src/main/java/com/pulseloop/coach/tools/NutritionTools.kt index 504dc55d..32a0893a 100644 --- a/app/src/main/java/com/pulseloop/coach/tools/NutritionTools.kt +++ b/app/src/main/java/com/pulseloop/coach/tools/NutritionTools.kt @@ -111,9 +111,9 @@ object NutritionTools { val dao = db.foodProductDao() val result = kotlinx.coroutines.runBlocking { // Cache-first: substring match against locally cached products (zero network). - val cached = FoodProductCache.recent(dao, 100) - .filter { it.name.contains(query, ignoreCase = true) } - .take(limit) + // Matched in SQL over the whole table — a recent-N page filtered in memory misses + // cached products that fell out of that window and pays a network call for them. + val cached = FoodProductCache.search(dao, query, limit) if (cached.isNotEmpty()) { resultsJson(cached.map { it.asFoodProduct() }, "local_cache") } else { diff --git a/app/src/main/java/com/pulseloop/notifications/CoachNotificationDataTrigger.kt b/app/src/main/java/com/pulseloop/notifications/CoachNotificationDataTrigger.kt index 718a8b76..522b19cf 100644 --- a/app/src/main/java/com/pulseloop/notifications/CoachNotificationDataTrigger.kt +++ b/app/src/main/java/com/pulseloop/notifications/CoachNotificationDataTrigger.kt @@ -86,7 +86,14 @@ class CoachNotificationDataTrigger( // on every sync-completion a re-linked ring produces. val s = checkinSettings() if (!s.coachEnabled || !s.notificationsEnabled) return@launch - slotRun() + // The run itself is a sibling job on [scope], NOT a child of this debounce job: + // the next "done" event cancels debounceJob, and a run started here takes seconds + // (network generation). Cancelling it mid-flight can land between + // recordDao.insert and deliver() inside runDueSlot — the slot recorded as sent + // with no notification shown, and the dedupe then suppresses every later attempt + // that day. Only the pending delay above is cancellable; concurrent runs are + // already covered by the runner's process-wide in-flight guard (SkippedDuplicate). + scope.launch { slotRun() } } } diff --git a/app/src/main/java/com/pulseloop/notifications/CoachNotificationSlotRunner.kt b/app/src/main/java/com/pulseloop/notifications/CoachNotificationSlotRunner.kt index 1702c727..1c4e36cb 100644 --- a/app/src/main/java/com/pulseloop/notifications/CoachNotificationSlotRunner.kt +++ b/app/src/main/java/com/pulseloop/notifications/CoachNotificationSlotRunner.kt @@ -224,8 +224,24 @@ class CoachNotificationSlotRunner( } catch (e: Exception) { // Ultimate fallback — pre-extraction worker behavior: even when everything // else blows up, the user still gets the generic check-in. + val slot = resolvedSlot ?: CoachNotificationSlot.forcedSlot(hourOf(now)) runCatching { deliver(GENERIC_TITLE, GENERIC_BODY) } - return CoachNotificationOutcome.Sent(resolvedSlot ?: CoachNotificationSlot.forcedSlot(hourOf(now))) + // Record it like every other delivery. This IS a send, so the (dateKey, slotRaw) + // key has to exist or the data trigger (which re-runs the due slot on the next + // sync completion, minutes later) sees no record and delivers the slot a second + // time — exactly the double-send the dedupe exists to stop. Best-effort: if the + // failure that landed us here was the DAO itself, we still don't want to throw. + runCatching { + recordDao.insert( + CoachNotificationRecordEntity( + title = GENERIC_TITLE, + body = GENERIC_BODY, + dateKey = dateKeyFor(now), + slotRaw = slotRaw(slot), + ) + ) + } + return CoachNotificationOutcome.Sent(slot) } finally { runInFlight.set(false) } diff --git a/app/src/main/java/com/pulseloop/nutrition/FoodProductCache.kt b/app/src/main/java/com/pulseloop/nutrition/FoodProductCache.kt index fdf7d7aa..59073425 100644 --- a/app/src/main/java/com/pulseloop/nutrition/FoodProductCache.kt +++ b/app/src/main/java/com/pulseloop/nutrition/FoodProductCache.kt @@ -24,6 +24,16 @@ object FoodProductCache { suspend fun recent(dao: FoodProductDao, limit: Int = 12): List = dao.recent(limit) + /** + * Substring match over the WHOLE cache, most-used first — the cache-first leg of + * `search_food_database`. Goes through the DAO's `LIKE` query rather than filtering a + * recent-N page in memory: the cache holds up to [MAX_CACHED_PRODUCTS] rows, so a + * recent-100 window silently misses an older cached match and sends the tool to the + * rate-limited Open Food Facts API for a product it already has. + */ + suspend fun search(dao: FoodProductDao, query: String, limit: Int): List = + dao.search(query, limit) + /** * Mark a cached product as used (bumps the frequency/recency signals). Re-upserts the * cache row only — iOS's `touchProduct` mutates the row and lets the caller batch the diff --git a/app/src/main/java/com/pulseloop/nutrition/OpenFoodFactsClient.kt b/app/src/main/java/com/pulseloop/nutrition/OpenFoodFactsClient.kt index d5597dab..4f5dc260 100644 --- a/app/src/main/java/com/pulseloop/nutrition/OpenFoodFactsClient.kt +++ b/app/src/main/java/com/pulseloop/nutrition/OpenFoodFactsClient.kt @@ -6,7 +6,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import okhttp3.HttpUrl -import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient import okhttp3.Request import java.io.IOException @@ -69,7 +69,7 @@ class OpenFoodFactsClient( ) : FoodDatabaseClient { override suspend fun product(barcode: String): FoodProduct? = withContext(Dispatchers.IO) { - val base = productBase.toHttpUrl() ?: throw OpenFoodFactsError.InvalidUrl + val base = productBase.toHttpUrlOrNull() ?: throw OpenFoodFactsError.InvalidUrl val url = base.newBuilder() .addPathSegment("api") .addPathSegment("v2") @@ -90,7 +90,7 @@ class OpenFoodFactsClient( override suspend fun search(query: String, pageSize: Int): List = withContext(Dispatchers.IO) { - val base = searchBase.toHttpUrl() ?: throw OpenFoodFactsError.InvalidUrl + val base = searchBase.toHttpUrlOrNull() ?: throw OpenFoodFactsError.InvalidUrl val url = base.newBuilder() .addPathSegment("search") .addQueryParameter("q", query) diff --git a/app/src/main/java/com/pulseloop/ui/screens/MealAnalysisSheet.kt b/app/src/main/java/com/pulseloop/ui/screens/MealAnalysisSheet.kt index 660b89be..54782540 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/MealAnalysisSheet.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/MealAnalysisSheet.kt @@ -604,7 +604,11 @@ object MealAnalysisLogic { if (trimmed.isEmpty()) return null val json = Json { ignoreUnknownKeys = true } if (trimmed.startsWith("{")) { - return try { json.decodeFromString(Estimate.serializer(), trimmed) } catch (_: Exception) { null } + // Whole-text parse first, but FALL THROUGH to the slice on failure rather than + // returning: kotlinx rejects trailing input, so a reply that opens with the object + // and appends a sentence of prose ("{...}\n\nLet me know if…") is perfectly + // recoverable and used to be reported as "no usable estimate". + try { return json.decodeFromString(Estimate.serializer(), trimmed) } catch (_: Exception) { } } val start = trimmed.indexOf('{') val end = trimmed.lastIndexOf('}')