diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart index 1c11424..895ca61 100644 --- a/lib/servicios/servicio_audio.dart +++ b/lib/servicios/servicio_audio.dart @@ -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 _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 _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( diff --git a/test/servicios/servicio_audio_controles_notificacion_test.dart b/test/servicios/servicio_audio_controles_notificacion_test.dart new file mode 100644 index 0000000..a037ff6 --- /dev/null +++ b/test/servicios/servicio_audio_controles_notificacion_test.dart @@ -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 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`', + ); + }); +}