Files
pluriwave/openspec/changes/archive/2026-07-19-auto-media-art-quality/design.md
T
FreeTLab f9003436ea docs(openspec): archive android-auto-media and auto-media-art-quality
Promotes the android-auto-media capability spec to openspec/specs/
and moves both completed changes into openspec/changes/archive/.
2026-07-19 13:22:18 +02:00

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 for default_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).