diff --git a/lib/estado/estado_ecualizador.dart b/lib/estado/estado_ecualizador.dart index 1ce946e..a424c2a 100644 --- a/lib/estado/estado_ecualizador.dart +++ b/lib/estado/estado_ecualizador.dart @@ -663,12 +663,29 @@ class EstadoEcualizador extends ChangeNotifier { /// Each step then re-checks [_activo]: a newer tap that landed mid-flight /// owns the outcome, and this superseded call must not apply a preset or /// persist a value the user has already changed their mind about. + /// + /// The handler can also REFUSE the change: when the native `setEnabled` + /// throws, `PluriWaveAudioHandler._aplicarEcualizadorActivo` rolls its own + /// flag back and skips its persistence write, so the value we optimistically + /// published never happened. Reading [ServicioAudio.ecualizadorActivo] back + /// (the handler is the single owner of the flag — eq-estado-unico) is how we + /// learn that: on divergence we adopt the handler's real value and return + /// WITHOUT persisting, instead of showing a lie and writing a rejected value + /// to disk that would resurrect it on the next start. The supersede check + /// runs FIRST so a newer tap still owns the outcome; the read-back only + /// speaks for a call nobody overtook. Future cambiarActivo(bool activo) async { _activo = activo; notifyListeners(); await audio.setEcualizadorActivo(activo); if (_activo != activo) return; + final aceptado = audio.ecualizadorActivo; + if (aceptado != activo) { + _activo = aceptado; + notifyListeners(); + return; + } if (activo) { await audio.aplicarPreset(_presetActual); if (_activo != activo) return; diff --git a/lib/main.dart b/lib/main.dart index 8b88469..1430bd7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -197,6 +197,12 @@ Future main() async { handler, leerEqActivoPersistido: ecualizador.leerActivo, guardarEqActivoPersistido: ecualizador.guardarActivo, + // The PRESET's half of the same seam. Without it the handler enabled + // the equalizer with its hardcoded FLAT preset on any engine where the + // phone UI never ran — i.e. every headless Android Auto bind. There is + // no write port: `EstadoEcualizador` still owns saving presets (a car + // preset choice goes through it), so the handler only ever reads. + leerPresetPersistido: ecualizador.leerPresetPrincipal, // Skip context («in which list am I»). Bound here, on the audio // bootstrap path of EVERY engine, precisely because the headless // Android Auto engine builds no widget tree and therefore no diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart index 2f1906f..fa5eca1 100644 --- a/lib/servicios/servicio_audio.dart +++ b/lib/servicios/servicio_audio.dart @@ -56,6 +56,11 @@ typedef GuardarEqActivoPersistido = Future Function(bool activo); /// tests, fakes), which simply falls back to deriving the context on the spot. typedef LeerContextoSaltoPersistido = Future Function(); +/// Read port for the equalizer's persisted PRESET, the exact sibling of +/// [LeerEqActivoPersistido]. Bound to `ServicioEcualizador.leerPresetPrincipal` +/// in `main.dart`; `null` for any caller with no disk (widget tests, fakes). +typedef LeerPresetPersistido = Future Function(); + /// Write port for the same context. Bound to `guardarContextoSalto`. typedef GuardarContextoSaltoPersistido = Future Function(ContextoSalto contexto); @@ -86,6 +91,22 @@ bool? _eqActivoPersistido; /// equalizer on, and the app has always behaved that way. bool estadoEqInicial({required bool? persistido}) => persistido ?? true; +/// The two native operations an equalizer on/off transition is made of, as +/// values so their ORDER is a testable fact rather than the incidental shape +/// of a method body. +/// +/// Off-device neither operation is observable (`_eqDisponible` is `false`, and +/// `AndroidEqualizer.parameters` never completes without an attached player), +/// so before this enum the sequence could only be asserted by reading the +/// source — which is how the wrong one shipped. +enum PasoEcualizador { + /// Write the current preset's band levels into the native effect. + ganancias, + + /// Flip the native effect on or off (`AudioEffect.setEnabled`). + habilitacion, +} + /// Reads the persisted equalizer flag through [leer] exactly once and seeds /// [handler] with it, without ever writing back. /// @@ -110,6 +131,37 @@ Future _sembrarEcualizadorDesdeDisco( ); } +/// Reads the persisted equalizer PRESET through [leer] exactly once and seeds +/// [handler] with it. +/// +/// The exact sibling of [_sembrarEcualizadorDesdeDisco], and it exists for the +/// exact same reason. eq-estado-unico gave the on/off FLAG a UI-independent +/// link to disk; the preset never got one, so `_presetActual` stayed on its +/// hardcoded `PresetEcualizador.flat`. On a phone that is invisible — +/// `EstadoEcualizador` owns the real preset and pushes it into the handler as +/// soon as the widget tree exists. On the headless engine Android Auto starts +/// there is no widget tree and no `EstadoEcualizador`, so a car toggle +/// enabled the equalizer and applied FLAT. +/// +/// Never throws: an unreadable preference store leaves the handler on the +/// historical default rather than taking down the audio bootstrap. +Future _sembrarPresetDesdeDisco( + PluriWaveAudioHandler handler, + LeerPresetPersistido leer, +) async { + PresetEcualizador? persistido; + try { + persistido = await leer(); + } catch (e) { + debugPrint( + '[PluriWave][ServicioAudio] no se pudo leer el preset EQ persistido: $e', + ); + persistido = null; + } + if (persistido == null) return; + await handler.sembrarPresetEcualizador(persistido); +} + /// Wires the freshly built handler into the module-level seams. /// /// [leerEqActivoPersistido] and [guardarEqActivoPersistido] give the handler @@ -123,6 +175,7 @@ void registrarHandler( PluriWaveAudioHandler handler, { LeerEqActivoPersistido? leerEqActivoPersistido, GuardarEqActivoPersistido? guardarEqActivoPersistido, + LeerPresetPersistido? leerPresetPersistido, LeerContextoSaltoPersistido? leerContextoSalto, GuardarContextoSaltoPersistido? guardarContextoSalto, }) { @@ -142,6 +195,9 @@ void registrarHandler( if (leerEqActivoPersistido != null) { unawaited(_sembrarEcualizadorDesdeDisco(handler, leerEqActivoPersistido)); } + if (leerPresetPersistido != null) { + unawaited(_sembrarPresetDesdeDisco(handler, leerPresetPersistido)); + } // iap-freemium-unlock (design.md Open Questions, orchestrator-resolved), // generalizado en fix/android-auto-musica-local item 4: invalida // activamente todo id de nivel raíz que un head unit pueda tener cacheado @@ -1057,7 +1113,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler ), ); - AndroidEqualizer _eq = AndroidEqualizer(); + AndroidEqualizer _eq = _crearEq(); late AudioPlayer _player = _crearPlayer(); StreamSubscription? _estadoPlayerSub; StreamSubscription? _bufferedSub; @@ -1222,6 +1278,39 @@ class PluriWaveAudioHandler extends BaseAudioHandler bool _eqDisponible = false; bool get ecualizadorDisponible => _eqDisponible; + /// Last [AndroidEqualizerParameters] resolved by [_activarEcualizador]. + /// + /// Cached rather than re-awaited because `AndroidEqualizer.parameters` is a + /// `Completer` future that only completes when the platform player attaches + /// (`just_audio.dart` `AndroidEqualizer._activate`). Awaiting it from a + /// toggle path therefore does not "read the device", it BLOCKS until the + /// next successful load — potentially forever if that load fails — which + /// would leave the car's equalizer button pending and its icon stale. + /// `null` means "not resolved yet on this player": the gains are skipped and + /// [_activarEcualizador] pushes them as soon as the player attaches. + AndroidEqualizerParameters? _paramsEq; + + /// The [PasoEcualizador]s the LAST on/off transition actually executed, in + /// execution order. Reset at the start of every transition, so it stays + /// bounded and says exactly what the most recent toggle did. + /// + /// This is the only way a test can see the order: both operations are + /// invisible off-device. Asserting "both happened" would have stayed green + /// against the very bug this exists for. + @visibleForTesting + List get pasosEcualizadorEjecutados => + List.unmodifiable(_pasosEqEjecutados); + final _pasosEqEjecutados = []; + + /// How many native equalizer calls have thrown. + /// + /// The native effect is write-only (`just_audio` exposes no + /// `Equalizer.getEnabled()`), so a failure used to be indistinguishable + /// from success both in a logcat and in a test. + @visibleForTesting + int get fallosNativosEcualizador => _fallosNativosEq; + int _fallosNativosEq = 0; + /// The equalizer's on/off state — and, since eq-estado-unico, its SINGLE /// in-memory owner. `EstadoEcualizador._activo` is now a pure display /// mirror of this field, and `ServicioEcualizador` is its durable copy. @@ -1401,6 +1490,36 @@ class PluriWaveAudioHandler extends BaseAudioHandler PresetEcualizador _presetActual = PresetEcualizador.flat; PresetEcualizador get presetActual => _presetActual; + + /// True once anybody has chosen a preset on this handler. Guards the disk + /// seed against clobbering a live choice — see [_sembrarPresetDesdeDisco]. + bool _presetElegido = false; + + /// The ordered native steps an on/off transition performs. + /// + /// Pure and public so the ORDER is asserted directly. + @visibleForTesting + static List pasosEcualizador({required bool activo}) => + activo + // GAINS FIRST. `AudioEffect.setEnabled(true)` re-activates the + // native `android.media.audiofx.Equalizer`, which still holds the + // band levels the PREVIOUS preset left in it — so enabling first + // means the driver hears the old equalization and then, one native + // round trip per band, the new one sliding in over it. That is the + // «doubled equalization» the owner reports from the car. Writing + // the levels while the effect is still bypassed makes the + // transition a single audible step. + ? const [PasoEcualizador.ganancias, PasoEcualizador.habilitacion] + // DISABLING DOES NOT RESET THE GAINS, on purpose. + // `AudioEffect.setEnabled(false)` (just_audio's + // `AudioPlayer.java:820-822` → `AudioEffect.setEnabled`) BYPASSES + // the effect; it neither releases it nor clears its band levels, + // and a bypassed effect is inaudible whatever they hold. Zeroing + // them would be one `setBandLevel` IPC per band for no audible + // difference, and the enable path above rewrites them all before + // re-enabling anyway — so there is no stale-gain window left for a + // reset to close. + : const [PasoEcualizador.habilitacion]; int? get androidAudioSessionId => _androidAudioSessionId; Stream get androidAudioSessionIdStream => _androidAudioSessionIdController.stream; @@ -1575,6 +1694,21 @@ class PluriWaveAudioHandler extends BaseAudioHandler )? fabricaReproductorPrueba; + /// Same seam as [fabricaReproductorPrueba], for the native equalizer effect. + /// + /// `AudioEffect.setEnabled` is a silent no-op while the player is detached + /// (`just_audio.dart` gates it on `_player._active`), so off-device a + /// failing native equalizer cannot otherwise be simulated at all — which is + /// why the silent `catch (_) {}` on that path shipped with zero coverage. + /// Static for the same reason as [fabricaReproductorPrueba]: `_eq` is a + /// field initializer, so the factory must already be installed before + /// `PluriWaveAudioHandler()` runs. Tests clear it in `tearDown`. + @visibleForTesting + static AndroidEqualizer Function()? fabricaEcualizadorPrueba; + + static AndroidEqualizer _crearEq() => + fabricaEcualizadorPrueba?.call() ?? AndroidEqualizer(); + AudioPlayer _crearPlayer() { final pipeline = AudioPipeline(androidAudioEffects: [_eq]); final fabrica = fabricaReproductorPrueba; @@ -2330,8 +2464,31 @@ class PluriWaveAudioHandler extends BaseAudioHandler await anterior.dispose().timeout(_timeoutCierrePlayer); } catch (_) {} - _eq = AndroidEqualizer(); - _eqDisponible = false; + _eq = _crearEq(); + // `_eqDisponible` is deliberately NOT reset here. It answers "does this + // DEVICE have a usable native Equalizer effect", which no station change + // can alter — and resetting it on every source change is what made a car + // toggle land in a window where every native EQ path was gated off (the + // reported «does nothing») and made the EQ custom action disappear from + // the now-playing screen and come back seconds later + // (`controlesEcualizadorPersonalizados` returns `const []` when + // unavailable). [_activarEcualizador] is the only writer now: it sets it + // true when the fresh effect reports bands, false when it throws. + // + // Keeping it true across the rebuild cannot lie or throw, and that was + // verified against just_audio 0.9.46 rather than assumed: + // - `AudioEffect.setEnabled` short-circuits on `_player._active`, so on + // the detached fresh player it only records the Dart-side intent and + // never reaches the platform — no throw, no native call. + // - that recorded intent is NOT lost: the effect's `_toMessage()` is + // only read when the player attaches (`AudioPlayer._setPlatformActive` + // → `InitRequest.androidAudioEffects`), so a toggle made inside this + // window is carried into the new native pipeline verbatim. + // - the one call that WOULD hang is `await AndroidEqualizer.parameters`: + // its `Completer` only completes in `_activate`, i.e. when the player + // attaches. No toggle path awaits it any more — they read the + // [_paramsEq] cache cleared just below and skip while it is null. + _paramsEq = null; // Resets alongside its siblings above: the fresh player starts detached, // so the next non-idle event is a genuine idle -> active edge that // [debeReasertarEcualizadorNativo] must see. A value stuck at `true` @@ -2403,8 +2560,8 @@ class PluriWaveAudioHandler extends BaseAudioHandler 'activo=$_ecualizadorActivo preset=${_presetActual.nombre}', ); if (_eqDisponible) { - await _eq.setEnabled(_ecualizadorActivo); - await aplicarPreset(_presetActual); + _paramsEq = params; + await _conmutarEcualizadorNativo(_ecualizadorActivo); } } catch (_) { _eqDisponible = false; @@ -2484,26 +2641,27 @@ class PluriWaveAudioHandler extends BaseAudioHandler /// Aplica un preset al ecualizador nativo Android. Future aplicarPreset(PresetEcualizador preset) async { _presetActual = preset; + // A preset chosen by anyone (car folder, phone screen) claims ownership: + // a disk seed still in flight must not overwrite it. See + // [_sembrarPresetDesdeDisco]. + _presetElegido = true; if (_eqDisponible) { try { + // Enable-then-gains here does NOT contradict [pasosEcualizador]'s + // gains-then-enable. That order matters only on an on/off TRANSITION, + // where enabling first un-bypasses an effect still holding the + // previous preset. Choosing a preset is not a transition: the effect + // is already in its final on/off state, so this `setEnabled` is the + // idempotent re-assert that keeps the native effect honest after a + // `stop()` (see [debeReasertarEcualizadorNativo]) and opens no + // stale-gain window of its own. await _eq.setEnabled(_ecualizadorActivo); if (_ecualizadorActivo) { - final params = await _eq.parameters; - for ( - int i = 0; - i < params.bands.length && i < preset.bandas.length; - i++ - ) { - await params.bands[i].setGain( - mapearGananciaNativa( - preset.bandas[i], - minDecibels: params.minDecibels, - maxDecibels: params.maxDecibels, - ), - ); - } + await _empujarGananciasNativas(preset); } - } catch (_) {} + } catch (e) { + _registrarFalloEq('aplicarPreset(${preset.nombre})', e); + } } // Item 4: keeps the EQ custom action's preset-cycle label in sync // regardless of WHO changed the preset (a car customAction tap or the @@ -2518,9 +2676,11 @@ class PluriWaveAudioHandler extends BaseAudioHandler bandas[index] = db; _presetActual = _presetActual.copyWithBandas(bandas); } + _presetElegido = true; if (!_eqDisponible || !_ecualizadorActivo) return; + final params = _paramsEq; + if (params == null) return; try { - final params = await _eq.parameters; if (index < params.bands.length) { await params.bands[index].setGain( mapearGananciaNativa( @@ -2530,7 +2690,68 @@ class PluriWaveAudioHandler extends BaseAudioHandler ), ); } - } catch (_) {} + } catch (e) { + _registrarFalloEq('setBanda($index)', e); + } + } + + /// Writes [preset]'s band levels into the native effect. + /// + /// Skips silently while [_paramsEq] is `null` (the player has not attached + /// since the last rebuild): the gains have nowhere to go yet and + /// [_activarEcualizador] pushes them the moment it does. + Future _empujarGananciasNativas(PresetEcualizador preset) async { + final params = _paramsEq; + if (params == null) return; + for (int i = 0; i < params.bands.length && i < preset.bandas.length; i++) { + await params.bands[i].setGain( + mapearGananciaNativa( + preset.bandas[i], + minDecibels: params.minDecibels, + maxDecibels: params.maxDecibels, + ), + ); + } + } + + /// The native operations an on/off transition performs, in + /// [pasosEcualizador] order. + /// + /// Returns `false` when the [PasoEcualizador.habilitacion] step itself + /// threw, i.e. when the device did NOT adopt [activo]. A failed gains step + /// does not make the transition dishonest: the effect really is in the + /// requested on/off state, just carrying stale band levels. + Future _conmutarEcualizadorNativo(bool activo) async { + _pasosEqEjecutados.clear(); + var conmutado = true; + for (final paso in pasosEcualizador(activo: activo)) { + try { + switch (paso) { + case PasoEcualizador.ganancias: + await _empujarGananciasNativas(_presetActual); + case PasoEcualizador.habilitacion: + await _eq.setEnabled(activo); + } + _pasosEqEjecutados.add(paso); + } catch (e) { + _registrarFalloEq('$paso(activo=$activo)', e); + if (paso == PasoEcualizador.habilitacion) conmutado = false; + } + } + return conmutado; + } + + /// Single trace/count point for every native equalizer failure. + /// + /// [debugPrint] and never `dart:developer`'s `log`, for the same reason as + /// the rest of this file: `log()` writes to the VM service, which the + /// RELEASE build a car runs does not have. + void _registrarFalloEq(String operacion, Object error) { + _fallosNativosEq++; + debugPrint( + '[PluriWave][ServicioAudio] fallo nativo del ecualizador en ' + '$operacion: $error', + ); } /// Sets the equalizer on/off state AND persists it — the single entry @@ -2539,6 +2760,24 @@ class PluriWaveAudioHandler extends BaseAudioHandler Future setEcualizadorActivo(bool activo) => _aplicarEcualizadorActivo(activo, persistir: true); + /// Adopts a PRESET that came from disk, the sibling of + /// [sembrarEcualizadorActivo]. Bound through + /// `registrarHandler(leerPresetPersistido: ...)`. + /// + /// Unlike the on/off flag's seed this one YIELDS to a live choice. The flag + /// has exactly one persisted value and the handler owns writing it, so + /// seeding it can never contradict anybody. The preset does not: the phone + /// UI resolves a richer value (per-station, and per-Bluetooth-device when + /// the multi-device toggle is on) that this narrow "principal preset" read + /// knows nothing about. The seed's disk read is `unawaited`, so without the + /// [_presetElegido] guard a slow read could land after `EstadoEcualizador` + /// had already pushed the right preset and silently replace it with the + /// principal one. The seed exists to fill a VOID, never to overrule. + Future sembrarPresetEcualizador(PresetEcualizador preset) async { + if (_presetElegido) return; + await aplicarPreset(preset); + } + /// Adopts a value that came FROM disk (eq-estado-unico item A). Identical /// to [setEcualizadorActivo] except that it does not write back — seeding /// is a read, and echoing it to disk would only add a pointless write on @@ -2550,14 +2789,19 @@ class PluriWaveAudioHandler extends BaseAudioHandler bool activo, { required bool persistir, }) async { + final anterior = _ecualizadorActivo; _ecualizadorActivo = activo; - if (_eqDisponible) { - try { - await _eq.setEnabled(activo); - if (activo) { - await aplicarPreset(_presetActual); - } - } catch (_) {} + if (_eqDisponible && !await _conmutarEcualizadorNativo(activo)) { + // The device REFUSED the on/off call. Publishing `activo` anyway would + // put an icon on the car's now-playing screen claiming a state the + // audio does not have — and persisting it would resurrect that lie on + // the next engine start. Rolling back is cheap here because + // `_ecualizadorActivo` is the single in-memory owner (eq-estado-unico) + // and the controls are rebuilt from it one line below; the toggle then + // honestly reads "unchanged" and the failure is in the logcat. + _ecualizadorActivo = anterior; + _actualizarControlesEq(); + return; } // Item 4: keeps the EQ custom action's on/off label in sync regardless // of WHO toggled it (a car customAction tap or the phone settings diff --git a/lib/servicios/servicio_ecualizador.dart b/lib/servicios/servicio_ecualizador.dart index 10bdb16..85d93c5 100644 --- a/lib/servicios/servicio_ecualizador.dart +++ b/lib/servicios/servicio_ecualizador.dart @@ -256,6 +256,27 @@ class ServicioEcualizador { return prefs.getBool(_keyActivo); } + /// The persisted principal preset, or `null` when the user has never saved + /// one. + /// + /// The exact sibling of [leerActivo] and narrow for the same reason: its + /// caller is `registrarHandler`, on the audio bootstrap path of EVERY + /// engine — including the headless one Android Auto starts, where there is + /// no widget tree and `EstadoEcualizador` never exists to push a preset + /// into the handler. It reads ONE key, runs none of [cargar]'s migrations + /// and mutates nothing. + /// + /// `null` (nothing saved, or an unreadable value) is preserved rather than + /// collapsed to [PresetEcualizador.flat] so the handler's own default — + /// not this service — decides what "never persisted" means, and so a seed + /// with nothing to say does not overwrite anything. + Future leerPresetPrincipal() async { + final prefs = await _resolverPrefs(); + final raw = prefs.getString(_keyPresetPrincipal); + if (raw == null || raw.isEmpty) return null; + return _leerPresetPrincipal(prefs); + } + Future eliminarPorEmisora(String uuid) async { final prefs = await _resolverPrefs(); final mapa = _leerPresetsPorEmisora(prefs); diff --git a/test/estado/estado_ecualizador_test.dart b/test/estado/estado_ecualizador_test.dart index 69c0282..63207a7 100644 --- a/test/estado/estado_ecualizador_test.dart +++ b/test/estado/estado_ecualizador_test.dart @@ -1944,6 +1944,59 @@ void main() { }, ); }); + + // --------------------------------------------------------------------------- + // The handler REJECTED the toggle (native setEnabled threw): the handler + // rolls its own flag back, so this class must not keep — nor persist — a + // value the engine refused. + // --------------------------------------------------------------------------- + + group('EstadoEcualizador — cambiarActivo cuando el handler rechaza', () { + test( + 'adopta el valor real del handler y NO persiste el valor rechazado', + () async { + final fakeAudio = _FakeAudioEqRechazaConmutacion(); + final fakeServicio = FakeServicioEcualizador(activo: true); + final eq = EstadoEcualizador(audio: fakeAudio, servicio: fakeServicio); + await eq.cargarPersistido(); + fakeAudio.cambiosEcualizadorActivo.clear(); + fakeServicio.guardarActivoLlamadas = 0; + + var avisos = 0; + eq.addListener(() => avisos++); + + await eq.cambiarActivo(false); + await Future.delayed(const Duration(milliseconds: 50)); + + // The native call failed, so the handler kept the equalizer ON. + expect(fakeAudio.ecualizadorActivo, isTrue); + expect( + eq.activo, + isTrue, + reason: 'the UI must show what the engine really does', + ); + expect(avisos, greaterThanOrEqualTo(1)); + expect( + fakeServicio.guardarActivoLlamadas, + equals(0), + reason: 'a rejected value must never reach disk', + ); + expect(fakeServicio.config.activo, isTrue); + eq.dispose(); + }, + ); + }); +} + +/// Fake handler that REFUSES every on/off change: it records the call (the +/// UI-initiated path did reach the engine) but leaves [ecualizadorActivo] +/// untouched, exactly like `PluriWaveAudioHandler._aplicarEcualizadorActivo` +/// rolling its flag back when the native `setEnabled` throws. +class _FakeAudioEqRechazaConmutacion extends FakeServicioAudio { + @override + Future setEcualizadorActivo(bool activo) async { + cambiosEcualizadorActivo.add(activo); + } } /// Fake whose [guardarActivo] stays pending until released, and releases the diff --git a/test/servicios/servicio_audio_eq_auto_test.dart b/test/servicios/servicio_audio_eq_auto_test.dart new file mode 100644 index 0000000..11586ea --- /dev/null +++ b/test/servicios/servicio_audio_eq_auto_test.dart @@ -0,0 +1,374 @@ +import 'dart:async'; + +import 'package:audio_service/audio_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:pluriwave/modelos/preset_ecualizador.dart'; +import 'package:pluriwave/servicios/servicio_audio.dart'; + +import '../helpers/handlers_audio.dart'; + +/// eq-coche — the equalizer toggle pressed FROM ANDROID AUTO. +/// +/// Reported by the owner: the toggle behaves correctly from the phone screen +/// but from the car it «sometimes sounds like a doubled equalization and +/// sometimes does nothing». +/// +/// Three independent causes, one per group below: +/// +/// A. The handler's `_presetActual` was hardcoded to `PresetEcualizador.flat` +/// and had NO disk seam. The on/off flag got one (`leerEqActivoPersistido`, +/// `eq-estado-unico` item A); the preset never did. On a headless Android +/// Auto engine — no Activity, no Provider tree, so no `EstadoEcualizador` +/// to push the real preset — enabling the equalizer from the car applied +/// FLAT. +/// +/// B. `_aplicarEcualizadorActivo` called `setEnabled(activo)` BEFORE pushing +/// the preset's gains, so the native effect was re-activated carrying +/// whatever band levels the previous preset had left in it and only +/// afterwards were the intended ones written, band by band. That audible +/// gap is the «doubled equalization». +/// +/// C. `_recrearPlayer` dropped `_eqDisponible` to `false` on EVERY station +/// change and never restored it until the fresh player attached. Every +/// native EQ path is gated on that flag, so a car toggle landing inside +/// the window flipped the icon and the flag but never touched the audio — +/// the «does nothing» — and the EQ button itself vanished from the car's +/// now-playing screen (`controlesEcualizadorPersonalizados` returns +/// `const []` when unavailable) and came back seconds later. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final crearHandler = registrarHandlersLiberables(); + + late _GuionReproductorEq guion; + + setUp(() { + guion = _GuionReproductorEq(); + PluriWaveAudioHandler.fabricaReproductorPrueba = + (pipeline, carga) => _ReproductorFalsoEq(guion, pipeline, carga); + }); + + tearDown(() { + PluriWaveAudioHandler.fabricaReproductorPrueba = null; + PluriWaveAudioHandler.fabricaEcualizadorPrueba = null; + }); + + group('A — the preset is seeded from disk on a headless engine', () { + test('registrarHandler consults the injected preset port exactly once ' + 'and seeds the handler with it, with no widget tree', () async { + final handler = crearHandler(); + var lecturas = 0; + + registrarHandler( + handler, + leerPresetPersistido: () async { + lecturas++; + return PresetEcualizador.jazz; + }, + ); + await pumpEventQueue(); + + expect(lecturas, 1, reason: 'exactly one disk read per engine start'); + expect( + handler.presetActual, + PresetEcualizador.jazz, + reason: + 'from the car the handler is the ONLY owner of the preset — ' + 'nothing else ever pushes one on a headless engine', + ); + }); + + test('a read failure leaves the historical default instead of ' + 'propagating', () async { + final handler = crearHandler(); + + registrarHandler( + handler, + leerPresetPersistido: () async => throw StateError('sin disco'), + ); + await pumpEventQueue(); + + expect(handler.presetActual, PresetEcualizador.flat); + }); + + test('without a preset port the handler is left untouched (widget tests, ' + 'fakes)', () async { + final handler = crearHandler(); + await handler.aplicarPreset(PresetEcualizador.rock); + + registrarHandler(handler); + await pumpEventQueue(); + + expect(handler.presetActual, PresetEcualizador.rock); + }); + + test('a preset already chosen while the disk read was in flight WINS — ' + 'seeding never clobbers a live choice', () async { + final handler = crearHandler(); + final lectura = Completer(); + + registrarHandler(handler, leerPresetPersistido: () => lectura.future); + // The phone UI (`EstadoEcualizador`) resolves a per-station preset and + // pushes it while the seed's disk read is still pending. + await handler.aplicarPreset(PresetEcualizador.pop); + lectura.complete(PresetEcualizador.jazz); + await pumpEventQueue(); + + expect( + handler.presetActual, + PresetEcualizador.pop, + reason: + 'the seed exists to fill a VOID, not to overrule the richer ' + 'per-station/per-device preset the phone UI resolves', + ); + }); + }); + + group('B — the preset is pushed BEFORE the effect is enabled', () { + test('enabling applies the gains first and only then flips the native ' + 'effect on', () { + expect( + PluriWaveAudioHandler.pasosEcualizador(activo: true), + [PasoEcualizador.ganancias, PasoEcualizador.habilitacion], + reason: + 'enabling first would re-activate the native Equalizer carrying ' + 'the PREVIOUS preset gains, which is the doubled equalization ' + 'the owner hears', + ); + }); + + test('disabling only flips the effect off — the band gains are NOT ' + 'reset', () { + expect( + PluriWaveAudioHandler.pasosEcualizador(activo: false), + [PasoEcualizador.habilitacion], + reason: + 'android.media.audiofx.AudioEffect.setEnabled(false) bypasses ' + 'the effect and RETAINS its band levels, and the enable path ' + 'rewrites them before re-enabling anyway — zeroing them would be ' + 'one native round trip per band for no audible difference', + ); + }); + + test('the real toggle path executes those steps IN THAT ORDER', () async { + final handler = crearHandler(); + registrarHandler(handler); + handler.simularEcualizadorDisponible(true); + + await handler.setEcualizadorActivo(true); + + expect( + handler.pasosEcualizadorEjecutados, + [PasoEcualizador.ganancias, PasoEcualizador.habilitacion], + reason: + 'the ORDER is the fix; asserting only that both happened would ' + 'stay green against the exact bug being fixed', + ); + }); + + test('the real disable path executes only the habilitacion step', () async { + final handler = crearHandler(); + registrarHandler(handler); + handler.simularEcualizadorDisponible(true); + + await handler.setEcualizadorActivo(false); + + expect(handler.pasosEcualizadorEjecutados, [ + PasoEcualizador.habilitacion, + ]); + }); + }); + + group('C — a station change no longer drops the equalizer', () { + test('once the EQ was available, no state published across a station ' + 'change and a car toggle has zero custom actions', () async { + final handler = crearHandler(); + registrarHandler(handler); + handler.simularEcualizadorDisponible(true); + // The EQ action is on the car's now-playing screen before the station + // changes — that is the state the driver is looking at. + await handler.setEcualizadorActivo(true); + + final acciones = []; + final sub = handler.playbackState.listen( + (estado) => acciones.add( + estado.controls.where((c) => c.customAction != null).length, + ), + ); + + await handler.playMediaItem( + const MediaItem(id: 'https://a', title: 'A'), + ); + await pumpEventQueue(); + // The car tap that used to land inside the window `_recrearPlayer` + // opened. It republishes the controls from `_eqDisponible`, so a flag + // reset to `false` shows up here as an EQ button that disappeared. + await handler.customAction(accionEqToggle); + await sub.cancel(); + + expect( + acciones, + isNotEmpty, + reason: 'the station change must publish at least one state', + ); + expect( + acciones.every((n) => n > 0), + isTrue, + reason: + 'the EQ button vanished and reappeared on every station change ' + 'because `_recrearPlayer` reset `_eqDisponible`; availability is ' + 'a DEVICE property and does not change with the station. Got ' + '$acciones', + ); + }); + + test('the availability flag survives the player rebuild, so a car toggle ' + 'inside the window still reaches the native effect', () async { + final handler = crearHandler(); + registrarHandler(handler); + handler.simularEcualizadorDisponible(true); + + await handler.playMediaItem( + const MediaItem(id: 'https://a', title: 'A'), + ); + await pumpEventQueue(); + + expect( + handler.ecualizadorDisponible, + isTrue, + reason: + 'this is the flag every native EQ path is gated on; false here ' + 'is exactly the reported «does nothing»', + ); + }); + }); + + group('D — a failed native call is traced and never lies', () { + test('a throwing setEnabled is traced instead of swallowed', () async { + PluriWaveAudioHandler.fabricaEcualizadorPrueba = + () => _EcualizadorQueFalla(); + final handler = crearHandler(); + registrarHandler(handler); + handler.simularEcualizadorDisponible(true); + + await handler.setEcualizadorActivo(true); + + expect( + handler.fallosNativosEcualizador, + greaterThan(0), + reason: + 'the silent `catch (_) {}` made a dead native equalizer ' + 'indistinguishable from a working one in a car logcat', + ); + }); + + test('a failed on/off call leaves the published state honest instead of ' + 'claiming a state the audio does not have', () async { + PluriWaveAudioHandler.fabricaEcualizadorPrueba = + () => _EcualizadorQueFalla(); + final handler = crearHandler(); + registrarHandler(handler); + handler.simularEcualizadorDisponible(true); + await handler.sembrarEcualizadorActivo(false); + + await handler.setEcualizadorActivo(true); + + expect( + handler.ecualizadorActivo, + isFalse, + reason: + 'the native effect refused, so the car icon must not read "on" ' + 'over audio that is not equalized', + ); + }); + + test('a failed on/off call is not persisted', () async { + PluriWaveAudioHandler.fabricaEcualizadorPrueba = + () => _EcualizadorQueFalla(); + final handler = crearHandler(); + final escrituras = []; + registrarHandler( + handler, + guardarEqActivoPersistido: (activo) async => escrituras.add(activo), + ); + handler.simularEcualizadorDisponible(true); + await handler.sembrarEcualizadorActivo(false); + + await handler.setEcualizadorActivo(true); + + expect( + escrituras, + isEmpty, + reason: + 'persisting a state the device rejected would resurrect it on ' + 'the next engine start', + ); + }); + }); +} + +/// An `AndroidEqualizer` whose `setEnabled` always throws, standing in for a +/// device whose native `Equalizer` effect refuses the call. Nothing else is +/// overridden, so the rest of the handler runs unchanged. +class _EcualizadorQueFalla extends AndroidEqualizer { + @override + Future setEnabled(bool enabled) async { + throw StateError('el efecto nativo rechazo la llamada'); + } +} + +/// Minimal script/observation record shared by every [_ReproductorFalsoEq] +/// the handler builds (it rebuilds its player on every source change). +class _GuionReproductorEq { + int llamadasSetUrl = 0; + _ReproductorFalsoEq? ultimoReproductor; +} + +/// An [AudioPlayer] whose platform-touching methods are replaced, so a real +/// station change can be driven under `flutter test`. Mirrors the double in +/// `servicio_audio_transporte_test.dart`. +class _ReproductorFalsoEq extends AudioPlayer { + _ReproductorFalsoEq( + this._guion, + AudioPipeline pipeline, + AudioLoadConfiguration carga, + ) : super(audioPipeline: pipeline, audioLoadConfiguration: carga) { + _guion.ultimoReproductor = this; + } + + final _GuionReproductorEq _guion; + final _estados = StreamController.broadcast(); + + @override + Stream get playerStateStream => _estados.stream; + + @override + Future setUrl( + String url, { + Map? headers, + Duration? initialPosition, + bool preload = true, + dynamic tag, + }) async { + _guion.llamadasSetUrl++; + return null; + } + + @override + Future play() async {} + + @override + Future pause() async {} + + @override + Future stop() async {} + + @override + Future setVolume(double volume) async {} + + @override + Future dispose() async { + await _estados.close(); + } +}