From e9f47d47c2b2387a043705bca4b79505bdc4d0fa Mon Sep 17 00:00:00 2001 From: freetlab Date: Fri, 28 Aug 2026 17:48:52 +0200 Subject: [PATCH 1/2] 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. --- lib/estado/estado_ecualizador.dart | 71 ++++++++++++- lib/servicios/servicio_audio.dart | 7 ++ test/estado/estado_ecualizador_test.dart | 126 +++++++++++++++++++++++ test/estado/estado_radio_test.dart | 2 +- test/helpers/fakes.dart | 46 ++++++++- 5 files changed, 245 insertions(+), 7 deletions(-) diff --git a/lib/estado/estado_ecualizador.dart b/lib/estado/estado_ecualizador.dart index c3a5dc9..a1ce397 100644 --- a/lib/estado/estado_ecualizador.dart +++ b/lib/estado/estado_ecualizador.dart @@ -37,7 +37,9 @@ class EstadoEcualizador extends ChangeNotifier { _presetsPersonalizadosService = presetsPersonalizadosService ?? ServicioPresetsPersonalizados(), _dispositivoAudio = dispositivoAudio, - _emisoraActualUuid = emisoraActualUuid ?? (() => null); + _emisoraActualUuid = emisoraActualUuid ?? (() => null) { + _escucharCambiosEqDesdeHandler(); + } final ServicioAudio audio; final ServicioEcualizador servicio; @@ -84,6 +86,20 @@ class EstadoEcualizador extends ChangeNotifier { StreamSubscription? _deviceSub; Future? _refrescoEnCurso; + /// Catches a car/notification-initiated EQ change that bypasses this + /// class entirely (eq-sync-superficies): `accionEqToggle` calls + /// `PluriWaveAudioHandler.setEcualizadorActivo` directly, and + /// `seleccionarPresetEqPorMediaId` calls `aplicarPreset` directly — both + /// mutate ONLY the handler's own `_ecualizadorActivo`/`_presetActual` + /// fields, never [audio]'s owner ([EstadoEcualizador]). Mirrors the exact + /// shape `EstadoRadio._escucharErroresReproduccion` already uses for the + /// equivalent `playFromMediaId` gap: on every [ServicioAudio.estadoStream] + /// tick (which the handler already re-emits on any EQ change via + /// `_actualizarControlesEq()`, regardless of who triggered it), compare + /// the handler's current EQ state against our cached copy and adopt it on + /// divergence. + StreamSubscription? _suscripcionEstadoAudioEq; + PresetEcualizador get presetActual => _presetActual; PresetEcualizador get presetPrincipal => _presetPrincipal; bool get activo => _activo; @@ -337,6 +353,58 @@ class EstadoEcualizador extends ChangeNotifier { notifyListeners(); } + /// Subscribes to [ServicioAudio.estadoStream] to catch a + /// car/notification-initiated EQ change (see [_suscripcionEstadoAudioEq] + /// doc for the full rationale). + void _escucharCambiosEqDesdeHandler() { + _suscripcionEstadoAudioEq = audio.estadoStream.listen((_) { + unawaited(_resincronizarConHandler()); + }); + } + + /// Compares the handler's live EQ state ([ServicioAudio.ecualizadorActivo], + /// [ServicioAudio.presetActual]) against our cached [_activo]/ + /// [_presetActual] and adopts the handler's value on divergence. + /// + /// Deliberately never calls back into [audio] here (no + /// `setEcualizadorActivo`/`aplicarPreset`): doing so would re-trigger the + /// handler's own `_actualizarControlesEq()` re-push, which would tick + /// [ServicioAudio.estadoStream] again and re-enter this method forever. + /// Only a local field write, [servicio] persistence and [notifyListeners] + /// happen here, so a divergence is resolved in a single pass. + /// + /// Wrapped in try/catch like every other handler-facing read in this + /// class (e.g. [_sembrarDispositivoActual]): a test double or an + /// unexpected platform state that makes [audio]'s EQ getters unavailable + /// must never crash the stream subscription — it just skips this tick. + Future _resincronizarConHandler() async { + try { + final activoHandler = audio.ecualizadorActivo; + final presetHandler = audio.presetActual; + + final activoDiverge = activoHandler != _activo; + final presetDiverge = presetHandler != _presetActual; + if (!activoDiverge && !presetDiverge) return; + + if (activoDiverge) { + _activo = activoHandler; + // Closes the persistence gap: `PluriWaveAudioHandler` never + // persists anything itself (it must stay headless-constructible, + // with zero SharedPreferences/Provider access) — [servicio] is the + // only owner of EQ persistence, so a car/notification toggle must + // be saved HERE or it is lost on the next process restart. + await servicio.guardarActivo(activoHandler); + } + if (presetDiverge) { + _presetActual = presetHandler; + } + + notifyListeners(); + } catch (_) { + // See doc above — never let a resync failure crash the app. + } + } + /// Applies [preset] to the audio engine and tracks it as current /// WITHOUT persisting it (used when switching stations). Future aplicarPresetActivo(PresetEcualizador preset) async { @@ -673,6 +741,7 @@ class EstadoEcualizador extends ChangeNotifier { @override void dispose() { _deviceSub?.cancel(); + _suscripcionEstadoAudioEq?.cancel(); super.dispose(); } } diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart index fe690d0..08d9fe6 100644 --- a/lib/servicios/servicio_audio.dart +++ b/lib/servicios/servicio_audio.dart @@ -582,6 +582,13 @@ class ServicioAudio { bool get ecualizadorDisponible => _handler.ecualizadorDisponible; PresetEcualizador get presetActual => _handler.presetActual; + /// Forwards the handler's own on/off flag (eq-sync-superficies): a + /// car/notification toggle (`accionEqToggle`) mutates + /// `PluriWaveAudioHandler._ecualizadorActivo` directly, bypassing + /// [setEcualizadorActivo] entirely. [EstadoEcualizador] polls this getter + /// on every [estadoStream] tick to detect and resync that divergence. + bool get ecualizadorActivo => _handler.ecualizadorActivo; + Future aplicarPreset(PresetEcualizador preset) => _handler.aplicarPreset(preset); Future setEcualizadorActivo(bool activo) => diff --git a/test/estado/estado_ecualizador_test.dart b/test/estado/estado_ecualizador_test.dart index 3434ed2..5823c60 100644 --- a/test/estado/estado_ecualizador_test.dart +++ b/test/estado/estado_ecualizador_test.dart @@ -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.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.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.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.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.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 diff --git a/test/estado/estado_radio_test.dart b/test/estado/estado_radio_test.dart index 0d8dec0..41f1676 100644 --- a/test/estado/estado_radio_test.dart +++ b/test/estado/estado_radio_test.dart @@ -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( diff --git a/test/helpers/fakes.dart b/test/helpers/fakes.dart index cb1c404..6f29b5d 100644 --- a/test/helpers/fakes.dart +++ b/test/helpers/fakes.dart @@ -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.broadcast(); final List presetsAplicados = []; final List 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 get estadoStream => _estadoController.stream; @@ -105,6 +134,7 @@ class FakeServicioAudio extends ServicioAudio { @override Future aplicarPreset(PresetEcualizador preset) async { presetsAplicados.add(preset); + _presetActualValor = preset; } @override @@ -113,6 +143,7 @@ class FakeServicioAudio extends ServicioAudio { @override Future 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 cargar() async => _config; @@ -367,6 +402,7 @@ class FakeServicioEcualizador extends ServicioEcualizador { @override Future guardarActivo(bool activo) async { + guardarActivoLlamadas++; _config = ConfiguracionEcualizador( principal: _config.principal, porEmisora: _config.porEmisora, From 2e15d05431d2baff36085d07020ef526d2de5328 Mon Sep 17 00:00:00 2001 From: freetlab Date: Fri, 28 Aug 2026 19:49:12 +0200 Subject: [PATCH 2/2] fix(ads): force test ad units in release during closed testing Closed-testing human testers cannot be registered as AdMob test devices, so release builds serving real ad units risked invalid traffic against an AdMob account that currently earns essentially nothing. Add usarAnunciosDePruebaEnRelease, a single boolean switch defaulted to true, that keeps bannerAdUnitId/interstitialAdUnitId on Google's official test ids even in kReleaseMode. Flipping it to false is the only change needed to go live. AndroidManifest's AdMob application id is untouched, as it only initializes the SDK. --- lib/servicios/servicio_anuncios.dart | 25 ++++++++++++++---- test/servicios/servicio_anuncios_test.dart | 30 ++++++++++++++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/lib/servicios/servicio_anuncios.dart b/lib/servicios/servicio_anuncios.dart index f5d9fdf..669400b 100644 --- a/lib/servicios/servicio_anuncios.dart +++ b/lib/servicios/servicio_anuncios.dart @@ -15,13 +15,28 @@ const _bannerAdUnitIdReal = 'ca-app-pub-6038935671414339/5658618378'; /// Real interstitial unit id, provisioned in the AdMob console (iap-freemium-unlock). const _interstitialAdUnitIdReal = 'ca-app-pub-6038935671414339/4189478248'; -/// Real id in release builds only; test id everywhere else (debug/profile, -/// including internal-testing-track builds run via `flutter run --release` -/// on a personal device — see the "never tap your own ads" note above). +/// TESTING-PHASE SWITCH. While `true`, release builds serve Google's official +/// TEST ad units instead of the real ones, so none of the closed-testing +/// human testers can generate invalid traffic against the AdMob account +/// (they cannot be registered as AdMob test devices). Flip to `false` for +/// the production release — that is the ONLY change needed to start serving +/// real ads. This does NOT affect the AdMob application id in +/// `AndroidManifest.xml`, which stays real in every build (it only +/// initializes the SDK and carries none of the click risk). +const usarAnunciosDePruebaEnRelease = true; + +/// Real id in release builds only, and only once [usarAnunciosDePruebaEnRelease] +/// is flipped to `false`; test id everywhere else (debug/profile, including +/// internal-testing-track builds run via `flutter run --release` on a +/// personal device — see the "never tap your own ads" note above). const bannerAdUnitId = - kReleaseMode ? _bannerAdUnitIdReal : bannerAdUnitIdPrueba; + kReleaseMode && !usarAnunciosDePruebaEnRelease + ? _bannerAdUnitIdReal + : bannerAdUnitIdPrueba; const interstitialAdUnitId = - kReleaseMode ? _interstitialAdUnitIdReal : interstitialAdUnitIdPrueba; + kReleaseMode && !usarAnunciosDePruebaEnRelease + ? _interstitialAdUnitIdReal + : interstitialAdUnitIdPrueba; /// Ads port + AdMob adapter (Design "Interfaces / Contracts", ADR-6): owns /// the entitlement gate for both surfaces, the interstitial's session diff --git a/test/servicios/servicio_anuncios_test.dart b/test/servicios/servicio_anuncios_test.dart index c5f192d..0c21790 100644 --- a/test/servicios/servicio_anuncios_test.dart +++ b/test/servicios/servicio_anuncios_test.dart @@ -161,6 +161,36 @@ void main() { }); }); + group('usarAnunciosDePruebaEnRelease — interruptor de fase de pruebas', () { + test('mientras esta en true, bannerAdUnitId e interstitialAdUnitId son los ' + 'ids oficiales de prueba de Google', () { + expect(usarAnunciosDePruebaEnRelease, isTrue); + expect(bannerAdUnitId, equals('ca-app-pub-3940256099942544/6300978111')); + expect( + interstitialAdUnitId, + equals('ca-app-pub-3940256099942544/1033173712'), + ); + }); + + test('los ids reales siguen presentes como constantes en el archivo (no se ' + 'pueden perder en un futuro edit)', () { + final source = + File('lib/servicios/servicio_anuncios.dart').readAsStringSync(); + expect( + source.contains("'ca-app-pub-6038935671414339/5658618378'"), + isTrue, + reason: 'el id real del banner debe seguir presente en el archivo', + ); + expect( + source.contains("'ca-app-pub-6038935671414339/4189478248'"), + isTrue, + reason: + 'el id real del interstitial debe seguir presente en el ' + 'archivo', + ); + }); + }); + group('debeMostrarBanner', () { test('free: true', () { final servicio = construir(