# Design: Android Auto (Projected) Media Browsing ## Technical Approach Reuse the existing `PluriWaveAudioHandler` (main isolate, `audio_service 0.18`) and add only the browse layer Android Auto needs: three overrides (`getChildren`, `getMediaItem`, `playFromMediaId`) fed by a dedicated, cold-start-safe data source, plus the manifest declaration. Playback still flows through the untouched internal `playMediaItem` (servicio_audio.dart:433). A pure tree builder makes the logic unit-testable without a running car or platform. Realises capability `android-auto-media`; phone playback path is unchanged. ## Architecture Decisions ### Decision: getChildren data source (cold-start safe) **Choice**: Introduce `FuenteEmisorasAuto` — a small browse-source abstraction registered into the handler via `registrarFuenteNavegacion(...)` (mirrors `registrarHandler`). Production impl reads **local** data directly: favourites from `ServicioFavoritos` (SQLite) and custom stations from the JSON file — both loadable without the network or the widget tree. `EstadoRadio`, when alive, pushes its in-memory lists as a **live snapshot** the source prefers; on a cold Auto bind it falls back to a direct local read. **Alternatives considered**: Inject `EstadoRadio` directly into the handler; a callback registered by `EstadoRadio` on init. **Rationale**: `ChangeNotifierProvider.create:` is **lazy** (app.dart:41) — a headless Auto bind runs `main()` but may never build `EstadoRadio`, and its `_init()` loads network `populares`. Depending on it would give the car an empty or blocked tree. Favourites+custom are local and reliable; the snapshot keeps car and phone identical when both are live. Provider architecture stays intact. ### Decision: media-id scheme **Choice**: Folders use bare stable constants (`root` = `AudioService.browsableRootId`, `favoritos`, `todas`, `mis_emisoras`); stations use `emisora:`. **Alternatives considered**: Reuse the stream URL as id (as the phone MediaItem does); numeric SQLite `id`. **Rationale**: The `emisora:` prefix is collision-free against folder ids and against the raw-URL ids the app uses internally; `uuid` is the stable cross-source key (`Emisora.==` is uuid-based). `playFromMediaId` parses the uuid, looks it up in the source, and builds the real `MediaItem` (id = `emisora.url`, `extras['uuid']`) exactly like the phone. ### Decision: default artwork delivery **Choice**: Stations with a favicon use it (http(s), already loadable). Logo-less stations get `android.resource://es.freetimelab.pluriwave/drawable/default_station_art` — a bundled `res/drawable` PNG. **Alternatives considered**: content:// via the configured FileProvider (copy asset → build URI); remote placeholder URL; folder-art-only. **Rationale**: `android.resource://` is loaded by `ContentResolver` with **no per-URI grant**, works offline, and cannot 404 — unlike FileProvider content URIs (need `FLAG_GRANT_READ_URI_PERMISSION` for the system art loader) or a remote URL (offline/quality-gate risk). Flutter `assets/` are **not** reachable via `android.resource`, so the PNG lives in `res/drawable`. FileProvider (files-path root → segment `files`) remains the documented fallback if a loader rejects `android.resource`. ### Decision: which stations surface & ordering **Choice**: `Favoritos` (SQLite), `Mis emisoras` (custom file), `Todas` = top `populares` snapshot when available. Each folder sorted by `ordenarEmisoras(_, ordenListas)` and **capped at 50**. **Rationale**: User-curated/local lists are reliable in a car (Google tests playback); a capped list avoids driver-distraction and Auto list limits. `Todas` degrades gracefully to empty-but-valid on cold bind. ### Decision: playback coherence with EstadoRadio **Choice**: `playFromMediaId` delegates to internal `playMediaItem`. `EstadoRadio` already subscribes to `audio.estadoStream` and `emisoraActual => _emisoraSeleccionada ?? audio.emisoraActual`; extend that listener to reconcile `_emisoraSeleccionada = audio.emisoraActual` on a car-initiated change so it does not **shadow** the car's station. **Rationale**: Reuse over duplication; the one-line reconcile keeps the mini-player/current-station display correct when playback starts from the car. ### Decision: content style (optional) **Choice**: Set `CONTENT_STYLE_*` extras — grid (2) for playable stations, list (1) for root folders. Non-blocking polish. ## Data Flow Car (MediaBrowser) ──getChildren──▶ Handler ──▶ FuenteEmisorasAuto │ ├─ live snapshot (EstadoRadio, if alive) │ └─ local read (SQLite favs + custom file) ← cold bind Car (tap) ──playFromMediaId(emisora:uuid)──▶ Handler ──lookup──▶ Emisora ──▶ playMediaItem (unchanged) │ EstadoRadio ◀── audio.estadoStream ── PlaybackState ───┘ (reconciles _emisoraSeleccionada) ## File Changes | File | Action | Description | |------|--------|-------------| | `android/app/src/main/res/xml/automotive_app_desc.xml` | Create | `` | | `android/app/src/main/AndroidManifest.xml` | Modify | Add `com.google.android.gms.car.application` meta-data | | `android/app/src/main/res/drawable/default_station_art.png` | Create | Bundled default station artwork | | `lib/servicios/navegacion_auto.dart` | Create | `FuenteEmisorasAuto` + local impl, id constants, pure `ConstructorArbolAuto` (tree/leaf builder, art fallback) | | `lib/servicios/servicio_audio.dart` | Modify | Override `getChildren`/`getMediaItem`/`playFromMediaId`; `registrarFuenteNavegacion`; delegate to `playMediaItem` | | `lib/estado/estado_radio.dart` | Modify | Push live snapshot to source; reconcile `_emisoraSeleccionada` on car-initiated playback | | `lib/main.dart` | Modify | Build + register the local browse source | | `test/servicios/navegacion_auto_test.dart` | Create | Tree, id resolution, art fallback, routing tests | ## Interfaces / Contracts ```dart abstract class FuenteEmisorasAuto { Future> favoritos(); Future> misEmisoras(); Future> todas(); // populares snapshot; may be empty (cold) Future porUuid(String uuid); } class ConstructorArbolAuto { // pure, no platform List raiz(); // 3 folder MediaItems (playable:false) List hijos(String parentId, {required List emisoras}); MediaItem itemEmisora(Emisora e); // id 'emisora:', title, artUri fallback Emisora? resolver(String id, List universo); static const idFavoritos = 'favoritos', idTodas = 'todas', idMisEmisoras = 'mis_emisoras'; } ``` ## Testing Strategy | Layer | What to Test | Approach | |-------|-------------|----------| | Unit | Root returns 3 folders (ids/titles/`playable:false`) | Fake `FuenteEmisorasAuto`; assert `getChildren(root)` | | Unit | Leaf ids `emisora:`, title+artUri always set; favicon vs default art fallback | `ConstructorArbolAuto.itemEmisora` | | Unit | `getMediaItem`/`resolver` maps id→Emisora; unknown→null | Pure builder assertions | | Unit | `playFromMediaId` builds MediaItem (id=url, extras uuid) and delegates to `playMediaItem` | Spy/seam over `playMediaItem` (existing test pattern) | | Manual (DHU) | Discovery, real art render, playback, play/pause car↔phone sync, grid/list | User-side, not `flutter test` | ## Migration / Rollout No migration. Additive: revert deletes the XML, the meta-data line, the drawable, the new file, and the three overrides — phone path untouched, zero residual state. ## Open Questions - [ ] Confirm the system art loader accepts `android.resource://`; else switch logo-less default to FileProvider content URI (`content://…/files/auto/default_station_art.png`). - [ ] `Todas` on a cold bind shows only if a snapshot exists — accept empty folder, or trigger a lightweight local populares cache? (defer)