fix: el ecualizador del coche aplica el preset real y en el orden correcto

Reportado desde el uso real: desde el movil el ecualizador va bien, pero el
boton de Android Auto a veces no hace nada y a veces suena como si se aplicara
una doble ecualizacion.

El preset del handler nunca se sembraba desde disco

`_presetActual` arrancaba en `flat` a fuego. registrarHandler sembraba el flag
de encendido pero no el preset, asi que en un motor donde la interfaz del
telefono nunca corrio -- el que arranca Android Auto -- el toggle del coche
aplicaba `flat`, o lo que hubiera quedado, en vez del preset del usuario. Es la
misma clase de fallo que ya se corrigio para el flag: aquel recibio un puerto
headless y el preset se quedo fuera. Ahora tiene el suyo, con la misma forma:
opcional, el fallo se traza y cae al valor por defecto, nunca propaga.

La siembra respeta un preset ya elegido por EstadoEcualizador, que es mas rico
que la clave principal, para que la lectura de disco en vuelo no lo pise.

El efecto se habilitaba antes de escribir las ganancias

La ruta era setEnabled -> setEnabled -> ganancias: `aplicarPreset` volvia a
llamar a setEnabled por su cuenta. Entre la habilitacion y la escritura sonaban
las ganancias anteriores, y ese hueco es lo que se percibia como doble
ecualizacion. Ahora una funcion pura devuelve los pasos en orden y ambas rutas
la recorren: ganancias primero, habilitacion despues.

Las ganancias NO se resetean al apagar, y es deliberado: setEnabled(false)
puentea el efecto sin liberarlo ni limpiar sus niveles, y la ruta de encendido
los reescribe enteros antes de habilitar, asi que no queda ninguna ventana de
ganancia rancia que un reset pudiera cerrar.

El boton desaparecia en cada cambio de emisora

`_recrearPlayer` bajaba `_eqDisponible` sin republicar controles, asi que cada
cambio de emisora emitia al menos un estado sin la accion de EQ. Peor: las
llamadas nativas estan detras de ese flag, de modo que un toggle en esa ventana
cambiaba el icono sin tocar el audio. Ahora el unico que lo escribe es
`_activarEcualizador`.

Mantenerlo optimista exigia quitar de la ruta del toggle el `await
_eq.parameters`, que es un Completer que solo se completa cuando el reproductor
se engancha: esperarlo dejaba el boton pendiente durante toda la carga, y para
siempre si la carga fallaba. Se cachean los parametros al activarse.

Los fallos nativos dejan de ser mudos

El `catch (_) {}` ocultaba que la llamada nativa habia fallado y dejaba el icono
afirmando un estado que el audio no tenia. Ahora se traza, y un fallo al
habilitar revierte el flag, republica los controles y no persiste.

EstadoEcualizador adopta lo que el motor acepto en vez de asumir que su peticion
prospero: sin eso, el telefono escribia en disco un valor que el handler acababa
de rechazar, reabriendo la divergencia que el dueño unico habia cerrado.

Suite completa: 1515 pasan, 2 omitidos. flutter analyze mantiene los 5 avisos
preexistentes.
This commit is contained in:
2026-09-06 00:08:09 +02:00
parent 405dc18430
commit a0fae57219
6 changed files with 744 additions and 29 deletions
+17
View File
@@ -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<void> 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;
+6
View File
@@ -197,6 +197,12 @@ Future<void> 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
+273 -29
View File
@@ -56,6 +56,11 @@ typedef GuardarEqActivoPersistido = Future<void> Function(bool activo);
/// tests, fakes), which simply falls back to deriving the context on the spot.
typedef LeerContextoSaltoPersistido = Future<ContextoSalto?> 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<PresetEcualizador?> Function();
/// Write port for the same context. Bound to `guardarContextoSalto`.
typedef GuardarContextoSaltoPersistido =
Future<void> 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<void> _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<void> _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<PlayerState>? _estadoPlayerSub;
StreamSubscription<Duration>? _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<PasoEcualizador> get pasosEcualizadorEjecutados =>
List.unmodifiable(_pasosEqEjecutados);
final _pasosEqEjecutados = <PasoEcualizador>[];
/// 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<PasoEcualizador> 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<int?> 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<void> 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<void> _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<bool> _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<void> 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<void> 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
+21
View File
@@ -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<PresetEcualizador?> leerPresetPrincipal() async {
final prefs = await _resolverPrefs();
final raw = prefs.getString(_keyPresetPrincipal);
if (raw == null || raw.isEmpty) return null;
return _leerPresetPrincipal(prefs);
}
Future<void> eliminarPorEmisora(String uuid) async {
final prefs = await _resolverPrefs();
final mapa = _leerPresetsPorEmisora(prefs);
+53
View File
@@ -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<void>.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<void> setEcualizadorActivo(bool activo) async {
cambiosEcualizadorActivo.add(activo);
}
}
/// Fake whose [guardarActivo] stays pending until released, and releases the
@@ -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<PresetEcualizador?>();
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 = <int>[];
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 = <bool>[];
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<void> 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<PlayerState>.broadcast();
@override
Stream<PlayerState> get playerStateStream => _estados.stream;
@override
Future<Duration?> setUrl(
String url, {
Map<String, String>? headers,
Duration? initialPosition,
bool preload = true,
dynamic tag,
}) async {
_guion.llamadasSetUrl++;
return null;
}
@override
Future<void> play() async {}
@override
Future<void> pause() async {}
@override
Future<void> stop() async {}
@override
Future<void> setVolume(double volume) async {}
@override
Future<void> dispose() async {
await _estados.close();
}
}