Files
pluriwave/openspec/changes/android-auto-media/design.md
T
Javier Bautista Fernández 07c6e32af0
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m22s
docs(auto): android auto research guide and sdd artifacts for android-auto-media
2026-07-16 16:28:54 +02:00

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).
  • Todas on a cold bind shows only if a snapshot exists — accept empty folder, or trigger a lightweight local populares cache? (defer)