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

100 lines
8.0 KiB
Markdown

# 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
```dart
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)