import 'dart:async'; import 'package:google_mobile_ads/google_mobile_ads.dart'; /// The single ad placement this app has: one interstitial, gating the /// *upload* phase of a cloud sync (see [CloudSyncService.syncNow]'s /// `beforeUpload` hook and [AppState.syncNow]) — deliberately not pulling/ /// merging remote changes down, and never anywhere else in the app. /// /// Showing an ad opens a gate that stays open for [gateValidity]; a sync /// attempted after that window closes has to show (and have the user sit /// through the start of) another one before it's allowed to push data to /// the cloud. Selecting/connecting a cloud provider is never gated — only /// the upload side of a sync is, via [showGateAd]. /// /// The Android AdMob App ID (android/app/src/main/AndroidManifest.xml) and /// [_interstitialAdUnitId] below are both the real ones from your AdMob /// console. 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. /// [AdGateResult.alreadyOpen] and [AdGateResult.justShown] both mean "the /// caller may proceed" — they're kept distinct only so [AppState] can tell /// whether *this* call is the one that actually put a user in front of an /// ad, which is the trigger for the "remove ads for a year" upsell dialog. /// [AdGateResult.blocked] means the caller must not proceed. enum AdGateResult { alreadyOpen, justShown, blocked } class AdService { static const _interstitialAdUnitId = 'ca-app-pub-9212406812117696/9482586380'; /// How long a successfully-shown ad keeps the upload gate open before the /// next sync attempt has to show another one. static const gateValidity = Duration(minutes: 5); bool _sdkInitialized = false; DateTime? _lastShownAt; InterstitialAd? _preloadedAd; Completer? _loadCompleter; bool get _gateOpen { final lastShownAt = _lastShownAt; return lastShownAt != null && DateTime.now().difference(lastShownAt) < gateValidity; } /// Starts the Mobile Ads SDK and begins preloading an 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; } /// The upload gate. Returns [AdGateResult.alreadyOpen] immediately if an /// ad was already shown within [gateValidity]; otherwise shows the /// preloaded interstitial and returns [AdGateResult.justShown] once it /// actually starts displaying (the only "watched it" signal a plain /// interstitial — as opposed to a rewarded ad — can give us), or /// [AdGateResult.blocked] if none was available in time or it failed to /// show. A [AdGateResult.blocked] result means the caller must not /// proceed with uploading. /// /// Bounded throughout so a slow ad load or a stuck ad SDK callback can /// never hang a sync indefinitely. Always lines up the next interstitial /// afterward, whether this attempt succeeded or not, so the next call — /// whether that's because this one failed or because [gateValidity] /// elapsed — has the best chance of a preloaded ad ready to go. Future showGateAd() async { if (_gateOpen) return AdGateResult.alreadyOpen; if (_preloadedAd == null) { await _loadCompleter?.future.timeout(const Duration(seconds: 4), onTimeout: () {}); } final ad = _preloadedAd; _preloadedAd = null; if (ad == null) { unawaited(_preload()); return AdGateResult.blocked; } final showed = Completer(); ad.fullScreenContentCallback = FullScreenContentCallback( onAdShowedFullScreenContent: (ad) { _lastShownAt = DateTime.now(); if (!showed.isCompleted) showed.complete(true); }, onAdDismissedFullScreenContent: (ad) => ad.dispose(), onAdFailedToShowFullScreenContent: (ad, error) { ad.dispose(); if (!showed.isCompleted) showed.complete(false); }, ); await ad.show(); final opened = await showed.future.timeout(const Duration(seconds: 8), onTimeout: () => false); unawaited(_preload()); return opened ? AdGateResult.justShown : AdGateResult.blocked; } }