Skip to content

iOS sync: close the port queue (#93, #94, #96, #130, workout pause intervals) - #53

Merged
foureight84 merged 23 commits into
mainfrom
ios-sync-triage-2026-08-22
Aug 24, 2026
Merged

iOS sync: close the port queue (#93, #94, #96, #130, workout pause intervals)#53
foureight84 merged 23 commits into
mainfrom
ios-sync-triage-2026-08-22

Conversation

@foureight84

Copy link
Copy Markdown
Owner

Ports the outstanding iOS→Android sync queue and closes it out. With this branch, every
first-parent upstream item since 0d1b965 is ported
— verified against a live git fetch
(origin/main = 439ca81, local main 0 commits behind).

docs/ios-sync.md is the audit trail; its "Outstanding" table now holds three rows, none of
them startable (two need ring hardware, one needs an Android screen that doesn't exist).

What's here

Item Commits
#93 Colmi R11 CRP driver hardening c95b6e8
#94 CoachNotificationDataTrigger (run due slot on sync completion) 9d43227
Workout pause intervals (Strava TCX drops paused trackpoints) 71f251e
#96 nutrition — OFF client + cache, five coach tools, barcode scanner, AI meal analysis a13238d, 05d8833, e80c76c
#130 RWfit JieLi 0xAB 05-group history bodies c9be848
Self-hosted provider: honor a caller-supplied JSON schema d3d1371
Meal confidence defaults to known like iOS (DB v22→v23) 0765b37

Suite: 1211 tests, 0 failures (was 1166 at the start of the branch).

Verified on hardware vs. not — please read before merging

Device-verified (Pixel 10 Pro, API 37, debug build, against a real vLLM server):

  • #96 end to end. Barcode → Open Food Facts → prefill → save, with the row read back out
    of pulseloop.db (off_barcode, code 0010878850577). Describe → LLM → review → save
    (llm_estimate/partial, notes = assumptions, meal type inferred). Photo → vision → review
    → save, with a human aiming the camera at an actual plate of food — the returned assumptions
    described detail only visible in the image, which is what proves the CoachAttachmentStore
    downscale → base64 input_image pipeline reached the model. Failed-phase and retry observed
    for real. No crashes; ML Kit initialized.
  • DB v22 → v23 upgrade in place. user_version 23, no crash, the existing off_barcode
    row moved mediumknown while both llm_estimate rows correctly kept partial.

NOT hardware-validated — three ring families:

  • #130 RWfit. Nothing here has ever talked to a real RWfit ring. The whole family was
    rebuilt from decompiled-rwfit-official/ after PR iOS sync (2026-08-08): port 8 PRs across Tiers 1–3 #45's version was found to be fabricated
    and backed out. The JieLi history bodies in this branch are read from the vendor parsers
    (x5/b.java, each layout cited in code) but are unproven against hardware.
  • #82 YCBT and #90 LuckRing/TK18 are untouched by this branch and remain in the same
    state: complete protocol layers, never a live connect.
  • #93 CRP hardening follows the vendor decompile and the issue Falling to pair Colmi R11 #29 capture analysis; the
    R11 owner (zaggash) has not re-validated this specific hardening pass.

The one non-obvious fix

LocalOpenAICompatClient was discarding the caller's text.format and substituting the coach
chat's own coach_response schema — in response_format and, via
CoachResponseSchema.promptInstruction, in the system prompt. On a guided-decoding backend
that isn't degraded output, it's impossible output: the model is constrained to one shape and
told to produce that same wrong shape. The new meal estimator failed 100% of calls until
this was 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 deliberately still sends no response_format: that setting means the
user's backend rejects the field, and a caller doesn't get to override a compatibility choice.

Deliberate divergences from iOS (recorded in the ledger)

  • #96: the meal photo is not persistedMealEntryEntity has no photo-ref column and
    this ports no schema migration for one. iOS stores it via CoachAttachmentStore.
  • #96: entry buttons gate on the coach master toggle alone; Android has neither iOS's
    nutrition photo-analysis sub-toggle nor an on-device provider mode.
  • #130: sport {5,14,16}, Muslim count {5,23,16} and the {5,x,0x30} delete variants
    are unported — the delete variants have no vendor parser at all.

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.
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.
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.
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.
…d-but-present

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.
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).
- 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.
…etion)

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.
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.
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".
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.
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).
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).
…); barcode + AI photo deferred (needs camera + device)

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).
#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).
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.
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.
… subset)

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.
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.
…hat missed it

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.
…rd 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.
- 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.
@foureight84
foureight84 merged commit b33c89c into main Aug 24, 2026
1 check passed
@foureight84
foureight84 deleted the ios-sync-triage-2026-08-22 branch August 24, 2026 00:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant