# Design: Android Auto Media Art & Quality Polish ## Technical Approach All logic lands in the **pure** `ConstructorArbolAuto` layer of `navegacion_auto.dart` (folders, leaves, art, subtitle) — never in the handler/browse async path, which per parent WARNING #1 still has zero execution coverage. Three pure additions: a zero-network favicon-validity gate feeding `artUriPara`, a uuid-hash rotation over 4 native drawables ported verbatim from the phone's `_fallbackArtFor`, and a `displaySubtitle` formatter from `Emisora.codec`/`bitrate`. Reuse over rebuild; phone UI and audio pipeline untouched. Realises the extended `android-auto-media` browse-item requirements. ## Architecture Decisions ### Decision: Case B (present-but-unreachable favicon) detection — LOAD-BEARING **Choice**: Static favicon-URL **validity gate** (no network) + document the genuine live-then-404/host-down case as an accepted known limitation this iteration. `artUriPara` uses the favicon only when it parses as an **absolute http/https URI with an authority**; otherwise it falls open to the rotating on-brand art. **Alternatives considered**: | Option | Coverage | Cost | Testable (Strict TDD, no net, untested handler) | Correctness risk | |--------|----------|------|--------------------------------------------------|------------------| | (a) eager HTTP HEAD + cache | live-404 too, racy | net latency on every cold tree build; offline + cache-invalidation story | needs HTTP+cache+offline seams injected into the exact zero-coverage path | TOCTOU vs OS's own later fetch; transient offline permanently swaps *good* art | | (b) piggyback phone `errorWidget` signal | partial — car-first stations blind | new persisted cross-isolate shared state | new persistence subsystem to test | staleness; couples car to phone history | | (c) accept whole gap | none of Case B | zero | trivial | ships no A improvement | | (d) lazy per-station HEAD + TTL cache | live-404, deferred | net on first browse-in; TTL staleness | same seam burden as (a) | same TOCTOU/false-negative | | **CHOSEN: static validity gate + documented gap** | malformed/non-http subset, deterministic | **zero network** | pure sync function, no seams | none — only swaps art for a URL that could never render as a remote image | **Rationale**: The OS art loader fetches `artUri` **independently and later, outside the Flutter engine** — a build-time probe *predicts* but cannot *bind* that fetch (TOCTOU): "reachable now" can 404 at render, "unreachable now" is often transient offline. So (a)/(d) trade guaranteed cost for a racy guess and can degrade a correct case. Under Strict TDD with the handler path already untested (WARNING #1), adding HTTP+cache+offline seams there is high-risk/low-reward. The **malformed/non-http(s)** subset (bare hosts, wrong scheme, `http://` with no authority, whitespace, unparseable) is a *real* slice of Radio Browser broken-art — and `Emisora.fromMap` (SQLite favorites) does **not** sanitize favicon, so these reach the tree today. We catch them deterministically at zero cost and unit-test them fully. The residual live-404 case stays a documented low-severity limitation — the same gap every mainstream Android Auto music app has. Proposal's Split Judgment explicitly permits this narrowing of A. ### Decision: Fallback-art selection — port `_fallbackArtFor` verbatim **Choice**: Replicate the exact expression `seed.codeUnits.fold(0,(a,b)=>a+b) % 4` (seed = `uuid`) and the **same ordered list** `[aurora, cosmic, pulse, nova]`, mapping the index to `android.resource://…/drawable/station_art_`. **Alternatives**: share one helper across widget+service (rejected — widget returns `assets/…` paths, service needs `android.resource://` URIs; different targets). **Rationale**: Identical formula + identical order = provable phone/car parity. A parity unit test enumerates the canonical order over sample uuids; a code comment cross-links both lists to mitigate reorder drift. ### Decision: Native drawables + retire `default_station_art.png` **Choice**: Add `station_art_{aurora,cosmic,pulse,nova}.png` to `res/drawable` (same `android.resource://` technique as parent). **Retire** `default_station_art.png` and the `_defaultArtUri` const. **Rationale**: `uuid` is always present (empty-uuid → index 0 → aurora), so rotation is **total** — no 5th fallback is reachable. Keeping the launcher-icon-lookalike is exactly the bug WARNING #2 flagged; deleting it removes the smell and dead asset. If a last resort were ever needed it would be one of the 4 arts, never a launcher copy. ### Decision: `displaySubtitle` quality format **Choice**: `MediaItem.displaySubtitle` (the audio_service 0.18.18 field for the browsable row's secondary text — distinct from `artist`/`album`, which the play path already uses semantically). Format `" kbps · "` (codec upper-cased/trimmed, `·` U+00B7). | codec | bitrate | subtitle | |-------|---------|----------| | MP3 | 128 | `128 kbps · MP3` | | null | 128 | `128 kbps` | | MP3 | null / ≤0 | `MP3` | | null | null / ≤0 | *omitted (leave null)* | **Rationale**: `bitrate ≤ 0` (Radio Browser stores 0) and empty/whitespace codec count as unknown; when both unknown, **omit** the field entirely — never set `""` (some renderers show a blank second line) and never render `"null"`. Applied to the browse leaf `itemEmisora`; now-playing item unchanged (out of scope). ## Data Flow Emisora ──▶ ConstructorArbolAuto.itemEmisora ├─ artUriPara(e): faviconUsable? favicon : station_art_ └─ subtituloCalidad(e): codec/bitrate → "kbps · CODEC" | null │ ▼ MediaItem(artUri, displaySubtitle) ──▶ OS media browser row ## File Changes | File | Action | Description | |------|--------|-------------| | `lib/servicios/navegacion_auto.dart` | Modify | `artUriPara` validity gate + rotation; `subtituloCalidad`; `itemEmisora` sets `displaySubtitle`; drop `_defaultArtUri` | | `android/app/src/main/res/drawable/station_art_aurora.png` … `_nova.png` | Create | 4 native copies of `assets/images/station_art_*.png` | | `android/app/src/main/res/drawable/default_station_art.png` | Delete | Launcher-icon copy retired (WARNING #2); rotation is total | | `test/servicios/navegacion_auto_test.dart` | Modify | Add validity-gate, rotation-parity, subtitle-matrix, leaf-integration tests; update the old default-art test | ## Interfaces / Contracts ```dart // All pure, no platform — unit-testable without net or Android build. String artUriPara(Emisora e); // valid http(s) favicon | rotating drawable URI String? subtituloCalidad(Emisora e); // formatted quality | null when both unknown int indiceArtePara(String seed); // fold-mod index, parity-critical (test seam) bool faviconUsable(String? favicon); // absolute http/https + authority ``` Drawable URI: `android.resource://es.freetimelab.pluriwave/drawable/station_art_`. ## Testing Strategy | Layer | What | Approach | |-------|------|----------| | Unit | Validity gate: http/https+authority passes; null/empty/whitespace/no-scheme/`http://`/relative/non-http → fallback | pure `faviconUsable`/`artUriPara` | | Unit | Rotation parity: index over sample uuids maps to correct drawable in canonical order | pure | | Unit | Subtitle matrix (5 rows incl. bitrate ≤0 and both-null→null) | pure `subtituloCalidad` | | Unit | `itemEmisora`: rotating `artUri` on invalid favicon; `displaySubtitle` set/omitted | builder assertions | | Static | 4 drawables present, authority matches applicationId; `default_station_art` removed | file check (no Android build) | | Manual DHU | Real art + subtitle render in car | user-side | Stays entirely in the pure layer — does **not** add I/O to the untested handler path (does not widen WARNING #1). ## Migration / Rollout Additive/reversible. Revert: restore `default_station_art.png`, `_defaultArtUri` and the old `_artUriPara`; delete the 4 drawables; drop `displaySubtitle` and the new tests. Phone UI and audio pipeline untouched — zero residual state. ## Open Questions - [ ] Confirm the OS art loader accepts `android.resource://` for the 4 new drawables (carries over from parent's open Q; low risk — identical mechanism already static-verified for `default_station_art`). Verify in a DHU session. - [x] Live-then-404/host-down favicon detection — resolved: deferred as a documented known limitation this iteration (see Decision 1).