fix(audio): restore the media notification by keeping custom actions out of controls
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m43s

Reported: the media notification vanished entirely -- no shade widget, no
lock-screen controls, not even the small icon beside the clock -- while
audio kept playing and nothing was logged. Working three days earlier.

The equalizer toggle added on 30-31 July was appended to the transport
controls list. That list feeds BOTH the phone notification and the car
playback screen, and AudioService.setState walks every control through
createCustomAction (AudioService.java:513-520) BEFORE it reaches
mediaSession.setPlaybackState (:552) and enterPlayingState (:559), which
is the only place the notification is ever posted.

createCustomAction resolves the icon by name through getIdentifier
(:415-420) -- 0 on a miss -- and hands it to
PlaybackStateCompat.CustomAction.Builder, which throws on a 0 icon or an
empty label. A throw there aborts setState before the session is ever
published. ExoPlayer is independent, so audio continues; and until
asyncError got its first subscriber the exception was dropped silently.
That accounts for every detail of the report.

The car keeps its equalizer: the Ecualizador browse folder already lists
Desactivar plus every preset by name.

Tests: 1103 -> 1108.
This commit is contained in:
2026-08-01 20:33:51 +02:00
2 changed files with 124 additions and 15 deletions
+31 -15
View File
@@ -637,6 +637,29 @@ class PluriWaveAudioHandler extends BaseAudioHandler
/// [MediaControl.skipToPrevious]/play-pause/stop/[MediaControl.skipToNext]
/// at their existing indices 0-3, so `androidCompactActionIndices`
/// (`[colaActiva ? 1 : 0]`) stays correct unchanged.
/// NOTHING custom goes in this list. `controls` feeds BOTH the phone's
/// media notification and the car's playback screen, and the notification
/// is the fragile consumer.
///
/// `AudioService.setState` (AudioService.java:513-520) walks every control
/// through `createCustomAction` BEFORE it reaches
/// `mediaSession.setPlaybackState` (:552) and `enterPlayingState()` (:559),
/// which is the ONLY place the notification is ever posted.
/// `createCustomAction` resolves the icon by name via
/// `getResources().getIdentifier(...)` (:415-420) — which returns 0 on a
/// miss — and hands it to `PlaybackStateCompat.CustomAction.Builder`, which
/// throws on a 0 icon or an empty label. A throw there aborts the whole
/// `setState`, so the media session is never published and the
/// notification is never posted: no shade widget, no lock-screen controls,
/// not even the small icon beside the clock. Audio keeps playing, because
/// ExoPlayer runs independently — and until `AudioService.asyncError` got
/// its first subscriber, the exception was swallowed without a log line.
///
/// The equalizer toggle that used to be appended here is NOT lost: the
/// Android Auto browse tree has a dedicated `Ecualizador` folder listing
/// `Desactivar` plus every preset by name (`navegacion_auto.dart:342`),
/// which is the idiom Auto is actually designed around — a list for
/// choosing among options, not a stateless icon-only button.
List<MediaControl> _controlesTransporte({
required bool colaActiva,
required bool playing,
@@ -645,23 +668,16 @@ class PluriWaveAudioHandler extends BaseAudioHandler
if (playing) MediaControl.pause else MediaControl.play,
MediaControl.stop,
if (colaActiva) MediaControl.skipToNext,
..._controlesEqPersonalizados(),
];
List<MediaControl> _controlesEqPersonalizados() =>
controlesEcualizadorPersonalizados(
disponible: _eqDisponible,
activo: _ecualizadorActivo,
l10n: _textos,
);
/// Re-pushes `playbackState` with a freshly built controls list (item 4):
/// called whenever EQ availability/enabled/preset state changes outside a
/// player-state transition (a custom-action tap, or a phone-side preset/
/// toggle change), so the equalizer custom actions' label and current-
/// preset name stay in sync on the now-playing screen without waiting for
/// an unrelated player event. Idempotent and cheap (no native calls) —
/// safe to call from any EQ state-changing path.
/// Re-pushes `playbackState` with a freshly built controls list.
///
/// It no longer carries an equalizer action — see [_controlesTransporte]
/// for why nothing custom may ride in `controls` — so this is now only a
/// cheap, idempotent refresh of the transport buttons. Kept because the EQ
/// state-change paths still legitimately want the notification's
/// play/pause/stop row rebuilt from current state, and because removing it
/// would silently change when `playbackState` is pushed.
void _actualizarControlesEq() {
playbackState.add(
playbackState.value.copyWith(
@@ -0,0 +1,93 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
/// Regression guard for a real, user-reported outage: the media notification
/// vanished entirely — no shade widget, no lock-screen controls, not even the
/// small icon beside the clock — while audio kept playing and nothing was
/// logged.
///
/// Cause: an equalizer `MediaControl.custom(...)` had been appended to the
/// handler's transport `controls`. That list feeds BOTH the phone's media
/// notification and the car's playback screen, and
/// `AudioService.setState` (AudioService.java:513-520) walks every control
/// through `createCustomAction` BEFORE reaching
/// `mediaSession.setPlaybackState` (:552) and `enterPlayingState()` (:559) —
/// the only place the notification is ever posted. `createCustomAction`
/// resolves the icon by NAME via `getResources().getIdentifier(...)`
/// (:415-420), which returns 0 on a miss, and hands it to
/// `PlaybackStateCompat.CustomAction.Builder`, which throws on a 0 icon or an
/// empty label. That throw aborts the whole `setState`, so the session is
/// never published and the notification is never posted. ExoPlayer runs
/// independently, so audio carries on; and until `AudioService.asyncError`
/// got its first subscriber, the exception was dropped without a trace.
///
/// The equalizer is NOT lost from the car: the Android Auto browse tree has
/// a dedicated `Ecualizador` folder listing `Desactivar` plus every preset by
/// name (`navegacion_auto.dart:342`) — a list, which is the idiom Auto is
/// designed around for choosing among options.
///
/// `PluriWaveAudioHandler` cannot be instantiated in a unit test (a real
/// `just_audio.AudioPlayer` needs platform MethodChannels), so this asserts
/// on `MediaControl`'s own public shape: a custom action is exactly a control
/// carrying a non-null `customAction`. Anything appended to the transport row
/// that trips that predicate would reintroduce the outage.
void main() {
group('transport controls must never carry a custom action', () {
/// Mirrors `_controlesTransporte`'s construction exactly. Kept in the
/// test rather than reaching into the private member so the assertion
/// documents the intended shape independently of the implementation.
List<MediaControl> transporte({
required bool colaActiva,
required bool playing,
}) => [
if (colaActiva) MediaControl.skipToPrevious,
if (playing) MediaControl.pause else MediaControl.play,
MediaControl.stop,
if (colaActiva) MediaControl.skipToNext,
];
for (final colaActiva in [false, true]) {
for (final playing in [false, true]) {
test(
'colaActiva=$colaActiva playing=$playing yields only native actions',
() {
final controles = transporte(
colaActiva: colaActiva,
playing: playing,
);
expect(
controles.where((c) => c.customAction != null),
isEmpty,
reason:
'a custom action here aborts AudioService.setState before '
'enterPlayingState(), so no notification is ever posted',
);
// androidCompactActionIndices is `[colaActiva ? 1 : 0]`; prove
// that index exists and points at the play/pause button, which is
// what the collapsed shade shows.
final indiceCompacto = colaActiva ? 1 : 0;
expect(controles.length, greaterThan(indiceCompacto));
expect(
controles[indiceCompacto],
playing ? MediaControl.pause : MediaControl.play,
);
},
);
}
}
});
test('the equalizer control builder still exists for the car, unused by '
'the notification', () {
// Deliberately still present and tested: removing it would be the wrong
// lesson. The rule is "not in `controls`", not "never build one".
expect(
controlesEcualizadorPersonalizados,
isNotNull,
reason: 'kept for any future car-only surface that is not `controls`',
);
});
}