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, + }) {} +}