fix(eq): resync EstadoEcualizador with car/notification-initiated changes

A toggle from the Android Auto notification or a preset picked from the
car's EQ folder mutated PluriWaveAudioHandler state directly, leaving
EstadoEcualizador (and therefore the phone UI) unaware and never
persisting the change, so it was lost on the next app restart.

Forward the handler's ecualizadorActivo flag through ServicioAudio and,
mirroring EstadoRadio's existing playFromMediaId resync, diff it plus
presetActual against the cached values on every estadoStream tick,
adopting and persisting a divergence via ServicioEcualizador.
This commit is contained in:
2026-08-28 17:48:52 +02:00
parent 689f3e8123
commit e9f47d47c2
5 changed files with 245 additions and 7 deletions
+126
View File
@@ -1728,6 +1728,132 @@ void main() {
eq.dispose();
});
});
// ---------------------------------------------------------------------------
// eq-sync-superficies: car/notification-initiated EQ changes must reach
// EstadoEcualizador (and persist through ServicioEcualizador), not just
// the audio handler.
// ---------------------------------------------------------------------------
group('EstadoEcualizador — resync with handler-initiated EQ changes '
'(eq-sync-superficies)', () {
test(
'a handler-initiated toggle (car/notification) syncs activo and '
'notifies listeners',
() async {
final fakeAudio = FakeServicioAudio();
final eq = EstadoEcualizador(
audio: fakeAudio,
servicio: FakeServicioEcualizador(activo: true),
);
await eq.cargarPersistido();
expect(eq.activo, isTrue);
var avisos = 0;
eq.addListener(() => avisos++);
// Simulates `accionEqToggle` calling
// `PluriWaveAudioHandler.setEcualizadorActivo` directly, bypassing
// `ServicioAudio`/`EstadoEcualizador` entirely.
fakeAudio.simularCambioEqDesdeHandler(activo: false);
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(eq.activo, isFalse);
expect(avisos, greaterThanOrEqualTo(1));
eq.dispose();
},
);
test(
'a handler-initiated preset change (Android Auto) syncs presetActual '
'and notifies listeners',
() async {
final fakeAudio = FakeServicioAudio();
final eq = EstadoEcualizador(
audio: fakeAudio,
servicio: FakeServicioEcualizador(principal: PresetEcualizador.flat),
);
await eq.cargarPersistido();
expect(eq.presetActual, equals(PresetEcualizador.flat));
var avisos = 0;
eq.addListener(() => avisos++);
// Simulates `seleccionarPresetEqPorMediaId` calling
// `PluriWaveAudioHandler.aplicarPreset` directly.
fakeAudio.simularCambioEqDesdeHandler(preset: PresetEcualizador.jazz);
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(eq.presetActual, equals(PresetEcualizador.jazz));
expect(avisos, greaterThanOrEqualTo(1));
eq.dispose();
},
);
test(
'a handler-initiated toggle is persisted through ServicioEcualizador '
'(survives a restart)',
() async {
final fakeAudio = FakeServicioAudio();
final fakeServicio = FakeServicioEcualizador(activo: true);
final eq = EstadoEcualizador(audio: fakeAudio, servicio: fakeServicio);
await eq.cargarPersistido();
fakeAudio.simularCambioEqDesdeHandler(activo: false);
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(fakeServicio.config.activo, isFalse);
expect(fakeServicio.guardarActivoLlamadas, equals(1));
eq.dispose();
},
);
test(
'resync does not cause an extra handler write (no feedback loop)',
() async {
final fakeAudio = FakeServicioAudio();
final eq = EstadoEcualizador(
audio: fakeAudio,
servicio: FakeServicioEcualizador(activo: true),
);
await eq.cargarPersistido();
fakeAudio.cambiosEcualizadorActivo.clear();
fakeAudio.presetsAplicados.clear();
fakeAudio.simularCambioEqDesdeHandler(activo: false);
await Future<void>.delayed(const Duration(milliseconds: 50));
// The resync must only read from `audio` and write to `servicio` —
// never write BACK into `audio`, or a handler write would trigger
// another stream tick, which would resync again, forever.
expect(fakeAudio.cambiosEcualizadorActivo, isEmpty);
expect(fakeAudio.presetsAplicados, isEmpty);
eq.dispose();
},
);
test(
'a UI-initiated toggle still works exactly as before and persists '
'exactly once (regression)',
() async {
final fakeAudio = FakeServicioAudio();
final fakeServicio = FakeServicioEcualizador(activo: true);
final eq = EstadoEcualizador(audio: fakeAudio, servicio: fakeServicio);
await eq.cargarPersistido();
fakeAudio.cambiosEcualizadorActivo.clear();
await eq.cambiarActivo(false);
// Give any (harmless, no-op) resync tick a chance to run too.
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(eq.activo, isFalse);
expect(fakeServicio.config.activo, isFalse);
expect(fakeServicio.guardarActivoLlamadas, equals(1));
expect(fakeAudio.cambiosEcualizadorActivo, equals([false]));
eq.dispose();
},
);
});
}
/// Fake whose [guardarActivo] stays pending until released, and releases the
+1 -1
View File
@@ -77,7 +77,7 @@ void main() {
final porEmisora = {'fav-1': PresetEcualizador.rock};
final estado = EstadoRadio(
esPremium: () => true,
audio: FakeServicioAudio(ecualizadorActivo: false),
audio: FakeServicioAudio(ecualizadorDisponible: false),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(
+41 -5
View File
@@ -15,11 +15,14 @@ import 'package:pluriwave/servicios/servicio_presets_personalizados.dart';
import 'package:pluriwave/servicios/servicio_radio.dart';
class FakeServicioAudio extends ServicioAudio {
FakeServicioAudio({this.ecualizadorActivo = true}) {
FakeServicioAudio({this.ecualizadorDisponible = true}) {
_estadoController.add(EstadoReproduccion.detenido);
}
final bool ecualizadorActivo;
/// Whether the native equalizer is available on this device — NOT whether
/// it is currently switched on (see [ecualizadorActivo] for that).
@override
final bool ecualizadorDisponible;
final _estadoController = StreamController<EstadoReproduccion>.broadcast();
final List<PresetEcualizador> presetsAplicados = [];
final List<Emisora> emisorasReproducidas = [];
@@ -30,6 +33,35 @@ class FakeServicioAudio extends ServicioAudio {
Emisora? _emisoraActual;
EstadoReproduccion _estadoActual = EstadoReproduccion.detenido;
/// Mirrors `PluriWaveAudioHandler._ecualizadorActivo`/`_presetActual`:
/// the handler-side EQ state, settable independently of the
/// `ServicioAudio`-forwarded methods below so tests can simulate a
/// car/notification-initiated change (eq-sync-superficies).
bool _ecualizadorActivoValor = true;
PresetEcualizador _presetActualValor = PresetEcualizador.flat;
@override
bool get ecualizadorActivo => _ecualizadorActivoValor;
@override
PresetEcualizador get presetActual => _presetActualValor;
/// Simulates a car/notification-initiated EQ change: mutates the (fake)
/// handler's own state directly, the same way
/// `PluriWaveAudioHandler.customAction`/`seleccionarPresetEqPorMediaId`
/// call `setEcualizadorActivo`/`aplicarPreset` on the handler WITHOUT
/// going through `ServicioAudio` — then re-emits the current playback
/// state, mirroring `_actualizarControlesEq()`'s unconditional
/// `playbackState.add(...)` republish so a resync listener on
/// [estadoStream] picks it up. Deliberately does NOT append to
/// [cambiosEcualizadorActivo]/[presetsAplicados]: those track calls that
/// arrived through the `ServicioAudio`-forwarded (UI-initiated) path.
void simularCambioEqDesdeHandler({bool? activo, PresetEcualizador? preset}) {
if (activo != null) _ecualizadorActivoValor = activo;
if (preset != null) _presetActualValor = preset;
emitirEstado(_estadoActual);
}
@override
void configurarLocalizaciones(AppLocalizations l10n) {
// No global handler in tests; just record the call.
@@ -39,9 +71,6 @@ class FakeServicioAudio extends ServicioAudio {
@override
Emisora? get emisoraActual => _emisoraActual;
@override
bool get ecualizadorDisponible => ecualizadorActivo;
@override
Stream<EstadoReproduccion> get estadoStream => _estadoController.stream;
@@ -105,6 +134,7 @@ class FakeServicioAudio extends ServicioAudio {
@override
Future<void> aplicarPreset(PresetEcualizador preset) async {
presetsAplicados.add(preset);
_presetActualValor = preset;
}
@override
@@ -113,6 +143,7 @@ class FakeServicioAudio extends ServicioAudio {
@override
Future<void> setEcualizadorActivo(bool activo) async {
cambiosEcualizadorActivo.add(activo);
_ecualizadorActivoValor = activo;
}
@override
@@ -349,6 +380,10 @@ class FakeServicioEcualizador extends ServicioEcualizador {
ConfiguracionEcualizador _config;
ConfiguracionEcualizador get config => _config;
/// Number of times [guardarActivo] has been called — lets tests assert a
/// persistence write happened exactly once (eq-sync-superficies).
int guardarActivoLlamadas = 0;
@override
Future<ConfiguracionEcualizador> cargar() async => _config;
@@ -367,6 +402,7 @@ class FakeServicioEcualizador extends ServicioEcualizador {
@override
Future<void> guardarActivo(bool activo) async {
guardarActivoLlamadas++;
_config = ConfiguracionEcualizador(
principal: _config.principal,
porEmisora: _config.porEmisora,