Files
pluriwave/openspec/changes/auto-media-art-quality/design.md
T
FreeTLab c193650cc4 fix(auto): fall back to brand art and surface quality on dead/missing favicons
Android Auto no longer copies the launcher icon as placeholder art; it
rotates through the same 4 on-brand station_art assets the phone UI
already uses, keyed by the same per-station hash for visual parity.
Malformed or unusable favicon URLs (including a Dart Uri quirk where
'http://' reports hasAuthority=true with an empty host) now fail the
validity gate instead of being handed to the OS media browser as-is.
Browsable items also show codec/bitrate as a subtitle when known.
2026-07-19 13:06:18 +02:00

97 lines
8.4 KiB
Markdown

# 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<int>(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_<name>`. **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 `"<bitrate> kbps · <CODEC>"` (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_<idx(uuid)>
└─ 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_<name>`.
## 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).