import 'dart:async'; import 'package:google_mobile_ads/google_mobile_ads.dart'; /// The single ad placement this app has: one interstitial, shown at most /// once per app session, right before the *upload-to-cloud* phase of the /// first sync that reaches it (see [CloudSyncService.syncNow]'s /// `beforeUpload` hook and [AppState.syncNow]) — deliberately not before /// pulling/merging remote changes down, and never anywhere else in the app. /// /// The Android AdMob App ID (android/app/src/main/AndroidManifest.xml) is /// the real one from your AdMob console. [_interstitialAdUnitId] below is /// TEMPORARILY back on Google's official test interstitial ID — your real /// one (`ca-app-pub-9212406812117696/9482586380`) was returning no-fill /// (error code 3), most likely just because it's brand new; this swap is /// only to confirm the trigger/preload/display mechanism itself works /// while that warms up. Swap the real one back in once it's serving. /// ios/Runner/Info.plist's `GADApplicationIdentifier` is still Google's /// test iOS App ID, though — an AdMob App ID is registered per-platform, so /// it needs its own real iOS App ID (and a real iOS interstitial ad unit /// ID here) from the AdMob console if this app ever ships on iOS. class AdService { static const _interstitialAdUnitId = 'ca-app-pub-3940256099942544/1033173712'; bool _sdkInitialized = false; bool _shownThisSession = false; InterstitialAd? _preloadedAd; Completer? _loadCompleter; /// Starts the Mobile Ads SDK and begins preloading this session's one /// interstitial. Idempotent (a no-op after the first call) and safe to /// call speculatively — [AppState] calls this from [AppState.syncNow] /// rather than unconditionally at app startup, so a user who never /// connects cloud sync never triggers any ad-related network activity at /// all. Future initialize() async { if (_sdkInitialized) return; _sdkInitialized = true; await MobileAds.instance.initialize(); unawaited(_preload()); } Future _preload() { final completer = Completer(); _loadCompleter = completer; InterstitialAd.load( adUnitId: _interstitialAdUnitId, request: const AdRequest(), adLoadCallback: InterstitialAdLoadCallback( onAdLoaded: (ad) { _preloadedAd = ad; if (!completer.isCompleted) completer.complete(); }, onAdFailedToLoad: (error) { if (!completer.isCompleted) completer.complete(); }, ), ); return completer.future; } /// Shows the preloaded interstitial if this is the first call this app /// session; every call after that (or if no ad ever became available) is /// a no-op. Waits for the ad to actually be dismissed before returning, /// so the caller — the sync engine, right before it starts uploading — /// genuinely happens *after* the ad, not just alongside it; bounded so a /// slow ad load or a stuck ad SDK callback can never block sync /// indefinitely. Future maybeShowBeforeSync() async { if (_shownThisSession) return; _shownThisSession = true; if (_preloadedAd == null) { await _loadCompleter?.future.timeout(const Duration(seconds: 4), onTimeout: () {}); } final ad = _preloadedAd; _preloadedAd = null; if (ad == null) return; final dismissed = Completer(); ad.fullScreenContentCallback = FullScreenContentCallback( onAdDismissedFullScreenContent: (ad) { ad.dispose(); if (!dismissed.isCompleted) dismissed.complete(); }, onAdFailedToShowFullScreenContent: (ad, error) { ad.dispose(); if (!dismissed.isCompleted) dismissed.complete(); }, ); await ad.show(); await dismissed.future.timeout(const Duration(seconds: 15), onTimeout: () {}); } }