Files
pluriwave/test/servicios/servicio_audio_ultima_emisora_test.dart
T
FreeTLab 86dd20b184 fix: el coche recuerda la ultima emisora y deja de publicar una sesion fantasma
Tres defectos preexistentes alrededor de la reanudacion en Android Auto. Ninguno
es una regresion: el consumidor (la raiz `recent`) se añadio en septiembre y es
lo que dejo el hueco a la vista.

La ultima emisora solo la escribia el telefono

La clave `ultima_emisora_v1` tenia como unico escritor a
`EstadoRadio._persistirUltimaEmisora`, y `EstadoRadio` solo existe si hay arbol
de widgets. El motor que arranca Android Auto es headless de verdad, asi que una
sesion que ocurriera solo en el coche jamas actualizaba la clave y al reconectar
se ofrecia la emisora de la ultima vez que se uso el movil.

El handler recibe ahora sus puertos de lectura y escritura, con la misma forma
que los del ecualizador y el contexto de salto, y escribe desde `_cambiarFuente`:
el cuello de botella por el que pasan todas las rutas -- telefono, toque en el
coche, voz, saltos, avance de cola y la propia reanudacion.

Se ELIMINA el escritor del telefono en vez de sumar un segundo. Dos escritores
independientes de la misma clave acaban divergiendo siempre; es exactamente lo
que ya costo varias rondas con el flag del ecualizador.

Las pistas locales quedan excluidas: un `content://` guardado como ultima
emisora seria una fila de reanudacion que no resuelve a nada.

play() sin fuente levantaba un servicio en primer plano vacio

just_audio publica `playing:true` antes de comprobar si hay fuente, asi que un
`play()` en frio no tocaba la plataforma pero si emitia ese estado sobre
`processingState: idle`. audio_service entraba en estado de reproduccion
mientras el estado nativo seguia en NONE: notificacion con boton de pausa, cero
audio, sin titulo ni caratula, y un Future que no se completaba nunca. El coche
enruta su tecla de play directamente ahi.

Ahora `play()` sin fuente abierta restaura la ultima emisora por la ruta normal,
y si no hay nada que restaurar no toca el reproductor ni publica nada.

En frio no habia metadatos que enseñar

El unico `mediaItem.add` util vivia dentro de `_cambiarFuente`, asi que en un
motor recien arrancado el lado nativo nunca recibia metadatos. Se siembra el
`mediaItem` de la emisora persistida sin cargar ni reproducir nada, con guarda
antes y despues de la lectura de disco para no pisar una emisora ya sonando.

`getMediaItem` resolvia solo contra el universo completo -- vacio en el motor del
coche -- mientras `porUuid` si caia en las destacadas. El coche podia navegar una
emisora destacada y luego no resolver su ficha. Ambos usan ahora la misma ruta.

Suite completa: 1529 pasan, 2 omitidos. flutter analyze mantiene los 5 avisos
preexistentes.
2026-09-06 00:10:58 +02:00

508 lines
16 KiB
Dart

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 = <Emisora>[];
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 = <Emisora>[];
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 = <Emisora>[];
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:<uuid>` 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 = <PlaybackState>[];
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 = <PlaybackState>[];
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<String>.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<Emisora?>();
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 = <String>[];
}
/// 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<PlayerState>.broadcast();
/// A fresh player has no source, exactly like the real one.
bool _fuenteCargada = false;
@override
Stream<PlayerState> get playerStateStream => _estados.stream;
@override
Future<Duration?> setUrl(
String url, {
Map<String, String>? headers,
Duration? initialPosition,
bool preload = true,
dynamic tag,
}) async {
_guion.llamadasSetUrl++;
_guion.urlsSolicitadas.add(url);
_fuenteCargada = true;
return null;
}
@override
Future<void> 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<void>().future;
return Future<void>.value();
}
@override
Future<void> pause() async {}
@override
Future<void> stop() async {}
@override
Future<void> setVolume(double volume) async {}
@override
Future<void> dispose() async {
await _estados.close();
}
}