iOS sync: close the port queue (#93, #94, #96, #130, workout pause intervals) - #53
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ports the outstanding iOS→Android sync queue and closes it out. With this branch, every
first-parent upstream item since
0d1b965is ported — verified against a livegit fetch(
origin/main=439ca81, localmain0 commits behind).docs/ios-sync.mdis the audit trail; its "Outstanding" table now holds three rows, none ofthem startable (two need ring hardware, one needs an Android screen that doesn't exist).
What's here
c95b6e8CoachNotificationDataTrigger(run due slot on sync completion)9d4322771f251ea13238d,05d8833,e80c76c0xAB05-group history bodiesc9be848d3d1371knownlike iOS (DB v22→v23)0765b37Suite: 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):
of
pulseloop.db(off_barcode, code0010878850577). 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
CoachAttachmentStoredownscale → base64
input_imagepipeline reached the model. Failed-phase and retry observedfor real. No crashes; ML Kit initialized.
user_version23, no crash, the existingoff_barcoderow moved
medium→knownwhile bothllm_estimaterows correctly keptpartial.NOT hardware-validated — three ring families:
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 fabricatedand 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.state: complete protocol layers, never a live connect.
R11 owner (zaggash) has not re-validated this specific hardening pass.
The one non-obvious fix
LocalOpenAICompatClientwas discarding the caller'stext.formatand substituting the coachchat's own
coach_responseschema — inresponse_formatand, viaCoachResponseSchema.promptInstruction, in the system prompt. On a guided-decoding backendthat 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
CoachSummaryGeneratorsendstext.formatthe same way and had the same latent bug.Response format = OFFdeliberately still sends noresponse_format: that setting means theuser's backend rejects the field, and a caller doesn't get to override a compatibility choice.
Deliberate divergences from iOS (recorded in the ledger)
MealEntryEntityhas no photo-ref column andthis ports no schema migration for one. iOS stores it via
CoachAttachmentStore.nutrition photo-analysis sub-toggle nor an on-device provider mode.
{5,14,16}, Muslim count{5,23,16}and the{5,x,0x30}delete variantsare unported — the delete variants have no vendor parser at all.