MO-Fuel-Tax-Back/lib/services/ad_service.dart

124 lines
5.1 KiB
Dart

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<void>? _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<void> initialize() async {
if (_sdkInitialized) return;
_sdkInitialized = true;
await MobileAds.instance.initialize();
unawaited(_preload());
}
Future<void> _preload() {
final completer = Completer<void>();
_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<AdGateResult> 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<bool>();
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;
}
}