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_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/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, 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(