feat(eq): android auto custom equalizer and robust device detection
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m16s

- MainActivity: onListen re-emits the current active device and registers
  the audio device callback idempotently, so recreated activities resync
  instead of freezing the active-device id on a disconnected device.
- servicio_dispositivo_audio: resubscribir() re-opens the event channel;
  estado_ecualizador exposes refrescarDispositivoActual() with an
  in-flight guard, invoked on app resume and when opening advanced EQ
  options, clearing stale green-dot device selections.
- navegacion_auto/servicio_audio: new 'Personalizado' browse tree in
  Android Auto (5 band folders, 13 gain steps each) applied live via
  setBanda; preset and gain taps persist at device level when
  multi-device EQ is active and respect station/matrix overrides,
  with apply-before-persist ordering and children-changed notifications.
- l10n: regenerate stale generated localizations; add rxdart as direct
  dependency for the subscribeToChildren override.
This commit is contained in:
Javier Bautista Fernández
2026-07-22 10:26:02 +02:00
parent 3473034b58
commit 163ff69f7a
29 changed files with 1883 additions and 42 deletions
+151
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_ecualizador.dart';
import 'package:pluriwave/estado/estado_radio.dart';
@@ -1402,4 +1404,153 @@ void main() {
},
);
});
// ---------------------------------------------------------------------------
// Fix: robust active-device detection (stale green dot)
// ---------------------------------------------------------------------------
group('EstadoEcualizador.refrescarDispositivoActual', () {
const btDevice = DispositivoAudio(
id: 'bt_a2dp:AA:BB:CC:DD:EE:FF',
tipo: TipoDispositivo.bluetoothA2dp,
nombre: 'BT Speaker',
);
test(
'resubscribes the event channel and re-seeds the id from a fresh '
'device query',
() async {
final fakeDispositivo = FakeServicioDispositivoAudio();
final eq = EstadoEcualizador(
audio: FakeServicioAudio(),
servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true),
dispositivoAudio: fakeDispositivo,
emisoraActualUuid: () => null,
);
await eq.cargarPersistido();
expect(eq.dispositivoActualId, equals('builtin_speaker'));
// The native side changed device but the stale subscription lost the
// event (activity recreation): only the fake's cached value moved.
fakeDispositivo.establecerDispositivoSinEvento(btDevice);
await eq.refrescarDispositivoActual();
expect(fakeDispositivo.resubscribirLlamadas, equals(1));
expect(eq.dispositivoActualId, equals(btDevice.id));
eq.dispose();
},
);
test('is a no-op when the multi-device toggle is off', () async {
final fakeDispositivo = FakeServicioDispositivoAudio();
final eq = EstadoEcualizador(
audio: FakeServicioAudio(),
servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: false),
dispositivoAudio: fakeDispositivo,
emisoraActualUuid: () => null,
);
await eq.cargarPersistido();
await eq.refrescarDispositivoActual();
expect(fakeDispositivo.resubscribirLlamadas, equals(0));
expect(eq.dispositivoActualId, isNull);
eq.dispose();
});
test('is a no-op when no device service is injected', () async {
final eq = EstadoEcualizador(
audio: FakeServicioAudio(),
servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true),
emisoraActualUuid: () => null,
);
await eq.cargarPersistido();
// Must not throw despite the missing service.
await eq.refrescarDispositivoActual();
expect(eq.dispositivoActualId, isNull);
eq.dispose();
});
test('is safe to call repeatedly', () async {
final fakeDispositivo = FakeServicioDispositivoAudio();
final eq = EstadoEcualizador(
audio: FakeServicioAudio(),
servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true),
dispositivoAudio: fakeDispositivo,
emisoraActualUuid: () => null,
);
await eq.cargarPersistido();
fakeDispositivo.establecerDispositivoSinEvento(btDevice);
await eq.refrescarDispositivoActual();
await eq.refrescarDispositivoActual();
await eq.refrescarDispositivoActual();
expect(fakeDispositivo.resubscribirLlamadas, equals(3));
expect(eq.dispositivoActualId, equals(btDevice.id));
eq.dispose();
});
test(
'a failing resubscribe still falls back to builtin_speaker via the '
'seeding error path instead of throwing',
() async {
final fakeDispositivo = FakeServicioDispositivoAudioThrows();
final eq = EstadoEcualizador(
audio: FakeServicioAudio(),
servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true),
dispositivoAudio: fakeDispositivo,
emisoraActualUuid: () => null,
);
await eq.cargarPersistido();
await eq.refrescarDispositivoActual();
expect(fakeDispositivo.resubscribirLlamadas, equals(1));
expect(eq.dispositivoActualId, equals('builtin_speaker'));
eq.dispose();
},
);
test(
'overlapping calls share one in-flight refresh (single resubscribe)',
() async {
final fakeDispositivo = _FakeDispositivoAudioResubscribirLento();
final eq = EstadoEcualizador(
audio: FakeServicioAudio(),
servicio: FakeServicioEcualizador(eqMultiDeviceEnabled: true),
dispositivoAudio: fakeDispositivo,
emisoraActualUuid: () => null,
);
await eq.cargarPersistido();
// Second call lands while the first is still awaiting resubscribir.
final primero = eq.refrescarDispositivoActual();
final segundo = eq.refrescarDispositivoActual();
fakeDispositivo.completarResubscribir();
await Future.wait([primero, segundo]);
expect(fakeDispositivo.resubscribirLlamadas, equals(1));
eq.dispose();
},
);
});
}
/// Fake whose [resubscribir] stays pending until [completarResubscribir]
/// runs — creates the overlap window for the in-flight-guard test above.
class _FakeDispositivoAudioResubscribirLento
extends FakeServicioDispositivoAudio {
final _resubscribir = Completer<void>();
void completarResubscribir() => _resubscribir.complete();
@override
Future<void> resubscribir() async {
resubscribirLlamadas++;
await _resubscribir.future;
}
}