# Tasks: auto-media-art-quality Strict TDD active for Dart layers. Behavioral task = RED (failing test) -> GREEN (minimal impl) -> REFACTOR (cleanup, still green). Native/XML/PNG/build.gradle tasks have no `flutter test` coverage (no Android build env) — marked **[static-review-only]**; `flutter build`/`flutter run`/`flutter analyze` MUST NOT be executed in this environment (they hang). "Parallel" tasks have no file overlap with concurrently-listed siblings; "Sequential" tasks depend on a prior task's output. Affected files: `lib/servicios/navegacion_auto.dart`, `android/app/src/main/res/drawable/station_art_aurora.png`, `android/app/src/main/res/drawable/station_art_cosmic.png`, `android/app/src/main/res/drawable/station_art_pulse.png`, `android/app/src/main/res/drawable/station_art_nova.png`, `android/app/src/main/res/drawable/default_station_art.png` (deleted), `test/servicios/navegacion_auto_test.dart`. Baseline (verified against live code, not trusted from spec/design prose): - `lib/servicios/navegacion_auto.dart:12-17` — `_defaultArtUri` const, to be dropped. - `lib/servicios/navegacion_auto.dart:102-108` — `itemEmisora()`, sets `artUri` only, no `displaySubtitle`. - `lib/servicios/navegacion_auto.dart:110-113` — `_artUriPara(e)`: null/empty favicon check only, no URL-shape validation. - `lib/widgets/tarjeta_emisora.dart:361-370` — `_fallbackArtFor(seed)`: `[aurora, cosmic, pulse, nova]` order, `seed.codeUnits.fold(0,(a,b)=>a+b) % arts.length`, assets at `assets/images/station_art_.png`. - `lib/modelos/emisora.dart:60-78` — `Emisora.fromMap` (SQLite favorites row) assigns `favicon: map['favicon'] as String?` with **no sanitization** (unlike `Emisora.fromApi:47`, which runs `_nonEmpty`). Confirms design.md's claim: malformed/whitespace favicon values from favorites rows reach the tree unfiltered today. - `android/app/src/main/res/drawable/default_station_art.png` exists (only PNG currently in that directory) — to be deleted. - `assets/images/station_art_{aurora,cosmic,pulse,nova}.png` exist — source images to copy into `android/app/src/main/res/drawable/`. - `android/app/build.gradle.kts:34` — `applicationId = "es.freetimelab.pluriwave"`, matches the existing `android.resource://es.freetimelab.pluriwave/drawable/...` authority used by `_defaultArtUri` and the design's new `station_art_` URIs. No manifest/build.gradle change is needed — adding `res/drawable` PNGs requires no manifest declaration (unlike the parent change's `automotive_app_desc.xml` meta-data, which pointed at an XML resource). - `test/servicios/navegacion_auto_test.dart:45-63` — existing test asserts fallback to `_defaultArtUri` on null/empty favicon; must be replaced (not just extended) since `_defaultArtUri` is retired. - `audio_service-0.18.18` (`lib/audio_service.dart:622`): `MediaItem.displaySubtitle` is `String?` — nullable, safe to pass a nullable `subtituloCalidad(e)` result directly. --- ## Phase 1 — Favicon validity gate (`faviconUsable`) ### 1.1 [x] [RED] `faviconUsable` rejects malformed/non-http(s) favicons, accepts valid http(s) (Sequential — first task, defines the seam) - Satisfies: Design Decision "Case B detection" (static validity gate); Spec "Playable Item Metadata" / Scenario "Station's logo URL is present but unreachable (Case B)" (malformed subset). - File: `test/servicios/navegacion_auto_test.dart`. - New `group('faviconUsable', ...)`. Table-driven cases, each asserting `faviconUsable(input) == expected`: - `null` → `false` - `''` → `false` - `' '` (whitespace only) → `false` - `'ftp://cdn.example.com/logo.png'` (non-http(s) scheme) → `false` - `'cdn.example.com/logo.png'` (no scheme, bare host) → `false` - `'http://'` (scheme present, no authority) → `false` - `'/relative/path/logo.png'` (relative, no scheme) → `false` - `'not a url at all $$$ ///'` (unparseable/garbage) → `false` - `'http://cdn.example.com/logo.png'` → `true` - `'https://cdn.example.com/logo.png'` → `true` - Run: fails (`faviconUsable` not implemented / not exported). ### 1.2 [x] [GREEN] Implement `faviconUsable` (Sequential — depends on 1.1) - File: `lib/servicios/navegacion_auto.dart`. - Top-level (or static) `bool faviconUsable(String? favicon)`: trim; if null/empty after trim → `false`. `Uri.tryParse(trimmed)`; if parse fails → `false`. Require `scheme == 'http' || scheme == 'https'` AND `uri.hasAuthority` (non-empty host) → `true`, else `false`. - Run: 1.1 passes. --- ## Phase 2 — Rotation parity (`indiceArtePara`) ### 2.1 [x] [RED] `indiceArtePara` matches `tarjeta_emisora.dart`'s `_fallbackArtFor` formula and order (Sequential — depends on Phase 1 file existing, independent logic) - Satisfies: Design Decision "Fallback-art selection — port `_fallbackArtFor` verbatim"; Spec "Fallback art matches phone-UI per-station selection". - File: `test/servicios/navegacion_auto_test.dart`. - New `group('indiceArtePara', ...)`. Assert `indiceArtePara(seed)` for a handful of sample uuids reproduces `seed.codeUnits.fold(0,(a,b)=>a+b) % 4` computed inline in the test (do not hardcode indices without showing the formula), covering at least: an empty string (`''` → index 0, since `fold` over no elements is 0), a short seed, and a real-looking uuid. - Assert the canonical order constant (or equivalent) used for mapping index→name is exactly `['aurora', 'cosmic', 'pulse', 'nova']`, matching `tarjeta_emisora.dart:362-367`. - Run: fails. ### 2.2 [x] [GREEN] Implement `indiceArtePara` + canonical art-name list (Sequential — depends on 2.1) - File: `lib/servicios/navegacion_auto.dart`. - `int indiceArtePara(String seed) => seed.codeUnits.fold(0, (a, b) => a + b) % 4;` — verbatim port, same formula as `tarjeta_emisora.dart:368`. - Private `const _nombresArte = ['aurora', 'cosmic', 'pulse', 'nova']` — same order as `tarjeta_emisora.dart:363-366`. Add a code comment cross-linking to `tarjeta_emisora.dart`'s `_fallbackArtFor` to mitigate reorder drift (per design.md's stated mitigation, since there is no structural enforcement of order — see Phase 6 for the added test-level guard). - Run: 2.1 passes. --- ## Phase 3 — `artUriPara` integration (favicon gate + rotating drawable, retires `_defaultArtUri`) ### 3.1 [x] [RED] `artUriPara` returns the favicon when usable, a rotating `station_art_` drawable URI otherwise (Sequential — depends on 1.2, 2.2) - Satisfies: Design Decision 1 (validity gate) + Decision 2 (rotation) combined at the `artUriPara` seam; Spec "Playable Item Metadata" (all 5 scenarios). - File: `test/servicios/navegacion_auto_test.dart`. - New `group('artUriPara', ...)`: - Valid `https://` favicon → `artUriPara(e) == e.favicon`. - `null` favicon → `artUriPara(e) == 'android.resource://es.freetimelab.pluriwave/drawable/station_art_'` (compute expected name via `indiceArtePara(e.uuid)` + canonical order in the test, not a hardcoded guess). - `''` favicon → same rotating-drawable behavior as null. - Malformed favicon (e.g. `'ftp://x/y.png'`, `'not a url'`) → same rotating-drawable behavior (Case B malformed subset). - Two different uuids that hash to different indices → assert their resolved `artUriPara` values differ (parity smoke check, not full enumeration — that's Phase 6). - Same uuid called twice → identical result (determinism). - Run: fails. ### 3.2 [x] [GREEN] Implement `artUriPara`; drop `_defaultArtUri` and the old `_artUriPara` (Sequential — depends on 3.1) - File: `lib/servicios/navegacion_auto.dart`. - Replace `_artUriPara` (lines 110-113) with `String artUriPara(Emisora e) => faviconUsable(e.favicon) ? e.favicon! : 'android.resource://es.freetimelab.pluriwave/drawable/station_art_${_nombresArte[indiceArtePara(e.uuid)]}';`. - Delete `_defaultArtUri` const (lines 12-17) — dead now that rotation is total (design.md Decision 3: uuid always present, empty-uuid → index 0 → aurora, no 5th fallback reachable). - Update `itemEmisora()` (line 106) call site from `_artUriPara(e)` to `artUriPara(e)`. - Run: 3.1 passes. ### 3.3 [x] [GREEN] Replace the obsolete default-art test in `itemEmisora` group (Sequential — depends on 3.2) - File: `test/servicios/navegacion_auto_test.dart`. - Replace the test at lines 45-63 (`'cae al arte por defecto cuando el favicon es null o vacío'`, which asserts the now-deleted `_defaultArtUri` literal) with an assertion that both null- and empty-favicon cases resolve to the correct rotating `station_art_` URI per `indiceArtePara`, consistent with the Phase 3.1 cases. - Run: full `navegacion_auto_test.dart` green. --- ## Phase 4 — Quality subtitle (`subtituloCalidad`) ### 4.1 [x] [RED] `subtituloCalidad` matrix: both known, codec-only, bitrate-only, both unknown, bitrate<=0 (Sequential — independent of Phase 1-3, same file) - Satisfies: Design Decision "`displaySubtitle` quality format"; Spec "Station has known codec and bitrate" / "Station has unknown codec or bitrate". - File: `test/servicios/navegacion_auto_test.dart`. - New `group('subtituloCalidad', ...)`, one case per design.md's table plus the `bitrate <= 0` edge (Radio Browser stores 0 for unknown): - `codec: 'mp3', bitrate: 128` → `'128 kbps · MP3'` (codec upper-cased; `·` is U+00B7). - `codec: null, bitrate: 128` → `'128 kbps'`. - `codec: 'mp3', bitrate: null` → `'MP3'`. - `codec: 'mp3', bitrate: 0` → `'MP3'` (bitrate ≤ 0 treated as unknown). - `codec: null, bitrate: null` → `null` (not `''`, not the literal string `'null'`). - `codec: null, bitrate: 0` → `null`. - `codec: ' '` (whitespace-only codec), `bitrate: null` → `null` (whitespace codec counts as unknown per design.md's "empty/whitespace codec count as unknown"). - `codec: ' mp3 '` (whitespace-padded codec), `bitrate: 128` → `'128 kbps · MP3'` (trimmed). - Explicitly assert none of the outputs contain the substring `'null'` (defends the "never render literal null" requirement directly, not just via exact-match). - Run: fails. ### 4.2 [x] [GREEN] Implement `subtituloCalidad` (Sequential — depends on 4.1) - File: `lib/servicios/navegacion_auto.dart`. - `String? subtituloCalidad(Emisora e)`: normalize codec via `trim().toUpperCase()`, treat empty-after-trim as unknown; treat `bitrate == null || bitrate! <= 0` as unknown. - Both known → `'$bitrate kbps · $codecUpper'`. Bitrate only → `'$bitrate kbps'`. Codec only → `codecUpper`. Both unknown → `null` (never `''`, never a string containing `'null'`). - Run: 4.1 passes. --- ## Phase 5 — `itemEmisora` wiring (`displaySubtitle`) ### 5.1 [x] [RED] `itemEmisora` sets `displaySubtitle` from `subtituloCalidad`; omits it (leaves `null`) when both unknown (Sequential — depends on 3.2, 4.2) - Satisfies: Spec "Browsable Media Tree" / Scenario "Station has known codec and bitrate" + "Station has unknown codec or bitrate", applied at the leaf-building integration point. - File: `test/servicios/navegacion_auto_test.dart`. - Extend the existing `group('ConstructorArbolAuto.itemEmisora', ...)`: - `Emisora` with `codec: 'mp3', bitrate: 128` → `item.displaySubtitle == '128 kbps · MP3'`. - `Emisora` with `codec: null, bitrate: null` → `item.displaySubtitle == null`. - Run: fails (current `itemEmisora` at lines 102-108 never sets `displaySubtitle`). ### 5.2 [x] [GREEN] Wire `displaySubtitle` into `itemEmisora` (Sequential — depends on 5.1) - File: `lib/servicios/navegacion_auto.dart`. - Add `displaySubtitle: subtituloCalidad(e)` to the `MediaItem(...)` construction in `itemEmisora` (lines 102-108), alongside the existing `artUri: Uri.parse(artUriPara(e))`. - Run: 5.1 passes; full `itemEmisora` group green. ### 5.3 [x] [REFACTOR] Cleanup pass on `navegacion_auto.dart` (Sequential — depends on 3.2, 4.2, 5.2 all green) - Re-read top to bottom: doc-comment `faviconUsable`/`artUriPara`/`indiceArtePara`/`subtituloCalidad` per the Design "Interfaces / Contracts" signatures; confirm no leftover reference to `_defaultArtUri` or the old `_artUriPara` anywhere in the file (grep the file for both identifiers — should return zero matches). - Full `navegacion_auto_test.dart` suite stays green. --- ## Phase 6 — Downstream coverage & drift guards (favorites sanitization gap, phone/car order parity) ### 6.1 [x] [RED→GREEN, coverage-only] Test that `faviconUsable`/`artUriPara` catches the unsanitized `Emisora.fromMap` favicon gap (Sequential — depends on 3.2) - Satisfies: Design Decision 1's explicit callout that `Emisora.fromMap` (SQLite favorites, `lib/modelos/emisora.dart:60-78`) does not sanitize `favicon` (no `_nonEmpty`, unlike `Emisora.fromApi:47`) — confirms the validity gate is the correct place to catch this, rather than silently assuming it. - File: `test/servicios/navegacion_auto_test.dart`. - Build an `Emisora` via `Emisora.fromMap({...})` (not the `_emisora()` test helper) with a malformed/whitespace favicon value in the map (e.g. `'favicon': ' '`, or `'favicon': 'not-a-url'`) that `fromMap` passes through unsanitized. Assert `artUriPara(builtEmisora)` resolves to a rotating `station_art_` URI, not the malformed string. - This is a regression-style test, not a design-doc gap-fix: it documents the current architecture's division of responsibility (sanitize-at-read-boundary vs sanitize-at-render-boundary) and pins it in place. If it fails, `fromMap` started sanitizing (fine) or the gate regressed (not fine) — either way the test makes the boundary explicit instead of assumed. - Run: passes on first run once Phase 3 lands (this is a characterization test of already-implemented behavior, so RED/GREEN collapses to a single assertion pass — still write it before considering Phase 3 "done" in spirit, but there is no separate implementation step). ### 6.2 [x] [RED→GREEN, coverage-only] Parity guard: phone-UI asset order and Android-Auto drawable order stay identical (Sequential — depends on 2.2) - Satisfies: Design.md's explicitly flagged risk — "reorder drift" between `tarjeta_emisora.dart`'s `_fallbackArtFor` asset list and `navegacion_auto.dart`'s `_nombresArte` list, called out as having "no structural enforcement," mitigated only by a code comment + this test. - File: `test/servicios/navegacion_auto_test.dart`. - New `group('parity: phone/auto art order', ...)`. Since `_fallbackArtFor`'s asset list is private to `tarjeta_emisora.dart` and returns `assets/...` paths (not directly importable/comparable in form), this test hardcodes the canonical order `['aurora', 'cosmic', 'pulse', 'nova']` **once**, with a comment explaining it must be kept in sync with `tarjeta_emisora.dart:363-367`'s literal list, and asserts `navegacion_auto.dart`'s exposed art-name list/mapping matches it element-for-element, in order. - This does not eliminate drift risk (design.md accepts that — no structural enforcement chosen) but ensures any future reorder in `navegacion_auto.dart` alone breaks a test immediately; a reorder in `tarjeta_emisora.dart` alone still requires a human to update this test's hardcoded list (documented limitation, same as design.md's own acknowledgment). - Run: passes once Phase 2 lands. --- ## Phase 7 — Android native drawables (retiring `default_station_art.png`) [static-review-only — no Android build env, `flutter build`/`flutter run`/`flutter analyze` MUST NOT be executed] ### 7.1 [x] Add 4 native drawable copies (Parallel with 7.2 — different files within the same directory, no overlapping content) - Satisfies: Spec "Fallback art matches phone-UI per-station selection" + "Fallback art is on-brand, not the launcher icon"; Design "Native drawables + retire `default_station_art.png`". - Files: `android/app/src/main/res/drawable/station_art_aurora.png`, `station_art_cosmic.png`, `station_art_pulse.png`, `station_art_nova.png` (new binary assets). - Copy verbatim from `assets/images/station_art_{aurora,cosmic,pulse,nova}.png` — same technique the parent change used for `default_station_art.png` (Phase 6.3 there). - Static review: filenames match exactly (case-sensitive) the names produced by `_nombresArte`/`indiceArtePara` in `navegacion_auto.dart` (`station_art_`, no extension in the `android.resource://` URI), matching the pattern already verified working for `default_station_art`. ### 7.2 [x] Delete `default_station_art.png` (Parallel with 7.1 — different file) - Satisfies: Design Decision "Native drawables + retire `default_station_art.png`" — rotation is total (uuid always present, empty-uuid → index 0 → aurora), so this drawable is unreachable dead weight and a byte-for-byte launcher-icon lookalike (parent change's WARNING #2). - File: `android/app/src/main/res/drawable/default_station_art.png` (delete). - Static review: confirm no remaining reference to `default_station_art` anywhere in the codebase after Phase 3.2 removes the Dart-side const (grep `default_station_art` across `lib/` and `android/` — should return zero matches after this task, other than this tasks.md/spec/design history). ### 7.3 [x] Verify `applicationId`/authority match for the new drawable URIs (Parallel with 7.1/7.2 — read-only verification, no file changes expected) - Satisfies: precedent from parent change's WARNING notes (authority/applicationId must match for `android.resource://` URIs to resolve). - Already verified during task planning: `android/app/build.gradle.kts:34` sets `applicationId = "es.freetimelab.pluriwave"`, matching the authority segment (`es.freetimelab.pluriwave`) used in both the retired `_defaultArtUri` and the new `station_art_` URIs built in `artUriPara` (Phase 3.2). **No manifest or build.gradle change is required** — adding PNGs to `res/drawable` needs no manifest declaration, unlike the parent change's `automotive_app_desc.xml` meta-data entry (a `res/xml` resource that did require a `` reference). - Static review only: re-confirm this holds at apply time (`applicationId` unchanged) before closing Phase 7. --- ## Phase 8 — Full regression sweep (Sequential — depends on all prior phases green) ### 8.1 [x] Run full `flutter test` suite (or the closest safe equivalent in this environment) - Satisfies: Design "Testing Strategy" unit rows; Spec-level success criteria across all modified scenarios. - Run the entire suite to catch cross-file regressions, not just `navegacion_auto_test.dart`. - **Expected deviation (execution constraint, same as parent change)**: if a full-suite run is not safely executable in this environment, run targeted files instead — at minimum `navegacion_auto_test.dart` in full — and record which files were actually exercised. A genuine full-suite run is recommended before merge/CI, same caveat the parent change recorded. ### 8.2 [x] `flutter analyze` — DO NOT RUN in this environment - **DEVIATION (execution constraint, same as parent change)**: `flutter analyze` hangs in this environment and MUST NOT be executed. Perform manual static review instead: confirm `artUriPara`, `faviconUsable`, `indiceArtePara`, `subtituloCalidad` are used (no unused-symbol risk), confirm no leftover references to `_defaultArtUri`/old `_artUriPara`, confirm all new code compiles implicitly by virtue of the modified file's tests passing in 8.1. A real `flutter analyze` pass is still recommended before merge/CI. ### 8.3 [x] `flutter build`/`flutter run` — DO NOT RUN in this environment - **DEVIATION (execution constraint, same as parent change)**: these commands hang in this environment and MUST NOT be executed. Phase 7's native drawable work is static-review-only for this reason; a real Android build (or DHU session) is required before shipping to confirm the 4 new `station_art_*` drawables resolve correctly via `android.resource://` (Design's remaining Open Question). ### 8.4 [x] Manual DHU verification note (optional, non-blocking, not coded) - Satisfies: Design "Testing Strategy" manual row; Design "Open Questions" (confirm OS art loader accepts `android.resource://` for the 4 new drawables). - Record in the commit/PR description that a Desktop Head Unit (or real car) session is recommended before shipping to visually confirm: (a) all 4 rotating arts render correctly per station, (b) `displaySubtitle` quality text renders as expected on the browse row, (c) no stale `default_station_art` reference lingers anywhere in the built APK's resources. --- ## Review Workload Forecast - Files touched: `lib/servicios/navegacion_auto.dart` (~40-60 changed/added lines: drop `_defaultArtUri` (~6 lines removed), add `faviconUsable`, `indiceArtePara`, `_nombresArte`, `artUriPara`, `subtituloCalidad` (~35-45 lines added), one-line `displaySubtitle` wiring in `itemEmisora`), `test/servicios/navegacion_auto_test.dart` (~130-170 added/changed lines: 6 new test groups — `faviconUsable`, `indiceArtePara`, `artUriPara`, `subtituloCalidad`, `itemEmisora` extension, parity guard — plus replacing the obsolete default-art test), 4 new binary PNGs under `android/app/src/main/res/drawable/` (`station_art_aurora/cosmic/pulse/nova.png`, copied from existing `assets/images/`, zero text-line cost), 1 binary deletion (`default_station_art.png`). - Estimated total changed/added lines (text only, excluding binary PNG bytes): roughly 170-230 lines. - Chained PRs recommended: No — this is well inside the 400-line single-PR budget even with generous test-heavy estimates. No manifest/build.gradle changes are needed (verified: `applicationId` already matches, `res/drawable` PNGs need no manifest declaration), which removes the one file class that made the parent change's Phase 6 riskier. - 400-line budget risk: Low. Total estimate (~170-230 text lines + 4 binary adds + 1 binary delete) sits comfortably under budget as a single commit/PR. - Decision needed before apply: No — proceed as a single delivery. The only structural caveat carried over from the parent change is that Phase 7 (native drawables) remains static-review-only (no Android build env here), same constraint class as the parent change's Phase 6, but it does not change file-count or line-count risk enough to warrant chaining. - Ownership/dependency note: Phase 1 (validity gate) and Phase 2 (rotation) are independent of each other and can proceed in any order — both are prerequisites for Phase 3 (`artUriPara` integration), which retires `_defaultArtUri`. Phase 4 (subtitle) is fully independent of Phases 1-3 (different concern, same file) and could be done in parallel by a second contributor without conflict risk beyond normal same-file merge care. Phase 5 depends on both Phase 3 and Phase 4 landing. Phase 6 is coverage-only and depends on Phase 3 (6.1) and Phase 2 (6.2) respectively but not on each other. Phase 7 has zero Dart dependency and may be done anytime in parallel with all Dart phases (mirrors the parent change's Phase 6 independence). Phase 8 is a hard sequential gate depending on everything above.