Fixes 9 of 10 review findings (10th requires a manual Play Console step, no code change): 1. app.dart/banner_anuncio_superior.dart: move the top SafeArea inside BannerAnuncioSuperior so it only reserves status-bar height when an ad actually renders, restoring edge-to-edge layout for premium and free-unloaded users. 2. servicio_anuncios.dart: bound every interstitial await (load, presentation, and the injected implementation itself) with injectable timeouts so a callback that never fires can no longer hang a caller. 3. estado_entitlement.dart/hoja_premium.dart: expose a typed resultadoUsuario signal for purchase/restore failures and restore-found-nothing, with dedicated localized messages (compraError, restauracionSinCompras) across all 13 locales -- never the raw developer/exception string. 4. main.dart/servicio_consentimiento.dart: add a GDPR/UMP consent flow (ConsentInformation/ConsentForm) that gates Mobile Ads SDK init on canRequestAds(); premium users never see a consent form; failures degrade to no ads instead of crashing or blocking startup. 6. servicio_anuncios.dart: track real ad presentation (onAdShowedFullScreenContent) so a failed-to-show interstitial no longer consumes a session cap slot. 7. banner_anuncio_superior.dart: add an explicit load-attempted guard so repeated didChangeDependencies (e.g. entitlement notifyListeners during a purchase) can only ever trigger one banner load attempt. 8. servicio_anuncios.dart: make esPremium a required constructor parameter, matching the hardened contract already applied to EstadoAlarmas/EstadoGrabacion/EstadoRadio. 9. hoja_premium.dart: add a dedicated premiumActivo localized string instead of reusing the equalizer's equalizerActive translation, across all 13 locales. All fixes implemented RED-first (failing test before production code). Full suite: 1261 passed, 2 pre-existing skips, 0 failures. flutter analyze: 5 pre-existing issues only, 0 new. [version set]
210 lines
8.8 KiB
Dart
210 lines
8.8 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/foundation.dart' show debugPrint, kReleaseMode;
|
|
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
|
|
|
/// Official Google TEST ad unit ids. ALWAYS used outside release builds —
|
|
/// tapping your own real ad unit during development/testing is invalid
|
|
/// traffic and AdMob suspends accounts for it, so this is not optional.
|
|
const bannerAdUnitIdPrueba = 'ca-app-pub-3940256099942544/6300978111';
|
|
const interstitialAdUnitIdPrueba = 'ca-app-pub-3940256099942544/1033173712';
|
|
|
|
/// Real banner unit id, provisioned in the AdMob console (iap-freemium-unlock).
|
|
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).
|
|
const bannerAdUnitId =
|
|
kReleaseMode ? _bannerAdUnitIdReal : bannerAdUnitIdPrueba;
|
|
const interstitialAdUnitId =
|
|
kReleaseMode ? _interstitialAdUnitIdReal : interstitialAdUnitIdPrueba;
|
|
|
|
/// Ads port + AdMob adapter (Design "Interfaces / Contracts", ADR-6): owns
|
|
/// the entitlement gate for both surfaces, the interstitial's session
|
|
/// frequency cap, and is the ONLY `google_mobile_ads` call site besides
|
|
/// `banner_anuncio_superior.dart`'s `BannerAd` widget wrapper. The frequency
|
|
/// cap and premium gating are pure/injectable (`ahora`,
|
|
/// `mostrarInterstitialImpl`) so they are unit-testable with a fake clock
|
|
/// and zero AdMob platform channels (Design Testing Strategy).
|
|
class ServicioAnuncios {
|
|
ServicioAnuncios({
|
|
required bool Function() esPremium,
|
|
DateTime Function()? ahora,
|
|
Future<bool> Function()? mostrarInterstitialImpl,
|
|
Duration? timeoutIntentoInterstitial,
|
|
}) : _esPremium = esPremium,
|
|
_ahora = ahora ?? DateTime.now,
|
|
_mostrarInterstitialImpl =
|
|
mostrarInterstitialImpl ?? _mostrarInterstitialAdMob,
|
|
_timeoutIntentoInterstitial =
|
|
timeoutIntentoInterstitial ?? timeoutIntentoInterstitialPorDefecto;
|
|
|
|
/// Session-scoped cap (ad-display spec "Interstitial Frequency Cap"): at
|
|
/// most 2 interstitials per process lifetime.
|
|
static const maxInterstitialsPorSesion = 2;
|
|
|
|
/// Minimum spacing between two interstitials (ad-display spec, same
|
|
/// requirement).
|
|
static const separacionMinima = Duration(minutes: 3);
|
|
|
|
/// FIX 2 (code review): bounds `InterstitialAd.load`'s callback wait
|
|
/// inside [_mostrarInterstitialAdMob] so a load callback that never fires
|
|
/// cannot hang a caller — every call site (`pantalla_alarmas.dart`,
|
|
/// `pantalla_favoritos.dart`,
|
|
/// `ajustes/pantalla_ajustes_emisoras_personalizadas.dart`) `await`s
|
|
/// [intentarInterstitial] before opening its form.
|
|
static const timeoutCargaInterstitialPorDefecto = Duration(seconds: 5);
|
|
|
|
/// FIX 2 (code review): bounds the wait for the ad to actually PRESENT
|
|
/// (`onAdShowedFullScreenContent`) or fail
|
|
/// (`onAdFailedToShowFullScreenContent`) after `show()`. This method
|
|
/// deliberately never waits for the ad to be DISMISSED — the caller is
|
|
/// not blocked on ad dismissal at all, only on the ad actually rendering.
|
|
static const timeoutPresentacionInterstitialPorDefecto = Duration(seconds: 5);
|
|
|
|
/// FIX 2 (code review): the overall bound applied around the INJECTED
|
|
/// [_mostrarInterstitialImpl] itself (production default: the sum of the
|
|
/// two timeouts above, plus headroom) — so ANY implementation, including
|
|
/// a future bug in an injected fake or a different ad SDK, can never hang
|
|
/// a caller indefinitely. Injectable so tests can use a short value.
|
|
static const timeoutIntentoInterstitialPorDefecto = Duration(seconds: 15);
|
|
|
|
final bool Function() _esPremium;
|
|
final DateTime Function() _ahora;
|
|
final Future<bool> Function() _mostrarInterstitialImpl;
|
|
final Duration _timeoutIntentoInterstitial;
|
|
|
|
int _mostrados = 0;
|
|
DateTime? _ultimoMostrado;
|
|
|
|
/// Ad-display spec "Persistent Top Banner": absent entirely for premium.
|
|
bool get debeMostrarBanner => !_esPremium();
|
|
|
|
bool _dentroDelCap() {
|
|
if (_esPremium()) return false;
|
|
if (_mostrados >= maxInterstitialsPorSesion) return false;
|
|
final ultimo = _ultimoMostrado;
|
|
if (ultimo != null && _ahora().difference(ultimo) < separacionMinima) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/// Attempts to show an interstitial for one of the two allowed CTAs (add
|
|
/// station manually, add alarm). Callers are responsible for the ADR-6
|
|
/// ordering invariant themselves (cap-check-before-interstitial for
|
|
/// add-alarm, so a refusal is never preceded by an ad) — this method only
|
|
/// owns entitlement + frequency-cap gating, never the caller's own
|
|
/// business-rule ordering.
|
|
///
|
|
/// Returns whether an interstitial actually rendered. A failed/aborted ad
|
|
/// load (network, no fill) does NOT consume the session cap — only a
|
|
/// genuinely SHOWN ad does (Spec intent: the cap limits driver-facing
|
|
/// interruptions, not load attempts).
|
|
Future<bool> intentarInterstitial() async {
|
|
if (!_dentroDelCap()) return false;
|
|
// FIX 2 (code review): bound the injected implementation itself — no
|
|
// caller may ever await this indefinitely, regardless of what
|
|
// [_mostrarInterstitialImpl] does internally. A timeout is treated
|
|
// exactly like "no ad shown": `false`, cap not consumed.
|
|
final mostrado = await _mostrarInterstitialImpl().timeout(
|
|
_timeoutIntentoInterstitial,
|
|
onTimeout: () => false,
|
|
);
|
|
if (mostrado) {
|
|
_mostrados++;
|
|
_ultimoMostrado = _ahora();
|
|
}
|
|
return mostrado;
|
|
}
|
|
|
|
static Future<bool> _mostrarInterstitialAdMob() async {
|
|
try {
|
|
final cargaCompleter = Completer<InterstitialAd?>();
|
|
// FIX 2 (code review): a load callback that never fires used to hang
|
|
// this await forever. `expiradoCarga` guards a LATE callback that
|
|
// still arrives after the timeout — the ad is disposed instead of
|
|
// leaked, and never completes the already-abandoned completer.
|
|
var expiradoCarga = false;
|
|
await InterstitialAd.load(
|
|
adUnitId: interstitialAdUnitId,
|
|
request: const AdRequest(),
|
|
adLoadCallback: InterstitialAdLoadCallback(
|
|
onAdLoaded: (ad) {
|
|
if (expiradoCarga) {
|
|
ad.dispose();
|
|
return;
|
|
}
|
|
if (!cargaCompleter.isCompleted) cargaCompleter.complete(ad);
|
|
},
|
|
onAdFailedToLoad: (error) {
|
|
debugPrint('[PluriWave][anuncios] interstitial load ERROR $error');
|
|
if (!cargaCompleter.isCompleted) cargaCompleter.complete(null);
|
|
},
|
|
),
|
|
);
|
|
final InterstitialAd? cargado;
|
|
try {
|
|
cargado = await cargaCompleter.future.timeout(
|
|
timeoutCargaInterstitialPorDefecto,
|
|
);
|
|
} on TimeoutException {
|
|
expiradoCarga = true;
|
|
return false;
|
|
}
|
|
if (cargado == null) return false;
|
|
|
|
// FIX 6 (code review): only a genuinely PRESENTED ad may consume the
|
|
// session cap. `onAdFailedToShowFullScreenContent` used to complete
|
|
// the same completer as a real dismissal and the method returned
|
|
// `true` unconditionally — a failed-to-show ad silently burned one of
|
|
// only 2 session slots.
|
|
//
|
|
// FIX 2 (code review): this method no longer waits for the ad to be
|
|
// DISMISSED at all — only for it to PRESENT or fail to present — and
|
|
// that wait is itself bounded, so a `fullScreenContentCallback` that
|
|
// never fires cannot hang the caller either. `expiradoPresentacion`
|
|
// guards a late callback the same way `expiradoCarga` does above.
|
|
var expiradoPresentacion = false;
|
|
final presentacionCompleter = Completer<bool>();
|
|
cargado.fullScreenContentCallback = FullScreenContentCallback(
|
|
onAdShowedFullScreenContent: (ad) {
|
|
if (!presentacionCompleter.isCompleted) {
|
|
presentacionCompleter.complete(true);
|
|
}
|
|
},
|
|
onAdDismissedFullScreenContent: (ad) {
|
|
ad.dispose();
|
|
},
|
|
onAdFailedToShowFullScreenContent: (ad, error) {
|
|
if (expiradoPresentacion) {
|
|
ad.dispose();
|
|
return;
|
|
}
|
|
ad.dispose();
|
|
if (!presentacionCompleter.isCompleted) {
|
|
presentacionCompleter.complete(false);
|
|
}
|
|
},
|
|
);
|
|
await cargado.show();
|
|
try {
|
|
return await presentacionCompleter.future.timeout(
|
|
timeoutPresentacionInterstitialPorDefecto,
|
|
);
|
|
} on TimeoutException {
|
|
expiradoPresentacion = true;
|
|
await cargado.dispose();
|
|
return false;
|
|
}
|
|
} catch (e) {
|
|
debugPrint('[PluriWave][anuncios] interstitial ERROR $e');
|
|
return false;
|
|
}
|
|
}
|
|
}
|