124 lines
5.1 KiB
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;
|
|
}
|
|
}
|