diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 00285e1..0579d5d 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -107,6 +107,11 @@
+
+
+
diff --git a/android/app/src/main/res/drawable/default_station_art.png b/android/app/src/main/res/drawable/default_station_art.png
new file mode 100644
index 0000000..56ef9ef
Binary files /dev/null and b/android/app/src/main/res/drawable/default_station_art.png differ
diff --git a/android/app/src/main/res/xml/automotive_app_desc.xml b/android/app/src/main/res/xml/automotive_app_desc.xml
new file mode 100644
index 0000000..e2db349
--- /dev/null
+++ b/android/app/src/main/res/xml/automotive_app_desc.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/lib/app.dart b/lib/app.dart
index 28671b8..29099ff 100644
--- a/lib/app.dart
+++ b/lib/app.dart
@@ -24,16 +24,23 @@ import 'widgets/pluri_layout.dart';
import 'widgets/pluri_onboarding_dialog.dart';
import 'widgets/pluri_wave_scaffold.dart';
import 'package:pluriwave/widgets/mini_reproductor.dart';
+import 'servicios/navegacion_auto.dart';
import 'servicios/servicio_alarmas_android.dart';
import 'servicios/servicio_dispositivo_audio.dart';
class PluriWaveApp extends StatelessWidget {
- const PluriWaveApp({super.key, this.prefs});
+ const PluriWaveApp({super.key, this.prefs, this.fuenteAuto});
/// Single SharedPreferences instance resolved in main() (S3-R4) and
/// injected into every state/service.
final SharedPreferences? prefs;
+ /// Android Auto browse source (Design "Data Flow" — cold-bind local read
+ /// available before EstadoRadio builds). Optional: defaults to `null`,
+ /// same as every other existing caller/test that constructs
+ /// [PluriWaveApp] without it.
+ final FuenteEmisorasAuto? fuenteAuto;
+
@override
Widget build(BuildContext context) {
return MultiProvider(
@@ -43,6 +50,7 @@ class PluriWaveApp extends StatelessWidget {
(_) => EstadoRadio(
prefs: prefs,
dispositivoAudio: ServicioDispositivoAudioReal(),
+ fuenteAuto: fuenteAuto,
),
),
// Domain notifiers (S4-R1/R2/R3). Created and disposed by EstadoRadio
diff --git a/lib/estado/estado_radio.dart b/lib/estado/estado_radio.dart
index d052a22..24df246 100644
--- a/lib/estado/estado_radio.dart
+++ b/lib/estado/estado_radio.dart
@@ -16,6 +16,7 @@ import 'estado_busqueda.dart';
import 'estado_ecualizador.dart';
import 'estado_grabacion.dart';
import 'orden_emisoras.dart';
+import '../servicios/navegacion_auto.dart';
import '../servicios/persistencia_tolerante.dart';
import '../servicios/servicio_audio.dart';
import '../servicios/servicio_dispositivo_audio.dart';
@@ -44,6 +45,7 @@ class EstadoRadio extends ChangeNotifier {
ServicioGrabacionRadio? servicioGrabacion,
SharedPreferences? prefs,
Future Function()? resolverArchivoCustom,
+ FuenteEmisorasAuto? fuenteAuto,
bool iniciarAutomaticamente = true,
}) : audio = audio ?? ServicioAudio(),
favoritos = favoritos ?? ServicioFavoritos(),
@@ -52,7 +54,8 @@ class EstadoRadio extends ChangeNotifier {
servicioEcualizador ?? ServicioEcualizador(prefs: prefs),
_dispositivoAudio = dispositivoAudio,
_prefs = prefs,
- _resolverArchivoCustom = resolverArchivoCustom {
+ _resolverArchivoCustom = resolverArchivoCustom,
+ _fuenteAuto = fuenteAuto {
ecualizador = EstadoEcualizador(
audio: this.audio,
servicio: this.servicioEcualizador,
@@ -96,6 +99,13 @@ class EstadoRadio extends ChangeNotifier {
final SharedPreferences? _prefs;
final Future Function()? _resolverArchivoCustom;
+ /// Android Auto browse source (Design "live snapshot the source
+ /// prefers"). Optional and unused by default — wired from main.dart via
+ /// the [FuenteEmisorasAutoLocal] instance registered into the handler.
+ /// When set, this instance's in-memory lists are pushed on every mutation
+ /// so the car sees the same data as the phone without a duplicate read.
+ final FuenteEmisorasAuto? _fuenteAuto;
+
/// Single startup instance injected from main() (S3-R4); falls back to
/// getInstance() only when nothing was injected (tests, legacy callers).
Future _resolverPrefs() async =>
@@ -292,6 +302,14 @@ class EstadoRadio extends ChangeNotifier {
grabacion.activa) {
unawaited(grabacion.detener());
}
+ // Design "playback coherence with EstadoRadio": a car-initiated
+ // selection (Android Auto's playFromMediaId) changes audio.emisoraActual
+ // directly, bypassing reproducir(). Without this, _emisoraSeleccionada
+ // would keep shadowing the car's station on emisoraActual's getter.
+ final actual = audio.emisoraActual;
+ if (actual != null && actual.uuid != _emisoraSeleccionada?.uuid) {
+ _emisoraSeleccionada = actual;
+ }
notifyListeners();
});
}
@@ -311,6 +329,9 @@ class EstadoRadio extends ChangeNotifier {
_errorCarga = _textos.radioApiConnectionError;
} finally {
_cargandoPopulares = false;
+ // Design "live snapshot the source prefers": Android Auto's `Todas`
+ // folder mirrors the same populares list the phone just loaded.
+ _fuenteAuto?.actualizarSnapshot(todas: _populares);
notifyListeners();
}
}
@@ -318,6 +339,7 @@ class EstadoRadio extends ChangeNotifier {
Future cargarFavoritos() async {
_listaFavoritos = await favoritos.obtenerTodos();
await _normalizarEmisoraPreferida();
+ _fuenteAuto?.actualizarSnapshot(favoritos: _listaFavoritos);
notifyListeners();
}
@@ -542,6 +564,7 @@ class EstadoRadio extends ChangeNotifier {
detalle: 'resolucion de ruta',
razon: e.toString(),
);
+ _fuenteAuto?.actualizarSnapshot(misEmisoras: _emisorasCustom);
notifyListeners();
return;
}
@@ -567,6 +590,7 @@ class EstadoRadio extends ChangeNotifier {
razon: e.toString(),
);
}
+ _fuenteAuto?.actualizarSnapshot(misEmisoras: _emisorasCustom);
notifyListeners();
}
@@ -582,6 +606,7 @@ class EstadoRadio extends ChangeNotifier {
if (!await archivo.exists()) {
_emisorasCustom = [];
_customDegradado = false;
+ _fuenteAuto?.actualizarSnapshot(misEmisoras: _emisorasCustom);
notifyListeners();
return null;
}
@@ -594,6 +619,7 @@ class EstadoRadio extends ChangeNotifier {
detalle: archivo.path,
razon: e.toString(),
);
+ _fuenteAuto?.actualizarSnapshot(misEmisoras: _emisorasCustom);
notifyListeners();
return null;
}
diff --git a/lib/main.dart b/lib/main.dart
index b9c67cc..5d189cc 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'app.dart';
+import 'servicios/navegacion_auto.dart';
import 'servicios/servicio_audio.dart';
import 'servicios/servicio_audio_session.dart';
import 'tema/pluriwave_tokens.dart';
@@ -41,12 +42,23 @@ Future main() async {
);
registrarHandler(handler);
+ // Android Auto browse source (Design "getChildren data source, cold-start
+ // safe") — registered before EstadoRadio builds so a headless Auto bind
+ // (main() runs but the lazily-created Provider tree may never build) can
+ // still serve favourites/custom stations from local reads.
+ final fuenteAuto = FuenteEmisorasAutoLocal();
+ registrarFuenteNavegacion(fuenteAuto);
+
// S3-R1: audio focus — phone calls / transient losses pause or duck the
// radio; headphones unplugged pauses it.
final sesionAudio = ServicioAudioSession(objetivo: handler);
unawaited(sesionAudio.configurar());
- runApp(_OrientacionResponsiveApp(child: PluriWaveApp(prefs: prefs)));
+ runApp(
+ _OrientacionResponsiveApp(
+ child: PluriWaveApp(prefs: prefs, fuenteAuto: fuenteAuto),
+ ),
+ );
}
Future _aplicarPoliticaOrientacion([ui.Display? display]) async {
diff --git a/lib/servicios/navegacion_auto.dart b/lib/servicios/navegacion_auto.dart
new file mode 100644
index 0000000..8213d8d
--- /dev/null
+++ b/lib/servicios/navegacion_auto.dart
@@ -0,0 +1,265 @@
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:audio_service/audio_service.dart';
+import 'package:path_provider/path_provider.dart';
+
+import '../estado/orden_emisoras.dart';
+import '../modelos/emisora.dart';
+import 'persistencia_tolerante.dart';
+import 'servicio_favoritos.dart';
+
+/// URI of the bundled default station artwork, served from
+/// `android/app/src/main/res/drawable` via `android.resource://` (Design
+/// "default artwork delivery" — no per-URI grant needed, works offline, and
+/// cannot 404 unlike a FileProvider content URI or a remote placeholder).
+const String _defaultArtUri =
+ 'android.resource://es.freetimelab.pluriwave/drawable/default_station_art';
+
+const _prefijoEmisora = 'emisora:';
+
+/// Browse-source abstraction for the Android Auto media tree (Design
+/// "getChildren data source, cold-start safe"). Kept separate from
+/// `EstadoRadio` so a headless Auto bind (`main()` runs but the widget tree
+/// — and therefore the lazily-created `EstadoRadio` — never builds) still
+/// gets a valid, non-throwing tree.
+abstract class FuenteEmisorasAuto {
+ Future> favoritos();
+ Future> misEmisoras();
+
+ /// `populares` snapshot; may be empty on a cold bind (Design "which
+ /// stations surface & ordering").
+ Future> todas();
+ Future porUuid(String uuid);
+
+ /// Live-snapshot push (Design "live snapshot the source prefers"):
+ /// `EstadoRadio`, when alive, calls this unconditionally on every
+ /// favorites/custom/populares mutation so a car and phone that are both
+ /// live see identical lists. Default no-op — only implementations that
+ /// actually buffer a snapshot (e.g. [FuenteEmisorasAutoLocal]) need to
+ /// override it; a null field on `EstadoRadio` skips the call entirely via
+ /// `?.`.
+ void actualizarSnapshot({
+ List? favoritos,
+ List? misEmisoras,
+ List? todas,
+ }) {}
+}
+
+/// Pure builder for the Android Auto browse tree: folders, leaf items, id
+/// resolution. No platform dependency — fully testable without a running
+/// car or a real `AudioHandler`.
+class ConstructorArbolAuto {
+ /// Root folder ids (Design "media-id scheme"). The tree root itself is
+ /// identified by [AudioService.browsableRootId], not by a constant here —
+ /// the handler compares against it directly before calling [raiz].
+ static const idFavoritos = 'favoritos';
+ static const idTodas = 'todas';
+ static const idMisEmisoras = 'mis_emisoras';
+
+ static const _idsCarpetas = {idFavoritos, idTodas, idMisEmisoras};
+ static const _maxItemsPorCarpeta = 50;
+
+ /// Content-style extras (Design "content style", optional polish): list
+ /// (1) for the root's folders, grid (2) for playable station items.
+ static const _contentStyleLista = {
+ 'android.media.browse.CONTENT_STYLE_BROWSABLE_HINT': 1,
+ 'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 1,
+ };
+ static const _contentStyleGrid = {
+ 'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 2,
+ };
+
+ /// The 3 root folders (Favoritos, Todas las emisoras, Mis emisoras), all
+ /// non-playable.
+ List raiz() => [
+ _carpeta(idFavoritos, 'Favoritos'),
+ _carpeta(idTodas, 'Todas las emisoras'),
+ _carpeta(idMisEmisoras, 'Mis emisoras'),
+ ];
+
+ MediaItem _carpeta(String id, String titulo) => MediaItem(
+ id: id,
+ title: titulo,
+ playable: false,
+ extras: _contentStyleLista,
+ );
+
+ /// Leaf items for [parentId], sorted via [ordenarEmisoras] and capped at
+ /// [_maxItemsPorCarpeta] (Design "which stations surface & ordering" —
+ /// avoids driver distraction and Auto list limits). Unknown [parentId]
+ /// (or an empty [emisoras]) returns an empty list instead of throwing.
+ List hijos(String parentId, {required List emisoras}) {
+ if (!_idsCarpetas.contains(parentId)) return const [];
+ if (emisoras.isEmpty) return const [];
+ final ordenadas = ordenarEmisoras(emisoras, OrdenEmisoras.calidad);
+ return ordenadas.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
+ }
+
+ /// Maps a single [Emisora] to a playable `MediaItem`: id `emisora:`
+ /// (Design "media-id scheme"), title, and artUri with the default-art
+ /// fallback (Design "default artwork delivery").
+ MediaItem itemEmisora(Emisora e) => MediaItem(
+ id: '$_prefijoEmisora${e.uuid}',
+ title: e.nombre,
+ playable: true,
+ artUri: Uri.parse(_artUriPara(e)),
+ extras: _contentStyleGrid,
+ );
+
+ String _artUriPara(Emisora e) {
+ final favicon = e.favicon;
+ return (favicon != null && favicon.isNotEmpty) ? favicon : _defaultArtUri;
+ }
+
+ /// Resolves `emisora:` ids to the matching [Emisora] in [universo].
+ /// Any other shape (no prefix, empty uuid, unmatched uuid) returns `null`
+ /// instead of throwing (Spec "Media Item Resolution by ID").
+ Emisora? resolver(String id, List universo) {
+ if (!id.startsWith(_prefijoEmisora)) return null;
+ final uuid = id.substring(_prefijoEmisora.length);
+ if (uuid.isEmpty) return null;
+ for (final emisora in universo) {
+ if (emisora.uuid == uuid) return emisora;
+ }
+ return null;
+ }
+}
+
+/// Routing seam between a car-tapped `emisora:` media id and the
+/// existing internal playback path (Design "playback coherence" — reuse
+/// over duplication). Resolves the uuid via [fuente], builds the same
+/// phone-shaped `MediaItem` (`id` = station url, `extras['uuid']`) that
+/// [ServicioAudio.reproducir] builds, and delegates to [reproducir].
+///
+/// A stale/unknown id (or a malformed one) is a no-op: [reproducir] is
+/// never called and no exception propagates (Spec "Unknown or stale media
+/// id").
+Future reproducirPorMediaId(
+ String id, {
+ required FuenteEmisorasAuto fuente,
+ required Future Function(MediaItem) reproducir,
+}) async {
+ if (!id.startsWith(_prefijoEmisora)) return;
+ final uuid = id.substring(_prefijoEmisora.length);
+ if (uuid.isEmpty) return;
+
+ final emisora = await fuente.porUuid(uuid);
+ if (emisora == null) return;
+
+ final item = MediaItem(
+ id: emisora.url,
+ title: emisora.nombre,
+ artist: emisora.pais ?? '',
+ album: 'PluriWave',
+ artUri:
+ emisora.favicon != null && emisora.favicon!.isNotEmpty
+ ? Uri.tryParse(emisora.favicon!)
+ : null,
+ extras: {'uuid': emisora.uuid},
+ );
+ await reproducir(item);
+}
+
+/// Local, cold-start-safe implementation of [FuenteEmisorasAuto] (Design
+/// "getChildren data source"). Reads favourites from SQLite and custom
+/// stations from the tolerant JSON file directly — both loadable without
+/// the network or a built widget tree, unlike `EstadoRadio._init()` (which
+/// is lazy-created by `ChangeNotifierProvider.create:` and may never run on
+/// a headless Auto bind). `EstadoRadio`, when alive, overrides these reads
+/// with a live snapshot via [actualizarSnapshot].
+class FuenteEmisorasAutoLocal implements FuenteEmisorasAuto {
+ FuenteEmisorasAutoLocal({
+ ServicioFavoritos? favoritosServicio,
+ Future Function()? resolverRutaCustom,
+ }) : _favoritosServicio = favoritosServicio ?? ServicioFavoritos(),
+ _resolverRutaCustom = resolverRutaCustom;
+
+ final ServicioFavoritos _favoritosServicio;
+ final Future Function()? _resolverRutaCustom;
+
+ List? _snapshotFavoritos;
+ List? _snapshotMisEmisoras;
+ List? _snapshotTodas;
+
+ /// Overrides the next reads with `EstadoRadio`'s live in-memory lists
+ /// (Design "live snapshot the source prefers"). Passing `null` for a
+ /// field leaves its current override (or local read) untouched.
+ @override
+ void actualizarSnapshot({
+ List? favoritos,
+ List? misEmisoras,
+ List? todas,
+ }) {
+ if (favoritos != null) _snapshotFavoritos = favoritos;
+ if (misEmisoras != null) _snapshotMisEmisoras = misEmisoras;
+ if (todas != null) _snapshotTodas = todas;
+ }
+
+ @override
+ Future> favoritos() async {
+ final snapshot = _snapshotFavoritos;
+ if (snapshot != null) return snapshot;
+ try {
+ return await _favoritosServicio.obtenerTodos();
+ } catch (_) {
+ // Cold-start safety (Spec "Browse requested before app state is
+ // loaded"): never throw out of a browse call.
+ return const [];
+ }
+ }
+
+ @override
+ Future> misEmisoras() async {
+ final snapshot = _snapshotMisEmisoras;
+ if (snapshot != null) return snapshot;
+ return _leerEmisorasCustom();
+ }
+
+ @override
+ Future> todas() async {
+ // No live network snapshot on a cold bind — populated only once
+ // EstadoRadio pushes its populares list (Design "which stations
+ // surface & ordering": degrades gracefully to empty-but-valid).
+ return _snapshotTodas ?? const [];
+ }
+
+ @override
+ Future porUuid(String uuid) async {
+ final listas = await Future.wait([favoritos(), misEmisoras(), todas()]);
+ for (final lista in listas) {
+ for (final emisora in lista) {
+ if (emisora.uuid == uuid) return emisora;
+ }
+ }
+ return null;
+ }
+
+ /// Mirrors `EstadoRadio._cargarEmisorasCustom()`'s tolerant JSON read:
+ /// missing/corrupt files never throw, they degrade to an empty list.
+ Future> _leerEmisorasCustom() async {
+ try {
+ final ruta = await _rutaArchivoCustom();
+ final archivo = File(ruta);
+ if (!await archivo.exists()) return const [];
+ final contenido = await archivo.readAsString();
+ final data = jsonDecode(contenido) as List;
+ final resultado = parseListaTolerante(
+ data,
+ Emisora.fromMap,
+ subsistema: 'emisoras_custom_auto',
+ coleccion: 'emisoras_custom',
+ );
+ return resultado.validas;
+ } catch (_) {
+ return const [];
+ }
+ }
+
+ Future _rutaArchivoCustom() async {
+ final resolver = _resolverRutaCustom;
+ if (resolver != null) return resolver();
+ final dir = await getApplicationDocumentsDirectory();
+ return '${dir.path}/emisoras_custom.json';
+ }
+}
diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart
index 0b9eccb..63871c0 100644
--- a/lib/servicios/servicio_audio.dart
+++ b/lib/servicios/servicio_audio.dart
@@ -11,6 +11,7 @@ import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../modelos/preset_ecualizador.dart';
import 'controlador_reconexion.dart';
+import 'navegacion_auto.dart';
import 'servicio_audio_session.dart';
/// Estado de reproducción expuesto al UI.
@@ -35,6 +36,16 @@ void registrarHandler(PluriWaveAudioHandler handler) {
_handlerGlobal = handler;
}
+// ─────────────────────────────────────────────────────────────────────────────
+// Android Auto browse source — registered from main.dart, mirrors
+// registrarHandler above (Design "getChildren data source registration").
+// ─────────────────────────────────────────────────────────────────────────────
+FuenteEmisorasAuto? _fuenteNavegacionGlobal;
+
+void registrarFuenteNavegacion(FuenteEmisorasAuto fuente) {
+ _fuenteNavegacionGlobal = fuente;
+}
+
/// Wrapper de alto nivel para el UI.
class ServicioAudio {
PluriWaveAudioHandler get _handler {
@@ -712,4 +723,84 @@ class PluriWaveAudioHandler extends BaseAudioHandler
favicon: mediaItem.artUri?.toString(),
);
}
+
+ // ── Android Auto browsing (thin delegation to navegacion_auto.dart's
+ // already-tested pure logic — Design "getChildren data source") ─────────
+
+ @override
+ Future> getChildren(
+ String parentMediaId, [
+ Map? options,
+ ]) async {
+ try {
+ final constructor = ConstructorArbolAuto();
+ if (parentMediaId == AudioService.browsableRootId) {
+ return constructor.raiz();
+ }
+ final fuente = _fuenteNavegacionGlobal;
+ if (fuente == null) return const [];
+ final emisoras = await _listaParaCarpeta(fuente, parentMediaId);
+ return constructor.hijos(parentMediaId, emisoras: emisoras);
+ } catch (_) {
+ // Spec "Browse requested before app state is loaded": never throw out
+ // of a browse call, even on an unexpected failure.
+ return const [];
+ }
+ }
+
+ @override
+ Future getMediaItem(String mediaId) async {
+ try {
+ final fuente = _fuenteNavegacionGlobal;
+ if (fuente == null) return null;
+ final universo = await _universoCompleto(fuente);
+ final constructor = ConstructorArbolAuto();
+ final emisora = constructor.resolver(mediaId, universo);
+ return emisora == null ? null : constructor.itemEmisora(emisora);
+ } catch (_) {
+ return null;
+ }
+ }
+
+ @override
+ Future playFromMediaId(
+ String mediaId, [
+ Map? extras,
+ ]) async {
+ final fuente = _fuenteNavegacionGlobal;
+ if (fuente == null) return;
+ try {
+ await reproducirPorMediaId(
+ mediaId,
+ fuente: fuente,
+ reproducir: playMediaItem,
+ );
+ } catch (e) {
+ // Spec "Unknown or stale media id": never propagate from the handler.
+ developer.log(
+ '[PluriWave] Error en playFromMediaId($mediaId): $e',
+ name: 'ServicioAudio',
+ level: 900,
+ );
+ }
+ }
+
+ Future> _listaParaCarpeta(
+ FuenteEmisorasAuto fuente,
+ String parentId,
+ ) => switch (parentId) {
+ ConstructorArbolAuto.idFavoritos => fuente.favoritos(),
+ ConstructorArbolAuto.idMisEmisoras => fuente.misEmisoras(),
+ ConstructorArbolAuto.idTodas => fuente.todas(),
+ _ => Future.value(const []),
+ };
+
+ Future> _universoCompleto(FuenteEmisorasAuto fuente) async {
+ final listas = await Future.wait([
+ fuente.favoritos(),
+ fuente.misEmisoras(),
+ fuente.todas(),
+ ]);
+ return listas.expand((lista) => lista).toList();
+ }
}
diff --git a/test/estado/estado_radio_test.dart b/test/estado/estado_radio_test.dart
index 977d165..27089fc 100644
--- a/test/estado/estado_radio_test.dart
+++ b/test/estado/estado_radio_test.dart
@@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart';
+import 'package:pluriwave/servicios/navegacion_auto.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -547,6 +548,111 @@ void main() {
);
},
);
+
+ group(
+ 'EstadoRadio — Android Auto: snapshot en vivo y reconciliación '
+ '(android-auto-media)',
+ () {
+ test(
+ 'empuja un snapshot actualizado a la fuente registrada cuando '
+ 'cambian favoritos/custom/populares',
+ () async {
+ final fuenteAuto = _FuenteEmisorasAutoEspia();
+ final archivo = await _crearArchivoCustom([
+ emisoraDemo(uuid: 'custom-auto-1', nombre: 'Custom Auto'),
+ ]);
+ final emisoraFav = emisoraDemo(uuid: 'fav-auto-1', nombre: 'Fav Auto');
+ final estado = EstadoRadio(
+ audio: FakeServicioAudio(),
+ favoritos: FakeServicioFavoritos(),
+ radio: FakeServicioRadio(
+ populares: [emisoraDemo(uuid: 'pop-auto-1', nombre: 'Pop Auto')],
+ ),
+ servicioEcualizador: FakeServicioEcualizador(),
+ resolverArchivoCustom: () async => archivo,
+ fuenteAuto: fuenteAuto,
+ iniciarAutomaticamente: false,
+ );
+
+ await estado.inicializar();
+
+ expect(
+ fuenteAuto.ultimoMisEmisoras?.map((e) => e.uuid),
+ contains('custom-auto-1'),
+ );
+ expect(
+ fuenteAuto.ultimoTodas?.map((e) => e.uuid),
+ contains('pop-auto-1'),
+ );
+
+ await estado.toggleFavorito(emisoraFav);
+
+ expect(
+ fuenteAuto.ultimoFavoritos?.map((e) => e.uuid),
+ contains('fav-auto-1'),
+ );
+ },
+ );
+
+ test(
+ 'reconcilia _emisoraSeleccionada cuando la selección viene desde '
+ 'el auto (no via reproducir())',
+ () async {
+ final audio = _AudioControlado();
+ final estado = EstadoRadio(
+ audio: audio,
+ favoritos: FakeServicioFavoritos(),
+ radio: FakeServicioRadio(),
+ servicioEcualizador: FakeServicioEcualizador(),
+ resolverArchivoCustom: _archivoCustomVacio,
+ iniciarAutomaticamente: false,
+ );
+ await estado.inicializar();
+ final desdeCoche = emisoraDemo(
+ uuid: 'auto-selected',
+ nombre: 'Desde el auto',
+ );
+
+ audio.seleccionarDesdeAuto(desdeCoche);
+ await Future.delayed(Duration.zero);
+
+ expect(estado.emisoraActual?.uuid, desdeCoche.uuid);
+ },
+ );
+ },
+ );
+}
+
+/// Spy [FuenteEmisorasAuto] that only records the last snapshot pushed by
+/// [EstadoRadio] via [actualizarSnapshot] — used to assert the live-snapshot
+/// wiring (Design "live snapshot the source prefers") without touching the
+/// real local data source.
+class _FuenteEmisorasAutoEspia implements FuenteEmisorasAuto {
+ List? ultimoFavoritos;
+ List? ultimoMisEmisoras;
+ List? ultimoTodas;
+
+ void actualizarSnapshot({
+ List? favoritos,
+ List? misEmisoras,
+ List? todas,
+ }) {
+ if (favoritos != null) ultimoFavoritos = favoritos;
+ if (misEmisoras != null) ultimoMisEmisoras = misEmisoras;
+ if (todas != null) ultimoTodas = todas;
+ }
+
+ @override
+ Future> favoritos() async => ultimoFavoritos ?? const [];
+
+ @override
+ Future> misEmisoras() async => ultimoMisEmisoras ?? const [];
+
+ @override
+ Future> todas() async => ultimoTodas ?? const [];
+
+ @override
+ Future porUuid(String uuid) async => null;
}
/// [File] spy: only the members `_cargarEmisorasCustom`/
@@ -630,6 +736,15 @@ class _AudioControlado extends ServicioAudio {
_pendientes.remove(uuid)?.complete();
}
+ /// Simulates a car-initiated selection (android-auto-media task 4.3):
+ /// changes [emisoraActual] WITHOUT going through [reproducir], then pushes
+ /// an `estadoStream` event, exactly like the real handler would after
+ /// `playFromMediaId` bypasses `EstadoRadio.reproducir()`.
+ void seleccionarDesdeAuto(Emisora emisora) {
+ _actual = emisora;
+ _estadoController.add(EstadoReproduccion.reproduciendo);
+ }
+
@override
Future aplicarPreset(PresetEcualizador preset) async {}
diff --git a/test/servicios/navegacion_auto_test.dart b/test/servicios/navegacion_auto_test.dart
new file mode 100644
index 0000000..fb93387
--- /dev/null
+++ b/test/servicios/navegacion_auto_test.dart
@@ -0,0 +1,226 @@
+import 'package:audio_service/audio_service.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:pluriwave/modelos/emisora.dart';
+import 'package:pluriwave/servicios/navegacion_auto.dart';
+
+void main() {
+ group('ConstructorArbolAuto.raiz', () {
+ test('devuelve exactamente 3 carpetas no reproducibles con los ids '
+ 'esperados', () {
+ final raiz = ConstructorArbolAuto().raiz();
+
+ expect(raiz, hasLength(3));
+ final ids = raiz.map((item) => item.id).toSet();
+ expect(
+ ids,
+ equals({
+ ConstructorArbolAuto.idFavoritos,
+ ConstructorArbolAuto.idTodas,
+ ConstructorArbolAuto.idMisEmisoras,
+ }),
+ );
+ for (final item in raiz) {
+ expect(item.playable, isFalse);
+ expect(item.title, isNotEmpty);
+ }
+ });
+ });
+
+ group('ConstructorArbolAuto.itemEmisora', () {
+ test('usa el favicon remoto como artUri cuando existe', () {
+ final emisora = _emisora(
+ uuid: 'uuid-logo',
+ nombre: 'Radio Con Logo',
+ favicon: 'https://cdn.example.com/logo.png',
+ );
+
+ final item = ConstructorArbolAuto().itemEmisora(emisora);
+
+ expect(item.id, 'emisora:${emisora.uuid}');
+ expect(item.title, emisora.nombre);
+ expect(item.artUri.toString(), emisora.favicon);
+ expect(item.playable, isTrue);
+ });
+
+ test('cae al arte por defecto cuando el favicon es null o vacío', () {
+ final sinFavicon = _emisora(
+ uuid: 'uuid-null',
+ nombre: 'Radio Sin Logo',
+ favicon: null,
+ );
+ final faviconVacio = _emisora(
+ uuid: 'uuid-vacio',
+ nombre: 'Radio Logo Vacio',
+ favicon: '',
+ );
+ const esperado =
+ 'android.resource://es.freetimelab.pluriwave/drawable/default_station_art';
+
+ final builder = ConstructorArbolAuto();
+
+ expect(builder.itemEmisora(sinFavicon).artUri.toString(), esperado);
+ expect(builder.itemEmisora(faviconVacio).artUri.toString(), esperado);
+ });
+ });
+
+ group('ConstructorArbolAuto.hijos', () {
+ test('limita a 50 items y respeta el orden de ordenarEmisoras', () {
+ final emisoras = List.generate(
+ 60,
+ (i) => _emisora(uuid: 'uuid-$i', nombre: 'Radio $i', bitrate: i),
+ );
+
+ final hijos = ConstructorArbolAuto().hijos(
+ ConstructorArbolAuto.idFavoritos,
+ emisoras: emisoras,
+ );
+
+ expect(hijos, hasLength(50));
+ // ordenarEmisoras por calidad ordena por bitrate descendente: el
+ // primer item debe ser el de mayor bitrate (59) y el ultimo (el #50)
+ // el de bitrate 10 (59..10 son 50 valores).
+ expect(hijos.first.id, 'emisora:uuid-59');
+ expect(hijos.last.id, 'emisora:uuid-10');
+ });
+
+ test('lista vacía cuando no hay emisoras, sin lanzar', () {
+ final hijos = ConstructorArbolAuto().hijos(
+ ConstructorArbolAuto.idFavoritos,
+ emisoras: const [],
+ );
+
+ expect(hijos, isEmpty);
+ });
+
+ test('parentId desconocido devuelve lista vacía', () {
+ final emisoras = [_emisora(uuid: 'uuid-1', nombre: 'Radio 1')];
+
+ final hijos = ConstructorArbolAuto().hijos(
+ 'carpeta-inexistente',
+ emisoras: emisoras,
+ );
+
+ expect(hijos, isEmpty);
+ });
+ });
+
+ group('ConstructorArbolAuto.resolver', () {
+ test('resuelve un uuid conocido a su Emisora', () {
+ final emisora = _emisora(uuid: 'uuid-conocido', nombre: 'Conocida');
+ final universo = [emisora, _emisora(uuid: 'otra', nombre: 'Otra')];
+
+ final resultado = ConstructorArbolAuto().resolver(
+ 'emisora:uuid-conocido',
+ universo,
+ );
+
+ expect(resultado, equals(emisora));
+ });
+
+ test(
+ 'devuelve null para id sin prefijo, id malformado o uuid sin match, '
+ 'sin lanzar',
+ () {
+ final universo = [_emisora(uuid: 'uuid-conocido', nombre: 'Conocida')];
+ final builder = ConstructorArbolAuto();
+
+ expect(builder.resolver('favoritos', universo), isNull);
+ expect(builder.resolver('emisora:', universo), isNull);
+ expect(
+ builder.resolver('emisora:uuid-desconocido', universo),
+ isNull,
+ );
+ },
+ );
+ });
+
+ group('reproducirPorMediaId', () {
+ test(
+ 'resuelve el id y delega a reproducir con un MediaItem con forma de '
+ 'telefono (id=url, extras.uuid)',
+ () async {
+ final emisora = _emisora(
+ uuid: 'uuid-play',
+ nombre: 'Radio a reproducir',
+ url: 'https://stream.demo/play',
+ );
+ final fuente = _FakeFuenteEmisorasAuto(
+ porUuidResultado: {emisora.uuid: emisora},
+ );
+ MediaItem? recibido;
+
+ await reproducirPorMediaId(
+ 'emisora:${emisora.uuid}',
+ fuente: fuente,
+ reproducir: (item) async {
+ recibido = item;
+ },
+ );
+
+ expect(recibido, isNotNull);
+ expect(recibido!.id, emisora.url);
+ expect(recibido!.extras?['uuid'], emisora.uuid);
+ },
+ );
+
+ test(
+ 'id obsoleto/desconocido no llama a reproducir ni lanza excepción',
+ () async {
+ final fuente = _FakeFuenteEmisorasAuto(porUuidResultado: const {});
+ var llamadas = 0;
+
+ await reproducirPorMediaId(
+ 'emisora:uuid-fantasma',
+ fuente: fuente,
+ reproducir: (item) async {
+ llamadas++;
+ },
+ );
+
+ expect(llamadas, 0);
+ },
+ );
+ });
+}
+
+Emisora _emisora({
+ required String uuid,
+ required String nombre,
+ String url = 'https://stream.demo/radio',
+ String? favicon,
+ int? bitrate,
+}) {
+ return Emisora(
+ uuid: uuid,
+ nombre: nombre,
+ url: url,
+ favicon: favicon,
+ bitrate: bitrate,
+ );
+}
+
+class _FakeFuenteEmisorasAuto implements FuenteEmisorasAuto {
+ _FakeFuenteEmisorasAuto({required Map porUuidResultado})
+ : _porUuidResultado = porUuidResultado;
+
+ final Map _porUuidResultado;
+
+ @override
+ Future> favoritos() async => const [];
+
+ @override
+ Future> misEmisoras() async => const [];
+
+ @override
+ Future> todas() async => const [];
+
+ @override
+ Future porUuid(String uuid) async => _porUuidResultado[uuid];
+
+ @override
+ void actualizarSnapshot({
+ List? favoritos,
+ List? misEmisoras,
+ List? todas,
+ }) {}
+}