La prueba cerrada forzaba los ids de prueba de Google tambien en release, para que los testers no generaran trafico invalido contra la cuenta de AdMob. Para la primera version de produccion el interruptor pasa a false: las builds release sirven el banner y el intersticial reales. Debug, profile y las pruebas siguen usando siempre los ids de prueba, asi que desarrollar en un movil propio no toca la cuenta real. El test guardian fija ahora el valor de produccion, para que nadie vuelva a encenderlo por descuido y publique banners "Test Ad" que no ingresan nada.
226 lines
9.6 KiB
Dart
226 lines
9.6 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';
|
|
|
|
/// 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). Turned `false` for the
|
|
/// first production release (1.3.3+166): release builds now serve the real
|
|
/// units. Debug and profile builds keep the test ids regardless. 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 = false;
|
|
|
|
/// 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 && !usarAnunciosDePruebaEnRelease
|
|
? _bannerAdUnitIdReal
|
|
: bannerAdUnitIdPrueba;
|
|
const interstitialAdUnitId =
|
|
kReleaseMode && !usarAnunciosDePruebaEnRelease
|
|
? _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;
|
|
}
|
|
}
|
|
}
|