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 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 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 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 _mostrarInterstitialAdMob() async { try { final cargaCompleter = Completer(); // 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(); 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; } } }