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:
@@ -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]);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user