Files
pluriwave/lib/servicios/servicio_anuncios.dart
T
FreeTLab 2e15d05431 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.
2026-08-28 19:49:12 +02:00

225 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). 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 && !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;
}
}
}