diff --git a/lib/estado/estado_radio.dart b/lib/estado/estado_radio.dart index 5d8a57f..5bf7e08 100644 --- a/lib/estado/estado_radio.dart +++ b/lib/estado/estado_radio.dart @@ -173,6 +173,10 @@ class EstadoRadio extends ChangeNotifier { static const _keyEmisoraPreferida = 'emisora_preferida_uuid_v1'; static const _keyOrdenListas = 'orden_listas_emisoras_v1'; static const _keyTimerSuenoPresets = 'timer_sueno_presets_segundos_v1'; + // Issue 4 (feedback-pruebas): last-played station, so the Escuchar hero + // keeps showing "what I was listening to" (stopped, not playing) after a + // full app restart instead of going empty. + static const _keyUltimaEmisora = 'ultima_emisora_v1'; static const _timerSuenoPresetsDefecto = [ 180, 300, @@ -300,6 +304,50 @@ class EstadoRadio extends ChangeNotifier { _cargarEmisorasCustom(), ]); await _normalizarEmisoraPreferida(); + await _restaurarUltimaEmisora(); + } + + /// Issue 4 (feedback-pruebas): restores the last-played station as a + /// STOPPED `emisoraActual` on a cold start. Only fills the gap — if + /// something is ALREADY selected (a real play already ran concurrently), + /// this is a no-op. Never touches `audio`: no playback starts, no network + /// request is made, `estadoStream`/`estaSonando` stay at their fresh + /// "detenido" default, exactly like every other consumer of + /// `emisoraActual` already expects (they gate "is it playing" on the + /// separate playback-status stream, never on `emisoraActual != null`). + Future _restaurarUltimaEmisora() async { + if (_emisoraSeleccionada != null || audio.emisoraActual != null) return; + try { + final prefs = await _resolverPrefs(); + final raw = prefs.getString(_keyUltimaEmisora); + if (raw == null) return; + final mapa = jsonDecode(raw) as Map; + _emisoraSeleccionada = Emisora.fromMap(mapa); + } catch (e) { + registrarSaltoPersistencia( + subsistema: 'ultima_emisora', + detalle: 'restaurar', + razon: e.toString(), + ); + } + } + + /// 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. @@ -321,6 +369,9 @@ 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)); } notifyListeners(); }); @@ -508,6 +559,10 @@ 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)); try { await audio.reproducir(emisora); if (revision != _revisionReproduccion) return; diff --git a/test/estado/estado_radio_test.dart b/test/estado/estado_radio_test.dart index 96ffa72..f8d2f76 100644 --- a/test/estado/estado_radio_test.dart +++ b/test/estado/estado_radio_test.dart @@ -412,259 +412,314 @@ void main() { ); }); - group( - 'EstadoRadio — emisoras custom: lectura tolerante y guardia de ' - 'degradacion (persistence-resilience)', - () { - test( - 'entradas invalidas se omiten sin perder las validas ni fabricar ' - 'uuid (D5 parcial)', - () async { - final archivo = await _crearArchivoCustomRaw( - jsonEncode([ - {'uuid': 'custom-1', 'nombre': 'Valida Uno', 'url': 'http://a'}, - {'uuid': 'custom-2', 'nombre': 'Valida Dos', 'url': 'http://b'}, - // falta 'url' (campo requerido) -> Emisora.fromMap lanza. - {'uuid': 'custom-3', 'nombre': 'Sin url'}, - // falta 'uuid' -> Emisora.fromMap lanza. - {'nombre': 'Sin uuid', 'url': 'http://d'}, - ]), - ); - final estado = EstadoRadio( - audio: FakeServicioAudio(), - favoritos: FakeServicioFavoritos(), - radio: FakeServicioRadio(), - servicioEcualizador: FakeServicioEcualizador(), - resolverArchivoCustom: () async => archivo, - iniciarAutomaticamente: false, - ); - - await estado.inicializar(); - - expect(estado.emisorasCustom, hasLength(2)); - expect(estado.emisorasCustom.map((e) => e.uuid).toSet(), { - 'custom-1', - 'custom-2', - }); - }, + group('EstadoRadio — emisoras custom: lectura tolerante y guardia de ' + 'degradacion (persistence-resilience)', () { + test('entradas invalidas se omiten sin perder las validas ni fabricar ' + 'uuid (D5 parcial)', () async { + final archivo = await _crearArchivoCustomRaw( + jsonEncode([ + {'uuid': 'custom-1', 'nombre': 'Valida Uno', 'url': 'http://a'}, + {'uuid': 'custom-2', 'nombre': 'Valida Dos', 'url': 'http://b'}, + // falta 'url' (campo requerido) -> Emisora.fromMap lanza. + {'uuid': 'custom-3', 'nombre': 'Sin url'}, + // falta 'uuid' -> Emisora.fromMap lanza. + {'nombre': 'Sin uuid', 'url': 'http://d'}, + ]), + ); + final estado = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: () async => archivo, + iniciarAutomaticamente: false, ); - test( - 'si resolver la ruta del archivo custom falla, la inicializacion ' - 'sobrevive y las cargas hermanas no se pierden (D5 IO-fail)', - () async { - final estado = EstadoRadio( - audio: FakeServicioAudio(), - favoritos: FakeServicioFavoritos(), - radio: FakeServicioRadio(), - servicioEcualizador: FakeServicioEcualizador(), - resolverArchivoCustom: - () async => throw const FileSystemException('sin storage'), - iniciarAutomaticamente: false, - ); + await estado.inicializar(); - // Path resolution failing must be treated as an IO-fail, not - // escape _cargarEmisorasCustom: it runs inside _init()'s - // Future.wait, so an uncaught throw would also reject the - // sibling loads (populares/favoritos/grupos). - await estado.inicializar(); + expect(estado.emisorasCustom, hasLength(2)); + expect(estado.emisorasCustom.map((e) => e.uuid).toSet(), { + 'custom-1', + 'custom-2', + }); + }); - expect(estado.emisorasCustom, isEmpty); - }, + test('si resolver la ruta del archivo custom falla, la inicializacion ' + 'sobrevive y las cargas hermanas no se pierden (D5 IO-fail)', () async { + final estado = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: + () async => throw const FileSystemException('sin storage'), + iniciarAutomaticamente: false, ); - test( - 'JSON invalido al nivel superior pone en cuarentena el archivo ' - 'original (D5 parse-fail)', - () async { - final archivo = await _crearArchivoCustomRaw('{bad'); - final estado = EstadoRadio( - audio: FakeServicioAudio(), - favoritos: FakeServicioFavoritos(), - radio: FakeServicioRadio(), - servicioEcualizador: FakeServicioEcualizador(), - resolverArchivoCustom: () async => archivo, - iniciarAutomaticamente: false, - ); + // Path resolution failing must be treated as an IO-fail, not + // escape _cargarEmisorasCustom: it runs inside _init()'s + // Future.wait, so an uncaught throw would also reject the + // sibling loads (populares/favoritos/grupos). + await estado.inicializar(); - await estado.inicializar(); + expect(estado.emisorasCustom, isEmpty); + }); - expect(estado.emisorasCustom, isEmpty); - final sidecar = File('${archivo.path}.corrupt'); - expect(await sidecar.exists(), isTrue); - expect(await sidecar.readAsString(), '{bad'); - expect(await archivo.exists(), isFalse); - }, + test('JSON invalido al nivel superior pone en cuarentena el archivo ' + 'original (D5 parse-fail)', () async { + final archivo = await _crearArchivoCustomRaw('{bad'); + final estado = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: () async => archivo, + iniciarAutomaticamente: false, ); - test( - 'agregar tras la cuarentena escribe solo la nueva emisora y no ' - 'toca el sidecar (D5, autoridad de escritura restaurada)', - () async { - final archivo = await _crearArchivoCustomRaw('{bad'); - final estado = EstadoRadio( - audio: FakeServicioAudio(), - favoritos: FakeServicioFavoritos(), - radio: FakeServicioRadio(), - servicioEcualizador: FakeServicioEcualizador(), - resolverArchivoCustom: () async => archivo, - iniciarAutomaticamente: false, - ); - await estado.inicializar(); - final sidecar = File('${archivo.path}.corrupt'); - final sidecarPrevio = await sidecar.readAsString(); + await estado.inicializar(); - final nueva = emisoraDemo(uuid: 'nueva-1', nombre: 'Nueva'); - await estado.agregarEmisoraCustom(nueva); + expect(estado.emisorasCustom, isEmpty); + final sidecar = File('${archivo.path}.corrupt'); + expect(await sidecar.exists(), isTrue); + expect(await sidecar.readAsString(), '{bad'); + expect(await archivo.exists(), isFalse); + }); - expect(estado.emisorasCustom.map((e) => e.uuid), ['nueva-1']); - final contenidoVivo = - jsonDecode(await archivo.readAsString()) as List; - expect(contenidoVivo, hasLength(1)); - expect((contenidoVivo.single as Map)['uuid'], 'nueva-1'); - expect(await sidecar.readAsString(), sidecarPrevio); - }, + test('agregar tras la cuarentena escribe solo la nueva emisora y no ' + 'toca el sidecar (D5, autoridad de escritura restaurada)', () async { + final archivo = await _crearArchivoCustomRaw('{bad'); + final estado = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: () async => archivo, + iniciarAutomaticamente: false, + ); + await estado.inicializar(); + final sidecar = File('${archivo.path}.corrupt'); + final sidecarPrevio = await sidecar.readAsString(); + + final nueva = emisoraDemo(uuid: 'nueva-1', nombre: 'Nueva'); + await estado.agregarEmisoraCustom(nueva); + + expect(estado.emisorasCustom.map((e) => e.uuid), ['nueva-1']); + final contenidoVivo = jsonDecode(await archivo.readAsString()) as List; + expect(contenidoVivo, hasLength(1)); + expect((contenidoVivo.single as Map)['uuid'], 'nueva-1'); + expect(await sidecar.readAsString(), sidecarPrevio); + }); + + test('fallo de IO al leer suprime la escritura y no se restaura con un ' + 'alta explicita (D5 IO-fail)', () async { + final espia = _ArchivoEspia( + path: '/fake/emisoras_custom.json', + exists: () async => true, + readAsString: + () async => + throw const FileSystemException('fallo simulado de lectura'), + ); + final estado = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: () async => espia, + iniciarAutomaticamente: false, ); - test( - 'fallo de IO al leer suprime la escritura y no se restaura con un ' - 'alta explicita (D5 IO-fail)', - () async { - final espia = _ArchivoEspia( - path: '/fake/emisoras_custom.json', - exists: () async => true, - readAsString: - () async => throw const FileSystemException( - 'fallo simulado de lectura', - ), - ); - final estado = EstadoRadio( - audio: FakeServicioAudio(), - favoritos: FakeServicioFavoritos(), - radio: FakeServicioRadio(), - servicioEcualizador: FakeServicioEcualizador(), - resolverArchivoCustom: () async => espia, - iniciarAutomaticamente: false, - ); + await estado.inicializar(); + expect(estado.emisorasCustom, isEmpty); - await estado.inicializar(); - expect(estado.emisorasCustom, isEmpty); - - await estado.agregarEmisoraCustom( - emisoraDemo(uuid: 'nueva-x', nombre: 'X'), - ); - - expect( - estado.emisorasCustom.map((e) => e.uuid), - contains('nueva-x'), - ); - expect(espia.writeAsStringCalls, 0); - }, + await estado.agregarEmisoraCustom( + emisoraDemo(uuid: 'nueva-x', nombre: 'X'), ); - test( - 'si ya existe un sidecar .corrupt no lo pisa, solo limpia el ' - 'archivo vivo (D5)', - () async { - final archivo = await _crearArchivoCustomRaw('{bad-nuevo'); - final sidecar = File('${archivo.path}.corrupt'); - await sidecar.writeAsString('contenido-previo-X'); + expect(estado.emisorasCustom.map((e) => e.uuid), contains('nueva-x')); + expect(espia.writeAsStringCalls, 0); + }); - final estado = EstadoRadio( - audio: FakeServicioAudio(), - favoritos: FakeServicioFavoritos(), - radio: FakeServicioRadio(), - servicioEcualizador: FakeServicioEcualizador(), - resolverArchivoCustom: () async => archivo, - iniciarAutomaticamente: false, - ); + test('si ya existe un sidecar .corrupt no lo pisa, solo limpia el ' + 'archivo vivo (D5)', () async { + final archivo = await _crearArchivoCustomRaw('{bad-nuevo'); + final sidecar = File('${archivo.path}.corrupt'); + await sidecar.writeAsString('contenido-previo-X'); - await estado.inicializar(); - - expect(await sidecar.readAsString(), 'contenido-previo-X'); - expect(await archivo.exists(), isFalse); - expect(estado.emisorasCustom, isEmpty); - }, - ); - }, - ); - - 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'), - ); - expect( - fuenteAuto.ultimoGrupos?.map((g) => g.id), - contains(GrupoFavoritos.sinAsignarId), - ); - - await estado.toggleFavorito(emisoraFav); - - expect( - fuenteAuto.ultimoFavoritos?.map((e) => e.uuid), - contains('fav-auto-1'), - ); - }, + final estado = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: () async => archivo, + iniciarAutomaticamente: false, ); - 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', - ); + await estado.inicializar(); - audio.seleccionarDesdeAuto(desdeCoche); - await Future.delayed(Duration.zero); + expect(await sidecar.readAsString(), 'contenido-previo-X'); + expect(await archivo.exists(), isFalse); + expect(estado.emisorasCustom, isEmpty); + }); + }); - expect(estado.emisoraActual?.uuid, desdeCoche.uuid); - }, + 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'), + ); + expect( + fuenteAuto.ultimoGrupos?.map((g) => g.id), + contains(GrupoFavoritos.sinAsignarId), + ); + + 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); + }); + }); + + group('EstadoRadio — última emisora reproducida (feedback-pruebas #4)', () { + test('la última emisora reproducida sobrevive a una nueva instancia ' + '(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( + 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); + + final audioDos = FakeServicioAudio(); + final estadoDos = EstadoRadio( + audio: audioDos, + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: _archivoCustomVacio, + iniciarAutomaticamente: false, + ); + + await estadoDos.inicializar(); + + expect(estadoDos.emisoraActual?.uuid, emisora.uuid); + expect(estadoDos.emisoraActual?.nombre, emisora.nombre); + expect( + audioDos.estaSonando, + isFalse, + reason: 'restoring the last station must never auto-start audio', + ); + }); + + test('sin ninguna emisora previamente reproducida, emisoraActual sigue ' + 'siendo null tras inicializar (instalación nueva)', () async { + final estado = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: _archivoCustomVacio, + iniciarAutomaticamente: false, + ); + + await estado.inicializar(); + + expect(estado.emisoraActual, isNull); + }); + + test('una emisora seleccionada desde el auto (fuera de reproducir()) ' + 'también se recuerda para la próxima instancia', () async { + final audio = _AudioControlado(); + final estadoUno = EstadoRadio( + audio: audio, + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: _archivoCustomVacio, + iniciarAutomaticamente: false, + ); + await estadoUno.inicializar(); + final desdeCoche = emisoraDemo( + uuid: 'auto-remembered', + nombre: 'Recordada desde el auto', + ); + audio.seleccionarDesdeAuto(desdeCoche); + await Future.delayed(Duration.zero); + + final estadoDos = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: _archivoCustomVacio, + iniciarAutomaticamente: false, + ); + await estadoDos.inicializar(); + + expect(estadoDos.emisoraActual?.uuid, desdeCoche.uuid); + }); + }); } /// Spy [FuenteEmisorasAuto] that only records the last snapshot pushed by