Promotes the android-auto-media capability spec to openspec/specs/ and moves both completed changes into openspec/changes/archive/.
8.4 KiB
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
// 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 fordefault_station_art). Verify in a DHU session. - Live-then-404/host-down favicon detection — resolved: deferred as a documented known limitation this iteration (see Decision 1).