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`', ); }); }