Compare commits
3
Commits
080d342de0
...
55fe50d07d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55fe50d07d | ||
|
|
2e15d05431 | ||
|
|
e9f47d47c2 |
@@ -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<DispositivoAudio>? _deviceSub;
|
||||
Future<void>? _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<EstadoReproduccion>? _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<void> _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<void> aplicarPresetActivo(PresetEcualizador preset) async {
|
||||
@@ -673,6 +741,7 @@ class EstadoEcualizador extends ChangeNotifier {
|
||||
@override
|
||||
void dispose() {
|
||||
_deviceSub?.cancel();
|
||||
_suscripcionEstadoAudioEq?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> aplicarPreset(PresetEcualizador preset) =>
|
||||
_handler.aplicarPreset(preset);
|
||||
Future<void> setEcualizadorActivo(bool activo) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user