A whole lot of stuff
This commit is contained in:
parent
faf319322a
commit
ad850d2be6
50 changed files with 3013 additions and 213 deletions
|
|
@ -2,37 +2,53 @@ 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 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.
|
||||
///
|
||||
/// 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.
|
||||
/// 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-3940256099942544/1033173712';
|
||||
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;
|
||||
bool _shownThisSession = false;
|
||||
DateTime? _lastShownAt;
|
||||
InterstitialAd? _preloadedAd;
|
||||
Completer<void>? _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.
|
||||
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;
|
||||
|
|
@ -59,16 +75,22 @@ class AdService {
|
|||
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<void> maybeShowBeforeSync() async {
|
||||
if (_shownThisSession) return;
|
||||
_shownThisSession = true;
|
||||
/// 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: () {});
|
||||
|
|
@ -76,21 +98,27 @@ class AdService {
|
|||
|
||||
final ad = _preloadedAd;
|
||||
_preloadedAd = null;
|
||||
if (ad == null) return;
|
||||
if (ad == null) {
|
||||
unawaited(_preload());
|
||||
return AdGateResult.blocked;
|
||||
}
|
||||
|
||||
final dismissed = Completer<void>();
|
||||
final showed = Completer<bool>();
|
||||
ad.fullScreenContentCallback = FullScreenContentCallback(
|
||||
onAdDismissedFullScreenContent: (ad) {
|
||||
ad.dispose();
|
||||
if (!dismissed.isCompleted) dismissed.complete();
|
||||
onAdShowedFullScreenContent: (ad) {
|
||||
_lastShownAt = DateTime.now();
|
||||
if (!showed.isCompleted) showed.complete(true);
|
||||
},
|
||||
onAdDismissedFullScreenContent: (ad) => ad.dispose(),
|
||||
onAdFailedToShowFullScreenContent: (ad, error) {
|
||||
ad.dispose();
|
||||
if (!dismissed.isCompleted) dismissed.complete();
|
||||
if (!showed.isCompleted) showed.complete(false);
|
||||
},
|
||||
);
|
||||
|
||||
await ad.show();
|
||||
await dismissed.future.timeout(const Duration(seconds: 15), onTimeout: () {});
|
||||
final opened = await showed.future.timeout(const Duration(seconds: 8), onTimeout: () => false);
|
||||
unawaited(_preload());
|
||||
return opened ? AdGateResult.justShown : AdGateResult.blocked;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue