diff --git a/lib/estado/estado_radio.dart b/lib/estado/estado_radio.dart index 0b0c484..88875e2 100644 --- a/lib/estado/estado_radio.dart +++ b/lib/estado/estado_radio.dart @@ -338,24 +338,6 @@ class EstadoRadio extends ChangeNotifier { } } - /// Best-effort remembers [emisora] as the last used station (issue 4) so - /// [_restaurarUltimaEmisora] can bring it back after a restart. Fire-and- - /// forget, same treatment [reproducir] already gives other non-critical - /// side effects (e.g. `radio.registrarClick`) — a failed write here must - /// never block or fail actual playback. - Future _persistirUltimaEmisora(Emisora emisora) async { - try { - final prefs = await _resolverPrefs(); - await prefs.setString(_keyUltimaEmisora, jsonEncode(emisora.toMap())); - } catch (e) { - registrarSaltoPersistencia( - subsistema: 'ultima_emisora', - detalle: 'persistir ${emisora.uuid}', - razon: e.toString(), - ); - } - } - /// Escucha el stream de estado del audio y gestiona errores de reproducción. void _escucharErroresReproduccion() { _suscripcionEstadoAudio = audio.estadoStream.listen((estado) { @@ -375,9 +357,12 @@ class EstadoRadio extends ChangeNotifier { final actual = audio.emisoraActual; if (actual != null && actual.uuid != _emisoraSeleccionada?.uuid) { _emisoraSeleccionada = actual; - // Issue 4: an Android-Auto-initiated selection is a real station - // change too — remember it the same way `reproducir` does. - unawaited(_persistirUltimaEmisora(actual)); + // Issue 4's write used to live here as well. It is gone: the handler + // persists every station itself from `_cambiarFuente`, which is the + // same source change that moved `audio.emisoraActual` and is the + // reason this branch runs at all. Writing again here would make the + // key's final value depend on how two independent fire-and-forget + // chains interleave on a fast station switch. } notifyListeners(); }); @@ -588,10 +573,13 @@ class EstadoRadio extends ChangeNotifier { } _emisoraSeleccionada = emisora; notifyListeners(); - // Issue 4: remembers the station the user just picked so it survives a - // restart — fire-and-forget, same treatment as `radio.registrarClick` - // below (a persistence failure here must never block playback). - unawaited(_persistirUltimaEmisora(emisora)); + // Issue 4's `ultima_emisora_v1` write used to be here. It now happens + // once, inside the handler's `_cambiarFuente`, which `audio.reproducir` + // below reaches for this very station — see + // [GuardarUltimaEmisoraPersistida]. Persisting here as well would have + // left the key with TWO fire-and-forget writers whose relative order + // decides the value after a fast A -> B switch, and this one cannot see + // the revision guard that already cancels a superseded change. try { await audio.reproducir(emisora); if (revision != _revisionReproduccion) return; diff --git a/lib/main.dart b/lib/main.dart index 1430bd7..f37ff2e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,6 +11,7 @@ import 'app.dart'; import 'estado/estado_entitlement.dart'; import 'servicios/arranque_audio.dart'; import 'servicios/contexto_reproduccion.dart'; +import 'servicios/emisoras_destacadas.dart'; import 'servicios/musica_local_auto.dart'; import 'servicios/navegacion_auto.dart'; import 'servicios/servicio_audio.dart'; @@ -210,6 +211,15 @@ Future main() async { // context the car could never have. leerContextoSalto: contextoSaltoPersistido, guardarContextoSalto: guardarContextoSalto, + // Last played station (`ultima_emisora_v1`). Bound here for the SAME + // reason as the skip context: `EstadoRadio` — which used to be its only + // writer — belongs to the widget tree, and the Android Auto engine + // builds none, so a session that happened only in the car never updated + // the key and the head unit was offered whatever the PHONE last played. + // The write port is now the key's single writer; the read port feeds the + // cold-start metadata seed and the bare-`play()` resume. + leerUltimaEmisora: ultimaEmisoraPersistida, + guardarUltimaEmisora: guardarUltimaEmisoraPersistida, ); // The handler is the only thing this app ever tears down // (`onTaskRemoved`), so the asyncError subscription's `cancel` travels diff --git a/lib/servicios/emisoras_destacadas.dart b/lib/servicios/emisoras_destacadas.dart index 1acdb8f..d25bd6b 100644 --- a/lib/servicios/emisoras_destacadas.dart +++ b/lib/servicios/emisoras_destacadas.dart @@ -155,6 +155,28 @@ Future esEmisoraGratuitaPorUuid( Future ultimaEmisoraPersistida({SharedPreferences? prefs}) => _ultimaEmisora(prefs: prefs); +/// Writes [emisora] as the last-played station — the SINGLE writer of +/// [claveUltimaEmisora]. +/// +/// It lives beside [ultimaEmisoraPersistida] rather than in `EstadoRadio` +/// because the key has to be written from the engine Android Auto starts, +/// which builds no widget tree and therefore never constructs `EstadoRadio` +/// at all: a session that happened only in the car used to leave the key +/// holding whatever the PHONE last played, so the head unit's resume row and +/// the free tier's featured folder were both stale on the next connect. +/// +/// Deliberately NOT swallowing failures here: the handler port that calls it +/// traces and swallows (a persistence failure must never break playback), +/// and a silent `catch` in BOTH places would make a dead write channel +/// invisible from a car logcat. +Future guardarUltimaEmisoraPersistida( + Emisora emisora, { + SharedPreferences? prefs, +}) async { + final resueltas = prefs ?? await SharedPreferences.getInstance(); + await resueltas.setString(claveUltimaEmisora, jsonEncode(emisora.toMap())); +} + /// Reads the persisted last-played station, or `null` when there is none, /// the payload is unreadable, or prefs themselves fail. Future _ultimaEmisora({SharedPreferences? prefs}) async { diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart index fa5eca1..e99229c 100644 --- a/lib/servicios/servicio_audio.dart +++ b/lib/servicios/servicio_audio.dart @@ -65,6 +65,25 @@ typedef LeerPresetPersistido = Future Function(); typedef GuardarContextoSaltoPersistido = Future Function(ContextoSalto contexto); +/// Read port for the persisted last-played station (`ultima_emisora_v1`). +/// Bound to `ultimaEmisoraPersistida` in `main.dart`; `null` for any caller +/// with no disk (widget tests, fakes), which then neither seeds the cold-start +/// metadata nor resumes anything from a bare `play()`. +typedef LeerUltimaEmisoraPersistida = Future Function(); + +/// Write port for the same key, and — since this seam exists — its ONLY +/// writer. +/// +/// It had none: `EstadoRadio._persistirUltimaEmisora` was the sole writer and +/// `EstadoRadio` is built by the lazy `ChangeNotifierProvider` in `app.dart`, +/// which a headless Android Auto engine (`AudioServicePlugin.java:75-111` +/// builds `new FlutterEngine(applicationContext)` with no Activity) never +/// reaches. So a session that happened ONLY in the car never updated the key, +/// and on the next connect the head unit was offered the station from the +/// last time the PHONE was used — the same stale record +/// `resolverEmisorasDestacadas` puts first in the free tier's featured folder. +typedef GuardarUltimaEmisoraPersistida = Future Function(Emisora emisora); + /// Last value read from disk for the equalizer on/off flag, or `null` while /// nothing has been read yet. /// @@ -178,6 +197,8 @@ void registrarHandler( LeerPresetPersistido? leerPresetPersistido, LeerContextoSaltoPersistido? leerContextoSalto, GuardarContextoSaltoPersistido? guardarContextoSalto, + LeerUltimaEmisoraPersistida? leerUltimaEmisora, + GuardarUltimaEmisoraPersistida? guardarUltimaEmisora, }) { _handlerGlobal = handler; // Registered BEFORE the seeding below is awaited so that a toggle arriving @@ -192,6 +213,20 @@ void registrarHandler( leer: leerContextoSalto, guardar: guardarContextoSalto, ); + // Same seam shape again for the last-played station. The WRITE half is + // registered before anything is awaited for the same reason the equalizer's + // is: a station change arriving during the read below must still be + // persisted. + handler.registrarPersistenciaUltimaEmisora( + leer: leerUltimaEmisora, + guardar: guardarUltimaEmisora, + ); + // Cold-start metadata (A3). Seeded eagerly, like the equalizer flag and + // unlike the skip context: a head unit asks for the now-playing metadata + // the moment it binds, and `audio_service` cannot send any while + // `mediaItem` is null. Fire-and-forget and internally guarded, so it is a + // no-op without a read port and never clobbers a live station. + unawaited(handler.sembrarUltimaEmisoraDesdeDisco()); if (leerEqActivoPersistido != null) { unawaited(_sembrarEcualizadorDesdeDisco(handler, leerEqActivoPersistido)); } @@ -1373,6 +1408,127 @@ class PluriWaveAudioHandler extends BaseAudioHandler _guardarContextoSalto = guardar; } + LeerUltimaEmisoraPersistida? _leerUltimaEmisora; + GuardarUltimaEmisoraPersistida? _guardarUltimaEmisora; + + /// Injects the last-played station's persistence ports (see + /// [GuardarUltimaEmisoraPersistida]). Both accept `null` — a handler with no + /// disk simply never remembers and never restores, exactly as before this + /// seam existed. + void registrarPersistenciaUltimaEmisora({ + LeerUltimaEmisoraPersistida? leer, + GuardarUltimaEmisoraPersistida? guardar, + }) { + _leerUltimaEmisora = leer; + _guardarUltimaEmisora = guardar; + } + + /// Whether [item] is a RADIO STATION rather than a local track. + /// + /// `ultima_emisora_v1` is read back as an `emisora:` row by the car's + /// recent root and by `resolverEmisorasDestacadas`, so a `content://` local + /// track written there would occupy that slot with a row that resolves to + /// nothing when tapped. Every station path builds its item through + /// [mediaItemParaEmisora] or `reproducirPorMediaId`, both of which stamp + /// `extras['uuid']`; `construirMediaItemColaLocal`/`reproducirPistaLocal` + /// stamp `extras['documentId']` instead. Private: it is asserted through + /// the real source-change path (a local track must leave the record + /// untouched), not as a predicate in isolation. + static bool _esMediaItemDeEmisora(MediaItem item) { + final uuid = item.extras?['uuid']; + return uuid is String && uuid.isNotEmpty; + } + + /// Best-effort write of the last-played station through the injected port. + /// + /// Never throws and never blocks the source change: a persistence failure + /// must cost the driver a stale resume row, never the station they just + /// asked for. Traced rather than swallowed, so a dead write channel is + /// visible in a car logcat instead of looking exactly like a working one. + Future _persistirUltimaEmisora(MediaItem item) async { + if (!_esMediaItemDeEmisora(item)) return; + final guardar = _guardarUltimaEmisora; + if (guardar == null) return; + try { + await guardar(emisoraDesdeMediaItem(item)); + } catch (e) { + debugPrint( + '[PluriWave][ServicioAudio] no se pudo guardar la ultima emisora: $e', + ); + } + } + + /// The persisted last-played station, or `null` when there is no port, no + /// record, or the read failed. Never throws — an unreadable record must + /// mean "nothing to resume", not a dead Play button. + Future _ultimaEmisoraRecordada() async { + final leer = _leerUltimaEmisora; + if (leer == null) return null; + try { + return await leer(); + } catch (e) { + debugPrint( + '[PluriWave][ServicioAudio] no se pudo leer la ultima emisora: $e', + ); + return null; + } + } + + /// Whether a source has actually been opened on this handler — set by + /// [_cambiarFuente] once it is past its revision guard, cleared by [stop]. + /// + /// Deliberately NOT `mediaItem.value != null`: since + /// [sembrarUltimaEmisoraDesdeDisco] publishes metadata on a cold start + /// WITHOUT loading anything, the two questions stopped being the same one. + /// Reading the metadata there would send a bare `play()` straight into + /// `_player.play()` on a player with no source, which is defect A2 all over + /// again. + bool _fuenteAbierta = false; + + /// Publishes the persisted station's metadata on a COLD start, without + /// touching the player. + /// + /// The handler constructor only wires streams, and the only `mediaItem.add` + /// sites are the duration update (which needs an item to already exist), + /// [_cambiarFuente] and [stop] (which publishes `null`). So on a headless + /// bind `mediaItem` was null, `audio_service.dart:1029-1033` returned before + /// `setMediaItem`, and the head unit received no metadata at all — no title, + /// no artwork, nothing to put on the now-playing surface. + /// + /// Checked before AND after the disk read: a station that started while the + /// read was in flight owns the metadata, and renaming what the driver is + /// actually listening to would be far worse than a blank tile. + Future sembrarUltimaEmisoraDesdeDisco() async { + if (mediaItem.value != null || _fuenteAbierta) return; + final ultima = await _ultimaEmisoraRecordada(); + if (ultima == null) return; + if (mediaItem.value != null || _fuenteAbierta) return; + mediaItem.add(mediaItemParaEmisora(ultima, l10n: _textos)); + } + + /// Resolves the persisted station and starts it through the ordinary play + /// path. Returns `false` when there was nothing to resume. + /// + /// Routed through [playMediaItem] on purpose — the revision guard, the + /// queue clearing, the skip-context recording and the terminal-state floor + /// all live behind that choke point, and a parallel path would have to + /// re-earn every one of them. + Future _reanudarUltimaEmisora() async { + final ultima = await _ultimaEmisoraRecordada(); + if (ultima == null) return false; + try { + await playMediaItem(mediaItemParaEmisora(ultima, l10n: _textos)); + } catch (e) { + // The failure is already published to `playbackState` by + // `_cambiarFuente`; a transport button must not additionally throw out + // of the handler (Spec "never propagate from the handler"). + debugPrint( + '[PluriWave][ServicioAudio] no se pudo reanudar la ultima emisora: $e', + ); + } + return true; + } + /// The remembered context: memory first, then the read port ONCE. /// /// Never throws — an unreadable context must mean "derive it again", not a @@ -2330,6 +2486,24 @@ class PluriWaveAudioHandler extends BaseAudioHandler if (revision != _revisionFuente) return; this.mediaItem.add(mediaItem); emisoraActual = _emisoraDesdeMediaItem(mediaItem); + // A source is now genuinely open on this handler — see [_fuenteAbierta]. + _fuenteAbierta = true; + // THE SINGLE WRITER of `ultima_emisora_v1`. Placed here, past the + // revision guard and beside the `mediaItem` publish, because this is the + // one point EVERY play path funnels through: the phone (`EstadoRadio. + // reproducir` -> `ServicioAudio.reproducir` -> `playMediaItem`), a car + // browse tap (`playFromMediaId`), voice (`playFromSearch`), a skip, a + // queue advance and the bare-`play()` resume below. + // + // `EstadoRadio._persistirUltimaEmisora` was deleted rather than kept + // alongside this. Two writers of one key is exactly the shape that + // produced the equalizer divergence twice: both wrote fire-and-forget, so + // on a fast A -> B station switch the interleaving of two independent + // unawaited chains decided the final value, and the phone's copy could + // not see the revision guard that already cancels a superseded change. + // One writer behind one serialized queue has neither problem, and it is + // the only writer that exists on the engine Android Auto starts. + unawaited(_persistirUltimaEmisora(mediaItem)); // A new source is being opened, so no previous terminal error owns the // screen any more (see [_errorTerminal]). _errorTerminal = false; @@ -2868,7 +3042,31 @@ class PluriWaveAudioHandler extends BaseAudioHandler Future reaplicarEcualizador() => _activarEcualizador(); @override - Future play() { + Future play() async { + // NO SOURCE LOADED — the cold-engine case, and the reason this override + // is no longer a one-liner. + // + // `AudioService.java:920` routes the car's `KEYCODE_MEDIA_PLAY` straight + // in here, and there is no `prepare`/`onPrepare`/`prepareFromMediaId` + // override anywhere in this app to have loaded anything first. Handed to + // `_player.play()`, `just_audio.dart:937-967` publishes + // `_playingSubject.add(true)` BEFORE its `_audioSource != null` gate: the + // platform is never touched, the returned Future NEVER completes, and yet + // `playing: true` is forwarded by [manejarEstadoPlayer] over + // `processingState: idle`. `AudioService.java:559-560` then runs + // `enterPlayingState()` while `getPlaybackState()` is `STATE_NONE` — a + // PluriWave notification with a pause button, no audio, no title and no + // artwork, or a `ForegroundServiceStartNotAllowedException` on API 31+. + // + // So: resolve the persisted station and go through the ordinary play + // path, and when there is nothing to resume touch neither the player nor + // `playbackState` and complete immediately. Doing nothing is the correct + // answer there — a phantom foreground session is strictly worse than a + // Play button that did not find anything to play. + if (!_fuenteAbierta) { + await _reanudarUltimaEmisora(); + return; + } _intencionReproducir = true; // Fresh user intent: whatever terminal error was standing no longer owns // the screen, so stop masking the player's `idle` (see [_errorTerminal]). @@ -2918,6 +3116,11 @@ class PluriWaveAudioHandler extends BaseAudioHandler // The session is over: whatever this run proved about the mount does not // carry into the next one (see [_reproduccionEstablecida]). _reproduccionEstablecida = false; + // The session is over and `mediaItem` is cleared below, so the next bare + // `play()` — a car transport button on a torn-down session — must resolve + // a station again instead of calling `_player.play()` on nothing (see + // [_fuenteAbierta] and [play]). + _fuenteAbierta = false; _revisionFuente++; await _player.stop(); // Publish `idle` OURSELVES rather than trusting the player to emit it. @@ -3394,12 +3597,26 @@ class PluriWaveAudioHandler extends BaseAudioHandler @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); + final uuid = uuidDeMediaIdEmisora(mediaId); + // Not a station id at all (`pista:`, `carpeta_local_*:`, `eq_preset:`, + // a folder, or `emisora:` with an empty tail) — unchanged behaviour. + if (uuid == null) return null; + // Was `_universoCompleto` (favoritos + misEmisoras + todas) alone, which + // is EMPTY on a headless bind, while `porUuid` has always also fallen + // back to the featured set. The car could therefore BROWSE a featured + // station and then fail to resolve its media item — an asymmetry, not a + // policy. Delegating to `porUuid` removes it (and short-circuits on the + // first list that matches instead of always awaiting all three), and the + // `FuenteEmisorasAutoDestacadas` stand-in covers the window before + // `main.dart` registers the real source, exactly as [playFromMediaId] + // already does. + final fuente = + _fuenteNavegacionGlobal ?? + FuenteEmisorasAutoDestacadas(await resolverEmisorasDestacadas()); + final emisora = await fuente.porUuid(uuid); + return emisora == null + ? null + : ConstructorArbolAuto().itemEmisora(emisora); } catch (_) { return null; } @@ -3693,12 +3910,4 @@ class PluriWaveAudioHandler extends BaseAudioHandler _ => 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 41f1676..6d56637 100644 --- a/test/estado/estado_radio_test.dart +++ b/test/estado/estado_radio_test.dart @@ -7,6 +7,7 @@ import 'package:pluriwave/estado/estado_radio.dart'; import 'package:pluriwave/modelos/emisora.dart'; import 'package:pluriwave/modelos/grupo_favoritos.dart'; import 'package:pluriwave/modelos/preset_ecualizador.dart'; +import 'package:pluriwave/servicios/emisoras_destacadas.dart'; import 'package:pluriwave/servicios/navegacion_auto.dart'; import 'package:pluriwave/servicios/servicio_audio.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -792,21 +793,16 @@ void main() { '(reinicio) como emisoraActual DETENIDA -- no arranca audio, no ' 'reproduce, sólo queda seleccionada', () async { final emisora = emisoraDemo(uuid: 'last-1', nombre: 'Ultima FM'); - final estadoUno = EstadoRadio( - esPremium: () => true, - audio: FakeServicioAudio(), - favoritos: FakeServicioFavoritos(), - radio: FakeServicioRadio(), - servicioEcualizador: FakeServicioEcualizador(), - resolverArchivoCustom: _archivoCustomVacio, - iniciarAutomaticamente: false, - ); - await estadoUno.inicializar(); - await estadoUno.reproducir(emisora); - await estadoUno.detenerReproduccion(); - // Lets the fire-and-forget persistence write settle before - // spinning up the "restart" instance. - await Future.delayed(Duration.zero); + // The record is now written by the audio handler's `_cambiarFuente` + // (`GuardarUltimaEmisoraPersistida`), which is the SINGLE writer of + // `ultima_emisora_v1` and the only one that also exists on the headless + // Android Auto engine — `EstadoRadio` used to write it too and no + // longer does. Seeded through that same production function here, so + // this test covers what `EstadoRadio` actually owns (the RESTORE) with + // a real payload instead of one a fake invented. The write itself is + // covered end to end in + // `test/servicios/servicio_audio_ultima_emisora_test.dart`. + await guardarUltimaEmisoraPersistida(emisora); final audioDos = FakeServicioAudio(); final estadoDos = EstadoRadio( @@ -848,9 +844,18 @@ void main() { }); test('una emisora seleccionada desde el auto (fuera de reproducir()) ' - 'también se recuerda para la próxima instancia', () async { + 'deja de estar ensombrecida por la seleccion previa del telefono', + () async { + // The PERSISTENCE half of this scenario moved to the handler, which is + // the only writer that exists on a car-only session — it is covered by + // «playFromMediaId desde el coche persiste ESA emisora» in + // `test/servicios/servicio_audio_ultima_emisora_test.dart`. What + // `EstadoRadio` still owns here, and what this test now pins, is the + // shadowing fix: a car selection bypasses `reproducir()`, so without + // the `estadoStream` listener `_emisoraSeleccionada` would keep + // shadowing the car's station on the `emisoraActual` getter. final audio = _AudioControlado(); - final estadoUno = EstadoRadio( + final estado = EstadoRadio( esPremium: () => true, audio: audio, favoritos: FakeServicioFavoritos(), @@ -859,7 +864,16 @@ void main() { resolverArchivoCustom: _archivoCustomVacio, iniciarAutomaticamente: false, ); - await estadoUno.inicializar(); + await estado.inicializar(); + final desdeElTelefono = emisoraDemo( + uuid: 'phone-picked', + nombre: 'Elegida en el telefono', + ); + unawaited(estado.reproducir(desdeElTelefono)); + audio.completar(desdeElTelefono.uuid); + await Future.delayed(Duration.zero); + expect(estado.emisoraActual?.uuid, desdeElTelefono.uuid); + final desdeCoche = emisoraDemo( uuid: 'auto-remembered', nombre: 'Recordada desde el auto', @@ -867,18 +881,14 @@ void main() { audio.seleccionarDesdeAuto(desdeCoche); await Future.delayed(Duration.zero); - final estadoDos = EstadoRadio( - esPremium: () => true, - audio: FakeServicioAudio(), - favoritos: FakeServicioFavoritos(), - radio: FakeServicioRadio(), - servicioEcualizador: FakeServicioEcualizador(), - resolverArchivoCustom: _archivoCustomVacio, - iniciarAutomaticamente: false, + expect( + estado.emisoraActual?.uuid, + desdeCoche.uuid, + reason: + 'the car changed the station without going through reproducir(); ' + 'the phone UI must follow it instead of keeping the previous ' + 'selection on screen', ); - await estadoDos.inicializar(); - - expect(estadoDos.emisoraActual?.uuid, desdeCoche.uuid); }); }); } diff --git a/test/servicios/servicio_audio_ultima_emisora_test.dart b/test/servicios/servicio_audio_ultima_emisora_test.dart new file mode 100644 index 0000000..1c86147 --- /dev/null +++ b/test/servicios/servicio_audio_ultima_emisora_test.dart @@ -0,0 +1,507 @@ +import 'dart:async'; + +import 'package:audio_service/audio_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:pluriwave/modelos/emisora.dart'; +import 'package:pluriwave/servicios/emisoras_destacadas.dart'; +import 'package:pluriwave/servicios/servicio_audio.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../helpers/handlers_audio.dart'; + +/// Resuming the last station in Android Auto — the three defects that made a +/// car-only session unable to remember, restart or even NAME what it was +/// playing. +/// +/// Every test here runs with NO widget tree and NO browse source registered: +/// that is the engine Android Auto actually starts +/// (`AudioServicePlugin.java:75-111` builds `new FlutterEngine(context)` with +/// no Activity), so `EstadoRadio` — the only thing that used to write +/// `ultima_emisora_v1` — is never constructed there. +/// +/// A1. The last station was written EXCLUSIVELY by `EstadoRadio`, so a +/// session that happened only in the car never updated the key and the +/// head unit was offered the station from the last time the PHONE was +/// used. The same key feeds `resolverEmisorasDestacadas`, so the free +/// tier's featured folder was stale too. +/// +/// A2. `play()` with no source called `_player.play()`, and +/// `just_audio.dart:937-967` publishes `_playingSubject.add(true)` +/// BEFORE the `_audioSource != null` gate — so the platform was never +/// touched, the returned Future never completed, and `playing: true` +/// was forwarded over `processingState: idle`. +/// `AudioService.java:559-560` then runs `enterPlayingState()` while +/// `getPlaybackState()` is `STATE_NONE`: a notification with a pause +/// button, no audio, no title and no artwork (or a +/// `ForegroundServiceStartNotAllowedException` on API 31+). +/// +/// A3. `mediaItem` was null on a cold start — the only `mediaItem.add` sites +/// are the duration update, `_cambiarFuente` and `stop` — so +/// `audio_service.dart:1029-1033` returned before `setMediaItem` and the +/// native side got no metadata at all. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final crearHandler = registrarHandlersLiberables(); + + late _GuionReproductor guion; + + /// The free set's first station: resolvable from the binary alone, so it + /// works on a bind where no browse source was ever registered — which is + /// the whole point of these tests. + const emisoraFip = Emisora( + uuid: 'pw-destacada-fip', + nombre: 'FIP', + url: 'https://icecast.radiofrance.fr/fip-midfi.mp3', + pais: 'France', + codigoPais: 'FR', + idioma: 'french', + ); + + setUp(() { + guion = _GuionReproductor(); + PluriWaveAudioHandler.fabricaReproductorPrueba = + (pipeline, carga) => _ReproductorFalso(guion, pipeline, carga); + // Fresh install = free tier (`esPremiumPersistido` is `getBool(...) ?? + // false`) and no `ultima_emisora_v1`. + SharedPreferences.setMockInitialValues({}); + }); + + tearDown(() { + PluriWaveAudioHandler.fabricaReproductorPrueba = null; + }); + + group('A1 — el coche escribe la ultima emisora', () { + test( + 'playFromMediaId desde el coche persiste ESA emisora por el puerto ' + 'inyectado, sin arbol de widgets', + () async { + final prefs = await SharedPreferences.getInstance(); + final handler = crearHandler(); + final guardadas = []; + registrarHandler( + handler, + guardarUltimaEmisora: (emisora) async { + guardadas.add(emisora); + await guardarUltimaEmisoraPersistida(emisora, prefs: prefs); + }, + ); + + await handler.playFromMediaId('emisora:${emisoraFip.uuid}'); + await pumpEventQueue(); + + expect( + guardadas.map((e) => e.uuid), + [emisoraFip.uuid], + reason: + 'a car-only session must update `ultima_emisora_v1` itself — ' + '`EstadoRadio` is never built on a headless engine', + ); + final persistida = await ultimaEmisoraPersistida(prefs: prefs); + expect(persistida?.uuid, emisoraFip.uuid); + expect( + persistida?.url, + emisoraFip.url, + reason: + 'the record has to be PLAYABLE: it is what the recent root and ' + '`resolverEmisorasDestacadas` hand back to the head unit', + ); + }, + ); + + test('playMediaItem directo (voz, telefono) persiste igual', () async { + final handler = crearHandler(); + final guardadas = []; + registrarHandler( + handler, + guardarUltimaEmisora: (emisora) async => guardadas.add(emisora), + ); + + await handler.playMediaItem( + const MediaItem( + id: 'https://ejemplo/stream', + title: 'Ejemplo', + extras: {'uuid': 'uuid-ejemplo'}, + ), + ); + await pumpEventQueue(); + + expect(guardadas.map((e) => e.uuid), ['uuid-ejemplo']); + expect(guardadas.single.url, 'https://ejemplo/stream'); + }); + + test( + 'una pista local NO se persiste como ultima emisora', + () async { + final handler = crearHandler(); + final guardadas = []; + registrarHandler( + handler, + guardarUltimaEmisora: (emisora) async => guardadas.add(emisora), + ); + + await handler.playMediaItem( + const MediaItem( + id: 'content://media/audio/7', + title: 'Pista local', + extras: {'documentId': 'doc-7'}, + ), + ); + await pumpEventQueue(); + + expect( + guardadas, + isEmpty, + reason: + '`ultima_emisora_v1` feeds the recent root and the featured ' + 'folder as an `emisora:` row — a `content://` track ' + 'there is a row that does nothing when tapped', + ); + }, + ); + + test('un fallo del puerto se traza y NUNCA propaga', () async { + final handler = crearHandler(); + registrarHandler( + handler, + guardarUltimaEmisora: (_) async => throw StateError('sin disco'), + ); + + await expectLater( + handler.playMediaItem( + const MediaItem( + id: 'https://ejemplo/stream', + title: 'Ejemplo', + extras: {'uuid': 'uuid-ejemplo'}, + ), + ), + completes, + ); + await pumpEventQueue(); + }); + + test('sin puerto (tests de widget, fakes) no pasa nada', () async { + final handler = crearHandler(); + registrarHandler(handler); + + await expectLater( + handler.playMediaItem( + const MediaItem( + id: 'https://ejemplo/stream', + title: 'Ejemplo', + extras: {'uuid': 'uuid-ejemplo'}, + ), + ), + completes, + ); + }); + }); + + group('A2 — play() sin fuente no publica una sesion fantasma', () { + test( + 'con una emisora persistida, play() resuelve y arranca ESA emisora: el ' + 'reproductor recibe su url', + () async { + final handler = crearHandler(); + registrarHandler( + handler, + leerUltimaEmisora: () async => emisoraFip, + ); + await pumpEventQueue(); + + unawaited(handler.play().catchError((_) {})); + await pumpEventQueue(); + + expect( + guion.urlsSolicitadas, + contains(emisoraFip.url), + reason: + '`AudioService.java:920` routes the car KEYCODE_MEDIA_PLAY ' + 'straight into play(); on a cold engine there is no source, so ' + 'it has to resolve the persisted station instead', + ); + }, + ); + + test( + 'y NINGUN estado publicado lleva playing:true sobre processingState ' + 'idle', + () async { + final handler = crearHandler(); + registrarHandler( + handler, + leerUltimaEmisora: () async => emisoraFip, + ); + await pumpEventQueue(); + + final fantasmas = []; + final sub = handler.playbackState.listen((estado) { + if (estado.playing && + estado.processingState == AudioProcessingState.idle) { + fantasmas.add(estado); + } + }); + + unawaited(handler.play().catchError((_) {})); + await pumpEventQueue(); + await sub.cancel(); + + expect( + fantasmas, + isEmpty, + reason: + 'playing:true over idle is what makes `AudioService.java:559` ' + 'call enterPlayingState() with STATE_NONE — a PluriWave ' + 'notification with a pause button, no audio and no title', + ); + }, + ); + + test( + 'sin nada persistido: no se toca el reproductor, no hay estado ' + 'fantasma y play() no se queda colgado', + () async { + final handler = crearHandler(); + registrarHandler(handler, leerUltimaEmisora: () async => null); + await pumpEventQueue(); + + final fantasmas = []; + final sub = handler.playbackState.listen((estado) { + if (estado.playing && + estado.processingState == AudioProcessingState.idle) { + fantasmas.add(estado); + } + }); + + await expectLater( + handler.play().timeout(const Duration(seconds: 2)), + completes, + ); + await pumpEventQueue(); + await sub.cancel(); + + expect( + guion.llamadasPlay, + 0, + reason: + 'with nothing to restore the player must not be touched at ' + 'all: `just_audio` publishes playing:true before its source ' + 'gate and never completes the future it returns', + ); + expect(guion.llamadasSetUrl, 0); + expect(fantasmas, isEmpty); + }, + ); + + test( + 'con una fuente ya abierta, play() sigue siendo la reanudacion de ' + 'siempre (pausa -> play no reabre nada)', + () async { + final handler = crearHandler(); + registrarHandler( + handler, + leerUltimaEmisora: () async => emisoraFip, + ); + await handler.playMediaItem( + const MediaItem( + id: 'https://ejemplo/stream', + title: 'Ejemplo', + extras: {'uuid': 'uuid-ejemplo'}, + ), + ); + await pumpEventQueue(); + await handler.pause(); + final urlsAntes = List.from(guion.urlsSolicitadas); + + await handler.play(); + await pumpEventQueue(); + + expect( + guion.urlsSolicitadas, + urlsAntes, + reason: + 'a resume must NOT re-open the source, and must never replace ' + 'the live station with the persisted one', + ); + expect(handler.intencionReproducir, isTrue); + }, + ); + }); + + group('A3 — arranque en frio: el coche recibe metadatos', () { + test( + 'con una emisora persistida se publica su mediaItem SIN arrancar ' + 'reproduccion', + () async { + final handler = crearHandler(); + + registrarHandler( + handler, + leerUltimaEmisora: () async => emisoraFip, + ); + await pumpEventQueue(); + + expect( + handler.mediaItem.value, + isNotNull, + reason: + '`audio_service.dart:1029-1033` returns before setMediaItem ' + 'when mediaItem is null, so a cold engine sent the head unit ' + 'no metadata whatsoever', + ); + expect(handler.mediaItem.value?.id, emisoraFip.url); + expect(handler.playbackState.value.playing, isFalse); + expect( + guion.llamadasSetUrl, + 0, + reason: + 'publishing metadata must not open a stream: a cold bind ' + 'happens on every reconnect and must stay silent', + ); + }, + ); + + test('sin nada persistido el mediaItem sigue vacio', () async { + final handler = crearHandler(); + + registrarHandler(handler, leerUltimaEmisora: () async => null); + await pumpEventQueue(); + + expect(handler.mediaItem.value, isNull); + }); + + test( + 'una emisora que ya empezo a sonar NO es pisada por la siembra', + () async { + final handler = crearHandler(); + final lectura = Completer(); + + registrarHandler(handler, leerUltimaEmisora: () => lectura.future); + await handler.playMediaItem( + const MediaItem( + id: 'https://enVivo/stream', + title: 'En vivo', + extras: {'uuid': 'uuid-en-vivo'}, + ), + ); + lectura.complete(emisoraFip); + await pumpEventQueue(); + + expect( + handler.mediaItem.value?.id, + 'https://enVivo/stream', + reason: + 'the seed exists to fill a VOID; clobbering the live station ' + 'would rename what the driver is listening to', + ); + }, + ); + }); + + group('getMediaItem resuelve tambien el set destacado', () { + test( + 'sin fuente de navegacion registrada, una emisora destacada resuelve', + () async { + final handler = crearHandler(); + registrarHandler(handler); + + final item = await handler.getMediaItem('emisora:${emisoraFip.uuid}'); + + expect( + item, + isNotNull, + reason: + '`porUuid` already falls back to the featured set, so the car ' + 'could BROWSE a featured station and not resolve its media ' + 'item — the asymmetry is the bug', + ); + expect(item?.id, 'emisora:${emisoraFip.uuid}'); + expect(item?.title, emisoraFip.nombre); + }, + ); + + test('un id que no es de emisora sigue devolviendo null', () async { + final handler = crearHandler(); + registrarHandler(handler); + + expect(await handler.getMediaItem('pista:doc-1'), isNull); + expect(await handler.getMediaItem('emisora:'), isNull); + }); + }); +} + +/// Shared script/observation record for every [_ReproductorFalso] the handler +/// builds (it rebuilds its player on every source change, so counters cannot +/// live on the instance). +class _GuionReproductor { + int llamadasPlay = 0; + int llamadasSetUrl = 0; + final urlsSolicitadas = []; +} + +/// An [AudioPlayer] double that reproduces the ONE `just_audio` behaviour +/// defect A2 is about: `play()` (`just_audio.dart:937-967`) publishes +/// `playing: true` BEFORE the `_audioSource != null` gate, and with no source +/// it never touches the platform and never completes the future it returned. +class _ReproductorFalso extends AudioPlayer { + _ReproductorFalso( + this._guion, + AudioPipeline pipeline, + AudioLoadConfiguration carga, + ) : super(audioPipeline: pipeline, audioLoadConfiguration: carga); + + final _GuionReproductor _guion; + final _estados = StreamController.broadcast(); + + /// A fresh player has no source, exactly like the real one. + bool _fuenteCargada = false; + + @override + Stream get playerStateStream => _estados.stream; + + @override + Future setUrl( + String url, { + Map? headers, + Duration? initialPosition, + bool preload = true, + dynamic tag, + }) async { + _guion.llamadasSetUrl++; + _guion.urlsSolicitadas.add(url); + _fuenteCargada = true; + return null; + } + + @override + Future play() { + _guion.llamadasPlay++; + if (!_estados.isClosed) { + _estados.add( + PlayerState( + true, + _fuenteCargada ? ProcessingState.ready : ProcessingState.idle, + ), + ); + } + // The dangling future: with no source, upstream `play()` awaits a + // `_playingSubject` transition the platform will never produce. + if (!_fuenteCargada) return Completer().future; + return Future.value(); + } + + @override + Future pause() async {} + + @override + Future stop() async {} + + @override + Future setVolume(double volume) async {} + + @override + Future dispose() async { + await _estados.close(); + } +}