fix: Today tiles clip at raised font scale; coach dies on a single DNS blip (+ Health Connect plan) - #47
Merged
Conversation
…ork failures Two unrelated field bugs from the same report (Pixel 10 Pro XL). **Today Activity tile clipped the calories row.** `TodayTileMetrics.height` was a fixed `168.dp` while everything inside a tile is sized in `sp`. The Activity tile's three label/value pairs need `3 x (12+18)sp + 6dp`, and only ~108dp survives the 16dp padding, the eyebrow row and the 8dp spacer — so they stop fitting at roughly **fontScale 1.15** and the third row (CAL) is sliced off. This is NOT a density/DPI problem, despite looking like one: dp and sp both scale with density, so a 420dpi Pixel 8 and a 480dpi Pixel 10 Pro XL lay out identically at the same font-size setting. That is why the earlier issue-#24 fix verified clean on the Pixel 8. Shrinking the text a second time would only move the breaking point, so the container grows instead — height is now derived from the font scale, sampled at 16.sp (Android 14+ scales each sp size on its own non-linear curve, so `fontScale` alone under-reports it) and clamped to 1.0..1.6. Every tile reads the same value, so the grid stays uniform and the Sleep/Chart/Gauge tiles get the same protection. **Coach turns died on a single DNS blip, with unreadable errors.** `ResponsesHttp` made exactly one attempt and surfaced the raw JDK string, so a momentary resolver failure printed `Unable to resolve host "generativelanguage.googleapis.com": No address associated with hostname` and the next attempt printed the single word `timeout` — neither hinting at the actual cause (an active VPN or a Private DNS entry that can't resolve Google hosts). Now retries up to twice with backoff, but ONLY for `UnknownHostException` and `ConnectException` — failures that provably never left the device. Read timeouts are deliberately not retried: OkHttp reports connect and read timeouts as the same exception, and re-sending a request the model already ran would bill the user's key twice. Transport errors also map to copy that names the host and points at VPN / Private DNS, keeping the raw text in parentheses for bug reports. One transport, so all four providers benefit. Runtime-verified on emulator-5554 (API 35) at Pixel 10 Pro XL geometry (1344x2992 @ 480dpi): - density 560 / fontScale 1.0 -> no vertical clip, only graceful horizontal ellipsis; confirms density is not the trigger. - density 480 / fontScale 1.3 with the height temporarily re-pinned to 168.dp -> reproduces the reported clip exactly. - density 480 / fontScale 1.3 and 2.0 with the fix -> all three values visible. - Coach error reproduced end-to-end via airplane mode with a Gemini key: the bubble now reads "Couldn't look up generativelanguage.googleapis.com ... check ... whether a VPN or a Private DNS setting is blocking it." 6 new unit tests; full suite green.
…P to ADAPT iOS PR #80 (Apple Health sync) was triaged SKIP with the note that Health Connect is the Android analogue "if ever wanted". It is wanted: the app has no Health Connect code at all, so ring data can't reach any consumer outside PulseLoop. Re-triaged to ADAPT/XL and queued. No code — this is the document a later session starts from. It covers what iOS writes to HealthKit today (the parity target), why not shipping on Google Play does not block this, the target design, and a seven-phase plan with per-phase verification. Two things worth surfacing from the research: - **The Play Store is a non-issue.** The "Health apps declaration" is a publishing-review gate, not a runtime one; Health Connect permissions are ordinary `android.permission.health.*` runtime permissions. Gadgetbridge — checked out at the parent repo root — ships a full integration sideload-only with zero workarounds, and is therefore the reference implementation here rather than iOS. The real caveats are that Android 13 and below need the Health Connect APK from Play, and that a privacy-rationale Activity is mandatory regardless of distribution. - **Two Android-specific identity traps** that would silently duplicate records: `SleepStageBlockEntity.id` is regenerated on every re-sync (iOS's block ids are stable, so its scheme does not port), and `MeasurementEntity.id` is a random UUID for live rows but a stable `history:<key>:<ts>` for history rows. Scope settled with the maintainer: write-only, iOS parity first (phases 1-4), then the types Health Connect supports and HealthKit made awkward — blood pressure, glucose, respiratory rate, VO2 max, resting HR, nutrition (phase 5). Stress and fatigue have no Health Connect record type, same gap as HealthKit.
…; cancellable retry Review follow-ups on this branch's own network-error work. **A read failure during the body stream bypassed the new error copy.** `response.body?.string()` sat outside the try, so anything thrown after the response headers arrived — a mid-stream disconnect, or a read timeout firing while the body was still coming — escaped as a raw exception, never became `ResponsesError.Transport`, and fell through `CoachTurnError.from` to its generic `else` branch. For a timeout that reprinted the JDK's one-word `timeout`: exactly the bug this branch set out to fix, on one of the two paths that produce it. The call and the body read now share one try, inside `use` so the response is closed on every path including a mid-read failure. That required separating outcomes from failures: an HTTP status is an *answer* from the provider, not a transport error, so `ResponsesError` is caught and rethrown ahead of the catch-all rather than being retried or re-wrapped as Transport. **The timeout branch dropped its detail.** Every sibling branch appends the raw text, and the KDoc promises it. OkHttp reports connect and read timeouts as the same exception and only the message tells them apart (`failed to connect to … after 30000ms` vs `timeout`), so that was the one string a bug report needed. Restored, and the copy no longer asserts the read-timeout reading. **`Thread.sleep` -> `delay`.** `post` is now suspend; all four call sites are already directly inside `override suspend fun send`, so no caller changed. Previously a cancelled turn (user leaves the coach screen, WorkManager stops the summary worker) left an IO thread parked and then fired the remaining doomed attempts anyway. Also drops the now-dead `lastFailure` accumulator. **Documented what the taller tile does not fix.** The font-scale height only helps tiles whose content is a plain sp-measured column — Activity, Sleep, Chart. `GaugeTile`/`BpRingColumn` pin `VitalRingGauge` to a dp literal and derive centre font sizes from it inside a `Box(modifier.size(size))`, so their centre text still overflows its ring at a high font scale. Pre-existing and separate; noted in the KDoc so it isn't rediscovered. `baseHeight` made private. Adds mockwebserver (matching the okhttp version) and `ResponsesHttpTest`, which drives the transport over a real socket instead of hand-building the error wrappers — the reason the body-read gap was invisible to the existing tests. Verified as a real regression test: against the previous commit, `testFailureWhileStreamingTheBodyIsStillATransportError` fails. 875 tests green; assembleDebug green.
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.
Two field bugs from one report on a Pixel 10 Pro XL, plus the Health Connect design doc as a separate commit.
1. Activity tile clipped the calories row — font scale, not DPI
TodayTileMetrics.heightwas a fixed168.dpwhile everything inside a tile is sized insp. The Activity tile's three label/value pairs need3 × (12+18)sp + 6dp, and only ~108dp survives the 16dp padding, the eyebrow row and the 8dp spacer — so they stop fitting at about fontScale 1.15 andCAL 2,419is sliced off.It reads like a DPI bug and isn't. Density scales
dpandsptogether, so a 420dpi Pixel 8 and a 480dpi Pixel 10 Pro XL lay out identically at the same font-size setting — which is why the earlier issue-#24 fix verified clean on the Pixel 8.Shrinking the text a second time would only move the breaking point, so the container grows instead. Height is derived from the font scale, sampled at
16.sp(Android 14+ gives each sp size its own non-linear curve, sofontScalealone under-reports it) and clamped to 1.0–1.6. Every tile reads the same value, so the grid stays uniform.2. Coach turns died on a single DNS blip, with unreadable errors
ResponsesHttpmade exactly one attempt and surfaced the raw JDK string. The reported failure printedUnable to resolve host "generativelanguage.googleapis.com": No address associated with hostname, then the single wordtimeout— neither hinting at the actual cause (both screenshots show an active VPN; a VPN or Private DNS that can't resolve Google hosts does exactly this).UnknownHostExceptionandConnectException— failures that provably never left the device. Read timeouts are deliberately not retried: OkHttp reports connect and read timeouts as the same exception, and re-sending a request the model already ran would bill the user's key twice.One shared transport, so all four providers benefit.
3. Health Connect design doc (separate commit, docs-only)
docs/health-connect-integration.md— the document a later session starts from. Re-triages iOS #80 from SKIP to ADAPT/XL in the ledger. No code.Worth knowing: the Play Store is a non-issue. The "Health apps declaration" is a publishing-review gate, not a runtime one. Gadgetbridge — checked out at the parent repo root — ships a full integration sideload-only with zero workarounds, so it, not iOS, is the reference implementation. Real caveats are that Android 13 and below need the Health Connect APK from Play, and a privacy-rationale Activity is mandatory regardless of distribution.
Verification
Runtime-verified on
emulator-5554(API 35) at Pixel 10 Pro XL geometry (1344×2992 @ 480dpi):168.dp6 new unit tests;
testDebugUnitTestandassembleDebuggreen.4. Review follow-ups (
0cc2c59)Three defects found reviewing the above, all in this branch's own network work:
response.body?.string()sat outside thetry, so anything thrown after the response headers arrived — a mid-stream disconnect, or a read timeout firing while the body was still coming — escaped as a raw exception, never becameResponsesError.Transport, and fell throughCoachTurnError.fromto its genericelse. For a timeout that reprinted the JDK's one-wordtimeout: the exact bug §2 set out to fix, on one of the two paths that produce it. The call and the body read now share onetry, insideuseso the response closes on every path. That required catching and rethrowingResponsesErrorahead of the catch-all — an HTTP status is an answer, not a transport failure, and must not be retried or re-wrapped.SocketTimeoutExceptionbranch dropped its detail, though every sibling appends it and the KDoc promises it. OkHttp reports connect and read timeouts as the same exception and only the message tells them apart (failed to connect to … after 30000msvstimeout) — so that was precisely the string a bug report needed. Restored; the copy no longer asserts the read-timeout reading.Thread.sleep→delay.postis nowsuspend; all four call sites were already directly insideoverride suspend fun send, so no caller changed. A cancelled turn (leaving the coach screen, WorkManager stopping the summary worker) previously left an IO thread parked and then fired the remaining doomed attempts anyway.Adds
mockwebserverandResponsesHttpTest, which drives the transport over a real socket rather than hand-building the error wrappers — the reason the body-read gap was invisible to the existing tests. Confirmed to be a real regression test: against the previous commit,testFailureWhileStreamingTheBodyIsStillATransportErrorfails.875 tests, 0 failures;
assembleDebuggreen.Not changed — flagged for a decision
Found while auditing the provider URLs (all four hosts/paths are correct):
?key=in the URL (GeminiClient.kt:69). Google's current docs treat this as legacy and recommend thex-goog-api-keyheader — a query-string key can land in logs and proxies. Left alone because it can't be verified end-to-end without a live key and breaking a working coach isn't worth the silent risk.X-Title; current docs name itX-OpenRouter-Title. Attribution-only, no functional impact.keyStore.apiKey(OpenAI) regardless of the selected provider (SettingsSubScreens.kt:187), and the offline fallback copy hardcodes "add an OpenAI key". Cosmetic, but it makes a correctly-saved Gemini key look unsaved.Also noted in the design doc:
EventPersistenceSubscriberis constructed inside thePulseLoopAppcomposable whileRingSyncWorkerruns BLE backgrounded, so a WorkManager cold start may have nothing persisting what it fetches. Pre-existing and out of scope here.🤖 Generated with Claude Code