Skip to content

fix: Today tiles clip at raised font scale; coach dies on a single DNS blip (+ Health Connect plan) - #47

Merged
foureight84 merged 4 commits into
mainfrom
fix/font-scale-tiles-and-coach-network
Aug 10, 2026
Merged

fix: Today tiles clip at raised font scale; coach dies on a single DNS blip (+ Health Connect plan)#47
foureight84 merged 4 commits into
mainfrom
fix/font-scale-tiles-and-coach-network

Conversation

@foureight84

@foureight84 foureight84 commented Aug 10, 2026

Copy link
Copy Markdown
Owner

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.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 × (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 and CAL 2,419 is sliced off.

It reads like a DPI bug and isn't. Density scales dp and sp together, 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, so fontScale alone under-reports it) and clamped to 1.0–1.6. Every tile reads the same value, so the grid stays uniform.

Correction (review follow-up). An earlier revision of this description claimed Sleep/Chart/Gauge all get the same protection. Gauge does not. GaugeTile and BpRingColumn pin VitalRingGauge to a dp literal (108.dp / 66.dp) and derive the centre font sizes from it (size.value * 0.30f) inside a Box(modifier.size(size)) — so at fontScale 2.0 the BP gauge's centre value still overflows its ring while the tile around it has spare room. Only the tiles whose content is a plain sp-measured Column — Activity, Sleep, Chart — actually benefit. That's pre-existing and separate from this fix (it needs the gauge size itself to become font-scale-aware), and it's now recorded in the TodayTileMetrics.height KDoc so it isn't rediscovered. The claim also appears in commit 9dd9542's message, which is left as-is rather than force-pushed.

2. Coach turns died on a single DNS blip, with unreadable errors

ResponsesHttp made exactly one attempt and surfaced the raw JDK string. The reported failure printed Unable to resolve host "generativelanguage.googleapis.com": No address associated with hostname, then the single word timeout — 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).

  • Retry, 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.
  • Actionable copy that names the host and points at VPN / Private DNS, keeping the raw text in parentheses for bug reports.

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):

Test Result
density 560, fontScale 1.0 No vertical clip — only graceful horizontal ellipsis. Confirms density is not the trigger.
density 480, fontScale 1.3, 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, airplane mode, real Gemini key Bubble reads "Couldn't look up generativelanguage.googleapis.com … check … whether a VPN or a Private DNS setting is blocking it."

6 new unit tests; testDebugUnitTest and assembleDebug green.

4. Review follow-ups (0cc2c59)

Three defects found reviewing the above, all in this branch's own network work:

  • A read failure during the body stream bypassed the new error copy entirely. 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. For a timeout that reprinted the JDK's one-word timeout: 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 one try, inside use so the response closes on every path. That required catching and rethrowing ResponsesError ahead of the catch-all — an HTTP status is an answer, not a transport failure, and must not be retried or re-wrapped.
  • The SocketTimeoutException branch 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 30000ms vs timeout) — so that was precisely the string a bug report needed. Restored; the copy no longer asserts the read-timeout reading.
  • Thread.sleepdelay. post is now suspend; all four call sites were already directly inside override 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 mockwebserver and ResponsesHttpTest, 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, testFailureWhileStreamingTheBodyIsStillATransportError fails.

875 tests, 0 failures; assembleDebug green.

Not changed — flagged for a decision

Found while auditing the provider URLs (all four hosts/paths are correct):

  1. Gemini passes the key as ?key= in the URL (GeminiClient.kt:69). Google's current docs treat this as legacy and recommend the x-goog-api-key header — 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.
  2. OpenRouter sends X-Title; current docs name it X-OpenRouter-Title. Attribution-only, no functional impact.
  3. AI Coach settings shows "API key needed" for a valid non-OpenAI key — the header checks 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: EventPersistenceSubscriber is constructed inside the PulseLoopApp composable while RingSyncWorker runs 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

…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.
@foureight84
foureight84 merged commit 61ccbf9 into main Aug 10, 2026
1 check passed
@foureight84
foureight84 deleted the fix/font-scale-tiles-and-coach-network branch August 10, 2026 20:42
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