8.0 KiB
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:<uuid>.
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 | <automotiveApp><uses name="media"/></automotiveApp> |
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
abstract class FuenteEmisorasAuto {
Future<List<Emisora>> favoritos();
Future<List<Emisora>> misEmisoras();
Future<List<Emisora>> todas(); // populares snapshot; may be empty (cold)
Future<Emisora?> porUuid(String uuid);
}
class ConstructorArbolAuto { // pure, no platform
List<MediaItem> raiz(); // 3 folder MediaItems (playable:false)
List<MediaItem> hijos(String parentId, {required List<Emisora> emisoras});
MediaItem itemEmisora(Emisora e); // id 'emisora:<uuid>', title, artUri fallback
Emisora? resolver(String id, List<Emisora> 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:<uuid>, 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). Todason a cold bind shows only if a snapshot exists — accept empty folder, or trigger a lightweight local populares cache? (defer)