fix(audio): stop emitting a transient idle during a source change

Root cause of the disappearing media notification, and it is self-inflicted
on EVERY Android version — no plugin patch involved.

`audio_service`'s `_observePlaybackState` (audio_service.dart:1131-1136) calls
`AudioService._stop()` on ANY transition into `idle` from a non-idle state.
That reaches `stopService()` -> `deactivateMediaSession()` ->
`notificationManager.cancel(NOTIFICATION_ID)`. The notification is re-posted
at exactly one place, `internalStartForeground()`, reachable only from the
`!wasPlaying && playing` edge in `setState()`, and its FIRST statement is
`ContextCompat.startForegroundService(...)` — which on API 31+ throws
`ForegroundServiceStartNotAllowedException` whenever the process is not in a
foreground state.

Every station change walked straight into that. `_cambiarFuente` pushes
`loading`, then `_recrearPlayer` disposes the old `AudioPlayer` and builds a
FRESH one; a fresh player's first `playerStateStream` event is always `idle`,
and the listener forwarded it verbatim. So `loading -> idle` tore the
foreground service down mid-source-change, and recovery depended on the
following `playing: true` edge restarting it. Screen off, lock screen, or an
Android Auto / Bluetooth-initiated start is precisely where the platform
refuses that restart: audio keeps playing, the notification never returns.
That is exactly what the user reports.

The mapping decision moves out of the private `_mapProcState` into a pure
top-level `mapearEstadoProceso(proc, {required bool cambiandoFuente})`, so the
one line that decides whether the foreground service dies is unit-testable
without instantiating the handler (which needs MethodChannels). It is
byte-for-byte identical to the old switch in every case except `idle` while a
source change is in flight, which now maps to `loading`. The test asserts the
full ProcessingState x cambiandoFuente matrix against a literal transcription
of the previous mapping, and asserts both directions explicitly: a real stop
still yields `idle`, a source-change idle yields `loading`, and `idle` is the
only case where the two branches differ at all.

The only risk this introduces is a `_cambiandoFuente` stuck at `true`: a real
user stop would be masked away from `idle`, the service would never stop, and
the notification would become unkillable. So the flag is cleared by four
independent mechanisms rather than one audited path:

  - a `finally` around the whole body of `_cambiarFuente`, which covers normal
    completion, BOTH `revision != _revisionFuente` early returns, every
    `rethrow` out of a catch clause, and any non-`Exception` `Error` that none
    of the three clauses matches;
  - eagerly at the top of each of the three catch clauses — needed on top of
    the `finally` because `_gestionarErrorReproduccion` calls `_player.stop()`
    WITHOUT awaiting it, so that `idle` could otherwise land while the mask
    was still up;
  - right after `setUrl` resolves, before anything below can await, since the
    fresh player's transient `idle` is already behind us at that point;
  - at the start of `stop()` — before `_player.stop()` — and at the start of
    `_gestionarErrorReproduccion`, which makes the invariant total: the flag
    is `false` before every single `_player.stop()` call in this class.

`stop()` matters most: `BaseAudioHandler.stop()` is empty, so the handler
never pushes `idle` itself — teardown is driven entirely by the player's
emission. A stop landing while a station change was still in flight would
otherwise be masked and the notification would survive the stop.

Audited: two `_player.stop()` call sites exist and both are preceded by a
clear; `_recrearPlayer` has exactly one caller and it is guarded; the old
player cannot emit during `_recrearPlayer` because its subscriptions are
cancelled first.
This commit is contained in:
2026-08-01 19:15:53 +02:00
parent b0271fa953
commit 6da3e69f7e
2 changed files with 214 additions and 11 deletions
@@ -0,0 +1,99 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:just_audio/just_audio.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
/// fix/notificacion-media — commit 2: exhaustive matrix for
/// [mapearEstadoProceso], the pure seam extracted out of the handler's
/// private `_mapProcState`.
///
/// The handler itself cannot be instantiated in a unit test (MethodChannels),
/// which is exactly why the mapping decision was lifted out: the one line
/// that decides whether `audio_service` tears the foreground service — and
/// with it the media notification — down is now testable on its own.
void main() {
/// The pre-change `_mapProcState` mapping, transcribed literally. Every
/// `cambiandoFuente: false` expectation below is checked against THIS, so a
/// future edit to the production switch that alters any non-masked case
/// fails here instead of silently changing playback semantics.
const mapeoHeredado = <ProcessingState, AudioProcessingState>{
ProcessingState.idle: AudioProcessingState.idle,
ProcessingState.loading: AudioProcessingState.loading,
ProcessingState.buffering: AudioProcessingState.buffering,
ProcessingState.ready: AudioProcessingState.ready,
ProcessingState.completed: AudioProcessingState.completed,
};
group('mapearEstadoProceso', () {
test('la tabla heredada cubre TODOS los ProcessingState — si just_audio '
'anade uno nuevo, este test cae antes que la matriz', () {
expect(mapeoHeredado.keys, containsAll(ProcessingState.values));
expect(ProcessingState.values, hasLength(mapeoHeredado.length));
});
group('cambiandoFuente: false (sin cambio de fuente en vuelo)', () {
for (final proc in ProcessingState.values) {
test('$proc mapea a ${mapeoHeredado[proc]}, igual que antes', () {
expect(
mapearEstadoProceso(proc, cambiandoFuente: false),
mapeoHeredado[proc],
);
});
}
});
group('cambiandoFuente: true (cambio de emisora en vuelo)', () {
for (final proc in ProcessingState.values) {
final esperado =
proc == ProcessingState.idle
? AudioProcessingState.loading
: mapeoHeredado[proc];
test('$proc mapea a $esperado', () {
expect(mapearEstadoProceso(proc, cambiandoFuente: true), esperado);
});
}
});
// ── Las dos direcciones, explicitas ──────────────────────────────────
test('un stop REAL sigue produciendo idle: `stop()` limpia la bandera '
'antes de `_player.stop()`, asi que audio_service puede seguir '
'cerrando el foreground service', () {
expect(
mapearEstadoProceso(ProcessingState.idle, cambiandoFuente: false),
AudioProcessingState.idle,
);
});
test('el idle transitorio del player recien creado produce loading: la '
'notificacion no se cancela a mitad de un cambio de emisora', () {
expect(
mapearEstadoProceso(ProcessingState.idle, cambiandoFuente: true),
AudioProcessingState.loading,
);
});
test('la mascara NO toca ningun estado distinto de idle', () {
for (final proc in ProcessingState.values) {
if (proc == ProcessingState.idle) continue;
expect(
mapearEstadoProceso(proc, cambiandoFuente: true),
mapearEstadoProceso(proc, cambiandoFuente: false),
reason: '$proc debe mapear igual con y sin cambio de fuente en vuelo',
);
}
});
test('idle es el UNICO caso en el que las dos ramas difieren', () {
final distintos =
ProcessingState.values
.where(
(proc) =>
mapearEstadoProceso(proc, cambiandoFuente: true) !=
mapearEstadoProceso(proc, cambiandoFuente: false),
)
.toList();
expect(distintos, [ProcessingState.idle]);
});
});
}