applovin_admob_sdk 1.0.23
applovin_admob_sdk: ^1.0.23 copied to clipboard
Dual-provider ad SDK for Flutter (AdMob + AppLovin MAX). Adapter pattern, state machine, VIP redeem with Cupertino dialog, GDPR/COPPA/CCPA flags, exponential backoff, AdEvent stream, debug overlay.
applovin_admob_sdk #
A production-grade dual-provider ad SDK for Flutter — AdMob and AppLovin MAX behind a single, opinionated API.
Drop in, configure 5 keys, ship. The SDK ships sensible defaults for compliance, anti-fraud, retention, and crash recovery — most apps need zero glue code beyond the splash bootstrap.
Table of contents #
- Why this SDK
- What's new in 1.0.19
- Quick start (copy-paste in 6 steps)
- Configuration reference
- VIP system
- Consent & compliance (GDPR / COPPA / CCPA)
- Debugging
- Pitfalls — read before filing a bug
- Public API surface
- FAQ
- Migration from older versions
- Support
- License
Why this SDK #
| You want | The SDK gives you |
|---|---|
| Switch between AdMob and AppLovin without rewriting code | One AdConfig.provider flag |
| First-session ad-free experience for new installs (boost D1 retention) | firstInstallVipGrace — auto-grants VIP for 24 hours on first install |
| GDPR-compliant consent UI without integrating a third-party CMP | Built-in Cupertino consent dialog, auto-shown post-splash |
| Google UMP form for EEA users | AdManager().requestUmpConsent() — wraps google_mobile_ads's built-in ConsentInformation API |
| Anti-fraud protection so AdMob doesn't suspend your account | Multi-layer safety gate: per-session/hour/day caps, throttle, CTR threshold, click-spam detection, progressive cooldown |
| Banner that pauses on navigation and resumes on return | buildBanner() — hooks into the navigator and adapter lifecycle automatically |
| Revenue tracking for LTV analytics | Stream<AdEvent> emits AdRevenueEvent per impression |
| Sane behavior when Android kills the process under memory pressure | Smart App-Open timeout (lifecycle-aware), process-restart marker, detached state warning |
What's new in 1.0.19 #
1.0.20 is an example-only release — the bundled example's splash now demos the recommended
requestAtt() → requestUmpConsent() → initialize()ordering. No library / public-API change vs 1.0.19.
Backwards-compatible with 1.0.1x. Recent additions:
- iOS App Tracking Transparency (1.0.19) —
AdManager().requestAtt()/requestAttIfNeeded()show the ATT prompt when needed and return a structuredAttResult { status, idfa, allowsTracking }(AttStatusenum). No-op on Android; never throws. Call it in the splash before UMP. See Consent → Option 0. - iOS App-Open watchdog fix (1.0.19) — the lifecycle-aware show timeout no
longer force-dismisses on iOS. On iOS the ad shows while the app stays
resumed, so the Android-only "foreground = hung" heuristic was force-closing every iOS App Open at ~10 s; iOS now relies on the native hidden/displayFailed callbacks plus the 90 s hard cap. - First-install anti-bypass guard (1.0.17) — the first-install VIP grace is
protected against uninstall/reinstall bypass (iOS Keychain flag; Android Auto
Backup of
SharedPreferences).
Earlier, the 1.0.15 release added:
- Cupertino consent dialog — opt-in via
AdConfig.autoShowConsentDialog: true(the default). Auto-shows on the home screen ~1 second after the splash flow completes, never during splash. Skipped automatically for VIP users. Persists the user's choice; surfaces the choice viaConsentManager.instancefor re-show from a Privacy settings page. - Google UMP wrapper —
AdManager().requestUmpConsent(...)calls intogoogle_mobile_ads's built-in UMP API (no extra dependency needed sincegoogle_mobile_ads6.x). Returns a structuredUmpConsentResult { canRequestAds, status, formShown, error }. - First-install VIP grace —
AdConfig.firstInstallVipGrace: FirstInstallVipGrace.auto(default). Auto-grants a one-time VIP entry on the very first SDK init for this install. Default: 30 seconds in debug builds, 24 hours in release. Tracked viaSharedPreferencesso the grant fires exactly once per install. - Smart App-Open timeout — replaces a fixed 10-second timeout that produced false-positive force-dismisses when users clicked an ad and were sent to a browser for 20+ seconds. The timeout polls the app lifecycle every 5 seconds (re-arms while paused), with a 90-second hard cap. On Android it force-dismisses when the app is foreground for two consecutive ticks without
onAdHiddenCallback(= hung overlay). On iOS the ad shows while the app staysresumed, so foreground is ignored and only the native callbacks + 90 s hard cap apply (fixed in 1.0.19). - Slot-state dismiss watcher — replaces the brittle adapter-callback timestamp writes that used to fire at the wrong moment for rewarded ads (rewarded
onDonefires when the reward is earned, not when the user actually dismisses). The watcher hooks every fullscreen slot'sstate.valueand records the dismiss instant onshowing → !showing. Source of truth for the resume guard. - VIP auto-expire timer —
VipManagernow schedules aTimerfor the soonestexpiresAt. When it fires, the manager purges the expired entry, refreshes the active flag, andAdManager(listening tovip.activeListenable) preloads all four ad slots so the next user-triggered show finds an ad ready. - Granular diagnostic logging — every gate (
adapter null,VIP,no network,slot showing, safety reason, recent dismiss) emits an explicit⏭️ skipped — <reason>log instead of returning silently. Process-restart marker🚀 AdManager singleton CREATEDfires once per process so two markers in the same logcat session indicate Android killed and restarted the app. Lifecycle observer logs full state (prev → current, slot states, VIP, splash flag, backgrounded duration).
See CHANGELOG.md for the full list, including all bug fixes.
Quick start #
Audience: developers integrating ads into a fresh Flutter app. No prior AdMob or AppLovin experience required. Each step is copy-paste.
Prerequisites #
- Flutter 3.27.0 or newer
- Android
minSdkVersion24 or newer (AppLovin MAX 13.x + AdMob requirement) - iOS deployment target 13.0 or newer (required by AppLovin MAX 13.x and
app_tracking_transparency) - An AdMob account (for AdMob ad units), an AppLovin account (for AppLovin), or both. The SDK ships Google's public test ad unit IDs so you can verify integration before creating real units.
Step 1 — Add the dependency #
Edit your app's pubspec.yaml:
dependencies:
applovin_admob_sdk: ^1.0.20
# Optional — only if you want to use AppLovin as an AdMob mediation network.
# Skip this line if you are using AppLovin directly via AdProvider.appLovin
# or AdMob without mediation.
gma_mediation_applovin:
Then run:
flutter pub get
Step 2 — Android configuration #
Open android/app/src/main/AndroidManifest.xml and add the three permissions inside <manifest>:
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="com.google.android.gms.permission.AD_ID"/>
Inside <application>, add the two <meta-data> tags below. Replace each value with your real key from the respective dashboard. The placeholders below use Google's public test App ID (always valid) and a placeholder for the AppLovin SDK key:
<application
android:label="My App"
android:icon="@mipmap/ic_launcher">
<!-- Required by google_mobile_ads even if you only use AppLovin.
Get yours from https://admob.google.com → Settings → App ID. -->
<meta-data
android:name="com.google.android.gms.ads.APPLICATION_ID"
android:value="ca-app-pub-3940256099942544~3347511713"/>
<!-- NOTE: `applovin_max` 4.x (used by this SDK) does NOT read the SDK key
from a manifest meta-data. The 86-character key is passed at runtime via
`AppLovinConfig.sdkKey` → `AdManager().initialize(...)`. You do NOT need
an `applovin.sdk.key` meta-data here; adding one is harmless but ignored.
Get the key from https://dash.applovin.com/o/account → Account → Keys. -->
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
...>
<!-- ⚠️ Do NOT add android:taskAffinity="" here. See "Pitfalls" below. -->
</activity>
</application>
Update android/app/build.gradle.kts (or build.gradle) to require Android 5.0 or newer:
android {
defaultConfig {
minSdk = 24
// ...
}
}
Step 3 — iOS configuration #
Open ios/Runner/Info.plist and add the keys below at the root <dict>. Replace YOUR_… placeholders:
<!-- AdMob App ID — must match the Android one for the same app -->
<key>GADApplicationIdentifier</key>
<string>ca-app-pub-3940256099942544~1458002511</string>
<!-- AppLovin SDK Key — must match the Android one -->
<key>AppLovinSdkKey</key>
<string>YOUR_86_CHARACTER_APPLOVIN_SDK_KEY_HERE</string>
<!-- Required since iOS 14.5: shown in the system ATT prompt -->
<key>NSUserTrackingUsageDescription</key>
<string>This identifier is used to deliver personalised ads.</string>
<!-- Required by AdMob & AppLovin on iOS 14.5+. Copy the canonical list
from https://developers.google.com/admob/ios/ios14#skadnetwork -->
<key>SKAdNetworkItems</key>
<array>
<!-- ~70 entries — paste from the link above -->
</array>
Update ios/Podfile to require iOS 13 or newer:
platform :ios, '13.0'
Then install pods:
cd ios && pod install && cd ..
Step 4 — Bootstrap the SDK in main.dart #
Replace your lib/main.dart with this:
import 'package:flutter/material.dart';
import 'package:applovin_admob_sdk/applovin_admob_sdk.dart';
import 'splash_screen.dart';
/// Global navigator key — required so the SDK can show consent dialogs and
/// loading buffers from a context-less callback path (e.g., from the lifecycle
/// observer when an ad dismisses).
final navigatorKey = GlobalKey<NavigatorState>();
void main() {
WidgetsFlutterBinding.ensureInitialized();
// ⚠️ This MUST be called before runApp(). The SDK's auto-show consent
// dialog and app-open-on-resume buffer rely on this navigator.
AdManager().setNavigatorKey(navigatorKey);
runApp(MaterialApp(
title: 'My App',
navigatorKey: navigatorKey,
// ⚠️ Both observers are required:
// - adRouteObserver: pauses banner refresh on navigation,
// resumes when the route comes back to top
// - AdScreenRouteLogger: emits route push/pop logs (debug only)
navigatorObservers: [adRouteObserver, AdScreenRouteLogger()],
home: const SplashScreen(),
));
}
Step 5 — Initialize the SDK in splash_screen.dart #
Create lib/splash_screen.dart. Replace the five TODO ad-unit IDs with values from your AppLovin dashboard. The AdMob IDs are Google's public test units and can be left as-is for verification:
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:applovin_admob_sdk/applovin_admob_sdk.dart';
import 'home_screen.dart';
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key});
@override
State<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen> {
Timer? _hardCap;
bool _navigated = false;
@override
void initState() {
super.initState();
AdManager().markSplashActive();
AdManager().incrementSplashCount();
// If the user reopens the app while the splash is still on the stack
// (rare race), short-circuit straight to home.
if (AdManager().countInitSplashScreen > 1) {
WidgetsBinding.instance.addPostFrameCallback((_) => _goHome());
return;
}
// Hard cap: if the SDK init or the splash app-open ad takes longer than
// the budget (network issues, etc.), force-navigate so the user is not
// stuck on the splash screen. Keep this in sync with
// `AdConfig.splashMaxDuration` (default 8 s).
_hardCap = Timer(const Duration(seconds: 8), _goHome);
// ⚠️ Subscribe BEFORE calling initialize(). SimpleEventBus only
// delivers fire events to listeners that registered before the fire.
SimpleEventBus().listen((BoolEvent e) {
if (e.value) {
_showSplashAppOpen();
} else {
_goHome();
}
});
WidgetsBinding.instance.addPostFrameCallback((_) {
AdManager().initialize(
config: AdConfig(
// Pick one. Switch by changing this single line.
provider: AdProvider.appLovin,
// TODO: replace with your real keys from dash.applovin.com
appLovin: const AppLovinConfig(
sdkKey: 'YOUR_86_CHARACTER_APPLOVIN_SDK_KEY_HERE',
bannerId: 'YOUR_BANNER_AD_UNIT_ID',
interstitialId:'YOUR_INTERSTITIAL_AD_UNIT_ID',
appOpenId: 'YOUR_APP_OPEN_AD_UNIT_ID',
rewardedId: 'YOUR_REWARDED_AD_UNIT_ID',
),
// AdMob test units — public, always valid. Replace with your real
// ad unit IDs (from admob.google.com) before publishing the app.
admob: const AdMobConfig(
bannerId: 'ca-app-pub-3940256099942544/6300978111',
interstitialId: 'ca-app-pub-3940256099942544/1033173712',
appOpenId: 'ca-app-pub-3940256099942544/9257395921',
rewardedId: 'ca-app-pub-3940256099942544/5224354917',
),
// Optional: localise the auto-show consent dialog.
// ConsentDialogStrings.vi for Vietnamese, or pass your own.
// consentDialogStrings: ConsentDialogStrings.vi,
// Optional: validate redeemed VIP keys against your server.
// vipKeyValidator: (key) => myServer.verifyVipKey(key),
),
onComplete: (success, gaid) {
// Optional: log to your analytics here.
debugPrint('SDK init complete: success=$success gaid=$gaid');
},
);
});
}
void _showSplashAppOpen() {
AdManager().loadAppOpenAd(onAdLoaded: (loaded) {
if (_navigated || !mounted) return;
if (!loaded) {
_goHome();
return;
}
AdLoadingDialog.showAdBuffer(context, onComplete: () {
if (!mounted) {
_goHome();
return;
}
// Cancel the hard cap BEFORE showAppOpenAd — the ad now owns the
// splash screen, so we should not race-fire markSplashInactive.
_hardCap?.cancel();
_hardCap = null;
AdManager().showAppOpenAd(
// bypassSafety: true is the ONE place we override safety —
// splash app-open is a privileged placement.
bypassSafety: true,
onAdDismiss: (_) => _goHome(),
);
});
});
}
void _goHome() {
if (_navigated) return;
_navigated = true;
_hardCap?.cancel();
_hardCap = null;
AdManager().markSplashInactive();
if (!mounted) return;
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const HomeScreen()),
);
}
@override
void dispose() {
_hardCap?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) => const Scaffold(
backgroundColor: Colors.deepPurple,
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.ads_click, size: 80, color: Colors.white),
SizedBox(height: 24),
CircularProgressIndicator(color: Colors.white),
],
),
),
);
}
Step 6 — Show ads on any screen #
Create lib/home_screen.dart. Any screen that should display ads extends AdScreen and uses AdScreenState instead of StatefulWidget and State. This gives you buildBanner(), showInterstitialAd(...), and showRewardedAd(...) automatically:
import 'package:flutter/material.dart';
import 'package:applovin_admob_sdk/applovin_admob_sdk.dart';
class HomeScreen extends AdScreen {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends AdScreenState<HomeScreen> {
int _coins = 0;
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('My App')),
body: Column(
children: [
// Anchored adaptive banner. Auto-loads, auto-pauses on
// navigation, auto-resumes on return, auto-skips if VIP.
buildBanner(),
const SizedBox(height: 24),
Text('Coins: $_coins', style: const TextStyle(fontSize: 24)),
const SizedBox(height: 24),
// Interstitial — full-screen ad after a user action
FilledButton(
onPressed: () => showInterstitialAd(
onDone: (shown) {
// Called whether or not the ad actually appeared.
// shown=true → ad was displayed and dismissed
// shown=false → blocked by safety, VIP, no network, etc.
debugPrint('Interstitial result: $shown');
},
),
child: const Text('Show interstitial'),
),
const SizedBox(height: 12),
// Rewarded — user opts in to watch in exchange for a reward
FilledButton(
onPressed: () => showRewardedAd(
onEarnedReward: (earned) {
if (earned) {
setState(() => _coins += 10);
}
},
),
child: const Text('Watch ad for +10 coins'),
),
],
),
);
}
That's the entire integration. Run:
flutter run
You should see the splash screen, then a splash app-open ad (if available), then the home screen. After ~1 second on the home screen, the consent dialog appears (skipped on subsequent launches once the user has answered). The default behaviour you get out-of-the-box:
- ✅ First-install VIP grace 24h — the user does not see ads during their first 24 hours after install. Tunable via
AdConfig.firstInstallVipGrace. - ✅ Cupertino consent dialog auto-shown ~1 second after splash on the home screen (skipped if VIP). Tunable via
AdConfig.autoShowConsentDialog,consentDialogStrings,consentDialogPostSplashDelay. - ✅ Splash app-open ad with an 8-second hard cap so the user is never stuck.
- ✅ Banner pause/resume automatically when the user navigates between screens.
- ✅ Anti-fraud multi-layer safety gate protects your AdMob/AppLovin account.
Configuration reference #
AdConfig #
AdConfig({
// ─── Provider selection ─────────────────────────────────────────
required AdProvider provider,
AppLovinConfig? appLovin,
AdMobConfig? admob,
// ─── First-install VIP grace ────────────────────────────────────
FirstInstallVipGrace firstInstallVipGrace = FirstInstallVipGrace.auto,
String firstInstallVipKey = '__FIRST_INSTALL__',
// ─── Consent flow ───────────────────────────────────────────────
bool autoShowConsentDialog = true,
ConsentDialogStrings consentDialogStrings = const ConsentDialogStrings(),
bool consentBarrierDismissible = false,
Duration consentDialogPostSplashDelay = const Duration(seconds: 1),
// ─── Logging ────────────────────────────────────────────────────
AdLogLevel logLevel = AdLogLevel.verbose,
List<String>? logTagFilter,
AdLogSink? onLog,
// ─── Safety / fraud protection ──────────────────────────────────
AdSafetyParams safety = AdSafetyParams.auto,
// ─── VIP ────────────────────────────────────────────────────────
Future<bool> Function(String key)? vipKeyValidator,
VipDialogStrings vipDialogStrings = const VipDialogStrings(),
// Legacy 1.x GAID allow-list — auto-migrated to VipManager entries
// (year-2099 expiry) on first init for the matching device only.
List<String> vipDeviceGaids = const [],
// ─── Splash flow ────────────────────────────────────────────────
Duration splashMaxDuration = const Duration(seconds: 8),
// ─── User-facing strings ────────────────────────────────────────
String adNotReadyMessage = 'Ad not ready — please wait and try again.',
String adLoadingMessage = 'Loading…',
// ─── Loading buffer ─────────────────────────────────────────────
int loadingBufferMs = 1000,
})
FirstInstallVipGrace #
Build-mode-aware presets — picks the right duration based on kDebugMode:
FirstInstallVipGrace.auto // 30s in debug, 24h in release (DEFAULT)
FirstInstallVipGrace.disabled // never grant
FirstInstallVipGrace.day // force 24h in both modes
FirstInstallVipGrace.debugShort // force 30s in both modes
const FirstInstallVipGrace(Duration(hours: 12)) // custom
The grant fires exactly once per install. Calling AdManager.destroy() followed by AdManager.initialize() in the same process does not re-grant.
AdSafetyParams presets #
AdSafetyParams.auto // production in release, debug in debug (DEFAULT)
AdSafetyParams.production // strict caps for real users
AdSafetyParams.debug // loose caps for QA testing
AdSafetyParams.production.copyWith(
maxFullscreenAdsPerDay: 10,
dryRun: kDebugMode,
) // override individual knobs
AdLogLevel #
AdLogLevel.verbose // everything (DEFAULT)
AdLogLevel.warning // warnings + errors only
AdLogLevel.error // errors only
AdLogLevel.none // silent
VIP system #
Conflict policy: latest-expiry-wins vs. global stacking #
The stack flag decides how a grant combines with existing VIP time:
stack |
Behaviour | Use for |
|---|---|---|
false (default) |
Latest-expiry-wins — when an entry with the same key exists, the new now + duration replaces it only if it expires later; otherwise the existing (longer) entry is kept. |
Purchases/restore where you set an absolute window. |
true |
Global stacking (cộng dồn toàn cục) — duration is added on top of the latest expiry across ALL active entries (any source). Every grant extends one growing VIP window; the granted key's entry becomes the new latest (created if new, updated if it existed) and grantedAt resets to now. |
"Redeem code", "watch ad → +N days" — all accumulate. |
// Global stacking: grants from ANY key add to one timeline.
await vip.addVip(key: 'WATCH', duration: const Duration(days: 6), stack: true); // 6d
await vip.addVip(key: 'PROMO30', duration: const Duration(days: 30), stack: true); // 36d total
await vip.addVip(key: 'PROMO30', duration: const Duration(days: 30), stack: true); // 66d total
Optional cap. Set AdConfig.maxVipStackDuration to bound the total stacked
window — a stacked grant is then clamped to now + maxVipStackDuration (excess
dropped; the entry still extends up to the cap). null (default) = uncapped.
Only the stacking path is clamped; a plain absolute addVip is never touched.
Programmatic add (purchase / restore flow) #
Use this when the user purchases a VIP unlock through your IAP flow:
await AdManager().vip!.addVip(
key: 'PURCHASED_PREMIUM_${transactionId}',
duration: const Duration(days: 365),
);
Cupertino dialog redeem (user inputs a key) #
Use this if you ship promo/redeem keys for VIP. The SDK shows a verifying → success/failed Cupertino dialog flow:
final didRedeem = await AdManager().vip!.redeemVip(
context,
key: userInputKey,
duration: const Duration(days: 30),
validator: (key) async {
// Validate against your server. Return true if valid.
final response = await myServer.verifyVip(key);
return response.isValid;
},
strings: AdManager().config?.vipDialogStrings ?? const VipDialogStrings(),
stack: true, // accumulate onto the current window instead of replacing
);
Watch a rewarded ad to EXTEND VIP (even while already VIP) #
By default the SDK suppresses every ad for a VIP member, so a rewarded ad will
not play (showRewardedAd calls back with vipAutoGrant). To let a VIP
voluntarily watch a real rewarded ad to top up their window, pass
bypassVipGuard: true. The slot isn't preloaded while VIP, so the SDK
load-on-demands it before showing:
AdManager().showRewardedAd(
bypassVipGuard: true, // play a real ad even for a VIP
onEarnedReward: (earned) {
if (!earned) return; // only granted on a completed ad — never auto-granted
AdManager().vip?.addVip(
key: 'REWARDED_VIP', // fixed key + stack → one accumulating entry
duration: const Duration(days: 3),
stack: true,
);
},
);
During the on-demand load the SDK shows a blocking loading dialog and waits up
to onDemandLoadTimeout (default 15 s, tunable per call). showRewardedAd is
re-entrancy-safe — a second tap while a load/show is in flight is rejected with
onEarnedReward(false).
Policy note: this is compliant because a real ad is always shown. Do not instead grant VIP without an ad — that loses revenue and risks rewarded-ad policy violations. Spam is bounded by the SDK's fullscreen safety caps.
Check VIP state #
// Synchronous check
if (AdManager().vip?.isActive ?? false) {
// User is VIP — render premium UI
}
// Reactive — rebuilds when VIP state changes
ValueListenableBuilder<bool>(
valueListenable: AdManager().vip!.activeListenable,
builder: (_, active, __) => active
? const VipBadge()
: const SizedBox.shrink(),
)
// Stream — for analytics / side effects
AdManager().vip!.activeStream.listen((active) {
analytics.logEvent('vip_state_changed', {'active': active});
});
Revoke #
// Specific key (e.g., user requested refund)
await AdManager().vip!.revokeVip('PURCHASED_PREMIUM_${transactionId}');
// All entries (e.g., logout)
await AdManager().vip!.revokeAll();
Disable first-install grace #
If your app does not want the 24-hour grace (some genres prefer to monetize immediately):
AdConfig(
firstInstallVipGrace: FirstInstallVipGrace.disabled,
// ...
)
Anti-bypass guard #
The grace is protected against the trivial bypass of "uninstall + reinstall to claim a fresh 24-hour window." The guard runs automatically inside AdManager.initialize — host apps need no code changes for the iOS side. Android requires host-app Auto Backup configuration (see below).
| Platform | Mechanism | Bypass-blocked scenarios | Limitations |
|---|---|---|---|
| iOS | Boolean flag in Keychain (kSecAttrAccessibleAfterFirstUnlock, no sync) |
Uninstall + reinstall on same device | "Erase All Content and Settings" wipes Keychain; encrypted backup → new device carries the flag |
| Android | Host's SharedPreferences flag (isFirstInstallGraceApplied) restored from Google Cloud Auto Backup |
Play Store reinstall on same Google account, after the ~24 h backup window | Reinstall within ~24 h of install (before Auto Backup runs); user disables cloud backup; cross-account reinstall |
Android — required host-app configuration
The SDK does not bundle a Play Install Referrer plugin: per Google's docs, Install Referrer timestamps reset on reinstall, so the API cannot distinguish a fresh install from a reinstall on its own. The realistic Android anti-bypass is Google Auto Backup restoring the grace flag from SharedPreferences on Play Store reinstall.
To enable it, in android/app/src/main/AndroidManifest.xml:
<application
android:allowBackup="true"
android:fullBackupContent="@xml/full_backup_content"
android:dataExtractionRules="@xml/data_extraction_rules">
Create android/app/src/main/res/xml/data_extraction_rules.xml (Android 12+):
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<include domain="sharedpref" path="FlutterSharedPreferences.xml"/>
<exclude domain="sharedpref" path="FlutterSecureStorage.xml"/>
</cloud-backup>
<device-transfer>
<include domain="sharedpref" path="FlutterSharedPreferences.xml"/>
<exclude domain="sharedpref" path="FlutterSecureStorage.xml"/>
</device-transfer>
</data-extraction-rules>
And full_backup_content.xml (Android 6–11):
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<include domain="sharedpref" path="FlutterSharedPreferences.xml"/>
<exclude domain="sharedpref" path="FlutterSecureStorage.xml"/>
</full-backup-content>
FlutterSecureStorage.xml is excluded because its EncryptedSharedPreferences ciphertext is unrecoverable without the device-bound Keystore key (which is not part of any backup).
Without this configuration, Android anti-bypass is effectively disabled — uninstall + reinstall always re-grants the grace window. That is a valid choice if you want to allow the bypass; just be aware of the trade-off.
Debug builds always bypass the guard
So you can iterate on flutter run without being locked out of the grace UX. Anti-bypass validation must happen on signed release builds (TestFlight / Play Store internal track).
Fail-open
The guard never denies grace on storage errors — it fails open so a transient Keychain hiccup never punishes a legitimate first-time user.
Consent & compliance #
The SDK supports three patterns. Pick whichever matches your release strategy.
Option 1 — Built-in Cupertino dialog (simplest, default) #
The SDK auto-shows a clean Cupertino dialog ~1 second after markSplashInactive, so it lands on the home screen rather than competing with the splash app-open ad. Persists the user's choice to SharedPreferences. Skipped automatically for VIP users.
No code required — this is the default. To re-show from a Privacy settings screen:
await ConsentManager.instance.showDialog(context);
To localize:
AdConfig(
consentDialogStrings: ConsentDialogStrings.vi, // Vietnamese pre-canned
// or supply your own:
consentDialogStrings: const ConsentDialogStrings(
title: 'Privacy Preferences',
message: 'This app shows ads to keep it free. ...',
allowButton: 'Allow personalized ads',
rejectButton: 'No thanks',
privacyPolicyLabel: 'Privacy Policy',
privacyPolicyUrl: 'https://yourapp.com/privacy',
),
)
To disable auto-show entirely (e.g., if you have your own consent UI):
AdConfig(
autoShowConsentDialog: false,
)
Option 0 — iOS App Tracking Transparency (call FIRST on iOS) #
ATT is built into the SDK — do not call app_tracking_transparency
directly. Call requestAtt() from your splash screen (after the first
frame), before requestUmpConsent and initialize, so the IDFA
availability is settled before the first ad request:
final att = await AdManager().requestAtt();
// att.status → AttStatus.{notSupported|notDetermined|restricted|denied|authorized}
// att.idfa → String? (only when authorized and non-zero)
// att.allowsTracking → bool (true when authorized, or non-iOS where ATT doesn't apply)
- No-op on Android — returns
AttStatus.notSupportedimmediately. - On iOS it shows the system prompt only when the status is
notDetermined; an already-decided status is returned without re-prompting. - Never throws — a missing plugin / Info.plist key degrades to
denied. - Do NOT call from
main()beforerunApp— Apple rejects ATT prompts shown over a blank screen. - Requires
NSUserTrackingUsageDescriptioninInfo.plist(see Setup). - ATT is independent of the GDPR consent flag — the native AppLovin/AdMob
SDKs read the ATT status directly when deciding IDFA usage, so
requestAtt()does not callsetConsent.
Option 2 — Google UMP form (required for EEA users on AdMob) #
Wrap Google's UMP API. Call this in your splash after requestAtt() and
before AdManager().initialize:
final result = await AdManager().requestUmpConsent(
testMode: kDebugMode,
debugGeography: DebugGeography.debugGeographyEea,
testIdentifiers: kDebugMode ? const ['<your-device-hash>'] : const [],
);
if (!result.canRequestAds) {
// User denied consent. You can either:
// - Skip ad initialization entirely
// - Initialize with non-personalized ads only
return;
}
// Continue with AdManager().initialize(...) as normal
Option 3 — Manual flag set (you have your own UI) #
If you already integrate a third-party CMP and just want the SDK to forward the flags to the providers:
await AdManager().setConsent(AdConsent(
hasUserConsent: true, // GDPR consent
isAgeRestrictedUser: false, // COPPA: app targets children < 13
doNotSell: false, // CCPA: California user opts out of data sale
));
Compliance checklist #
- ❌
app-ads.txtplaced at the root of your app's domain - ❌ Privacy Policy URL declared in App Store / Play Store listing
- ❌ iOS App Tracking Transparency prompt shown via
AdManager().requestAtt()in the splash, beforerequestUmpConsent/AdManager().initialize(see Option 0) - ❌ If app targets children,
isAgeRestrictedUser: true(COPPA) - ❌ If targeting EEA users, integrate UMP via Option 2 above
Debugging #
Built-in debug overlay #
Wrap your MaterialApp builder to mount a floating debug panel that appears only in debug builds:
runApp(MaterialApp(
// ...
builder: (context, child) {
if (child == null) return const SizedBox.shrink();
return Stack(children: [
child,
const DebugAdOverlay(),
]);
},
));
A 🐛 Ad pill appears in the bottom-left corner. Tap to expand into a panel showing realtime SDK state: slot states (idle/loading/ready/showing/cooldown), VIP status, init flag, splash flag, safety status. Auto-hidden in release builds.
Verbose logs #
Every SDK log is prefixed with roy93~ [Tag] for easy grep. Examples:
roy93~ [AdManager] 🚀 AdManager singleton CREATED — new Flutter process / cold start at 2026-04-26T13:06:09.808
roy93~ [AdManager] initialize start, provider=appLovin
roy93~ [AppLovinAdapter] inter [AppLovin] ✅ displayed | network=AppLovin creativeId=1540789 latency=792ms
roy93~ [VipManager] ⏰ VIP entry expired — purging + refreshing
roy93~ [AdManager] 🛡️ interstitial dismissed — app-open suppression armed
roy93~ [AdManager] ⏭️ app-open on resume skipped — interstitial/rewarded currently showing
Pipe logs into Crashlytics / Sentry #
AdConfig(
onLog: (level, tag, message) {
if (level == AdLogLevel.error) {
FirebaseCrashlytics.instance.log('[$tag] $message');
}
if (level == AdLogLevel.warning) {
Sentry.captureMessage('[$tag] $message');
}
},
// ...
)
Process-restart marker #
If you see two 🚀 AdManager singleton CREATED markers in the same logcat session, Android killed and restarted your app between them — typically because of memory pressure while the user had a long ad open. The user perceives this as "the app crashed". Use this signal to size your in-memory cache budget appropriately.
Pitfalls #
1. Do NOT set android:taskAffinity="" #
Flutter's flutter create template adds android:taskAffinity="" to MainActivity by default in some Flutter versions. Remove it when integrating this SDK with AppLovin:
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
- android:taskAffinity=""
...>
Why: AppLovin's full-screen ad activity (AppLovinFullscreenActivity) inherits the application's default task affinity, which is the package name. With android:taskAffinity="" on MainActivity, the two activities end up in different Android tasks. After the user presses HOME and reopens the app, the activity stack management breaks; when the user dismisses the ad, no activity is available to return to and Android drops the user to the launcher. The user perceives this as a crash.
2. iOS requires SKAdNetworkItems #
Without SKAdNetworkItems in Info.plist, AdMob and AppLovin will not serve ads on iOS 14.5+. Copy the canonical list from AdMob's iOS 14 guide — it has roughly 70 entries.
3. AppLovin has no public test ad units #
Unlike AdMob, AppLovin requires a real account and real ad unit IDs. To avoid being charged for development impressions, register your test device in dash.applovin.com → MAX → Test Mode. The SDK auto-registers the current device's GAID in debug builds via AppLovinMAX.setTestDeviceAdvertisingIds(...) so this is mostly handled for you.
4. setNavigatorKey must be called before runApp #
If you forget, the auto-show consent dialog has no BuildContext to use and silently skips. The dialog will eventually surface on a future launch, but better to wire it correctly the first time.
5. Initialize the SDK in SplashScreen, not main #
The SDK fires a BoolEvent over SimpleEventBus when initialization completes. Listeners must be registered before the fire — SimpleEventBus does not buffer past events for late subscribers. The conventional pattern is:
splash.initState: register the listenersplash.initState: scheduleAdManager().initializevia a post-frame callback- The init completes,
BoolEventfires, listener runs
If you initialize in main directly, the listener registration in your splash will miss the fire and the splash will hang on the hard cap.
6. Slot state after dismiss #
The SDK's _lastFullscreenDismissAt is recorded by a slot-state watcher on the showing → !showing transition, not by adapter callbacks. This is the source of truth for the resume-guard window. If you wrap or override slot state mutation, ensure the transition still fires (slot.markDismissed() or equivalent).
Public API #
AdManager singleton #
AdManager() // factory; returns the singleton
AdManager().setNavigatorKey(key) // call before runApp (REQUIRED)
AdManager().initialize(config, onComplete) // call once in splash
AdManager().destroy() // teardown for hot-reinit / test cleanup
AdManager().markSplashActive()
AdManager().markSplashInactive()
AdManager().incrementSplashCount()
AdManager().setConsent(adConsent) // GDPR / COPPA / CCPA flags
AdManager().requestAtt() // iOS ATT prompt (no-op Android) → AttResult
AdManager().requestUmpConsent(...) // Google UMP wrapper
AdManager().showAppOpenAd(onAdDismiss)
AdManager().showInterstitial(onDoneFlow)
AdManager().showRewardedAd(onEarnedReward, {vipAutoGrant, bypassVipGuard, onDemandLoadTimeout})
AdManager().loadAppOpenAd(onAdLoaded)
AdManager().canShowInterstitial()
AdManager().isInitialised // bool
AdManager().vip // VipManager? (null before init)
AdManager().consentManager // ConsentManager?
AdManager().adapter // AdProviderAdapter?
AdManager().consent // current AdConsent flags
AdManager().events // Stream<AdEvent>
AdManager().initRevision // ValueNotifier<int> — bumps on init
AdManager().processStartedAtMs // wall-clock of singleton creation
AdScreen #
A base class for screens that display ads. Mirror replacement for StatefulWidget/State:
class HomeScreen extends AdScreen {
const HomeScreen({super.key});
@override State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends AdScreenState<HomeScreen> {
Widget buildBanner(); // anchored adaptive banner
void showInterstitialAd({required onDone, ...}); // pre-check + buffer + show
void showRewardedAd({required onEarnedReward, ...}); // same
}
VipManager #
final vip = AdManager().vip!;
vip.addVip(key, duration, {stack}) // Future<VipEntry> — stack:true accumulates
vip.redeemVip(context, ..., {stack}) // Future<bool> — full Cupertino flow
vip.revokeVip(key) // Future<void>
vip.revokeAll() // Future<void>
vip.isActive // bool
vip.activeListenable // ValueListenable<bool>
vip.activeStream // Stream<bool>
vip.expiresAt // DateTime? — latest active entry
vip.entries // List<VipEntry> — read-only snapshot
ConsentManager #
final mgr = ConsentManager.instance;
mgr.current // ConsentSettings
mgr.listenable // ValueListenable<ConsentSettings>
mgr.hasBeenAsked // bool
mgr.adConsent // AdConsent — runtime flag projection
mgr.showDialog(context) // re-show binary dialog
mgr.set(settings) // programmatic update + persist
mgr.applyToProviders() // re-apply current to providers
mgr.reset() // wipe state — next init re-prompts
Stream<AdEvent> #
Pipe into Firebase / AppsFlyer / etc. for LTV tracking:
AdManager().events.listen((event) {
if (event is AdRevenueEvent) {
analytics.logAdRevenue(
currency: event.currencyCode,
value: event.value,
network: event.networkName,
);
}
if (event is AdRewardEvent) {
analytics.logEvent('ad_reward', {'amount': event.amount});
}
});
Event types: AdLoadEvent, AdShowEvent, AdClickEvent, AdRewardEvent, AdRevenueEvent.
FAQ #
Do I need both AdMob and AppLovin accounts? #
No. The provider you specify in AdConfig.provider determines which one is active at runtime. The other config struct (appLovin or admob) is unused but the constructor still requires the matching one to be non-null. Pass placeholder values for the unused one.
Can I switch providers at runtime? #
Not safely. Both SDKs are designed to initialize once per process. To swap, call AdManager().destroy(), change the config, and call AdManager().initialize() again — but be aware the user will see splash transitions and ad reload latency. Most apps pick one provider per build configuration.
How do I test the first-install grace? #
In debug builds, the grace defaults to 30 seconds. Wipe the app data and re-launch:
adb shell pm clear com.your.package
flutter run
The SDK logs 🎁 first-install VIP grace granted (30s, mode=debug) on a fresh install. After 30 seconds the timer fires, 🔓 VIP inactive — kicking secondary preload logs, and ads start serving.
Debug builds bypass the anti-bypass guard, so each flutter run cycle grants a fresh grace.
How do I test the anti-bypass guard? #
Anti-bypass only runs on release builds. Build a signed release and install it the way real users would:
- iOS — TestFlight or a signed Ad Hoc build. Install, wait for grace to expire (30 s in debug, 24 h in release — temporarily set
firstInstallVipGrace: FirstInstallVipGrace.debugShortin your test build to keep the cycle short), uninstall, then reinstall. Look for🛡️ Keychain flag present — prior install detected on this devicein the splash log on the second install. - Android — Play Store internal testing track (Auto Backup must be configured — see "Android — required host-app configuration" above). Wait long enough for Auto Backup to run (typically ~24 h after first launch, or trigger manually via
adb shell bmgr backupnow <package>). Then uninstall and reinstall. The grace block should be skipped because the restored prefs flag short-circuits before the guard runs.
Sideload via adb install of a release APK will simply re-grant the grace window each time — this is expected behaviour now that the SDK no longer ships an Install Referrer-based conservative skip.
My ad is not showing — how do I debug? #
- Check the log for
⏭️ skipped — <reason>. The SDK emits an explicit reason for every gate (adapter null, VIP, no network, slot showing, safety throttle, recent dismiss). The reason will tell you exactly what to fix. - Check the
DebugAdOverlayfor the slot state.idlemeans no load attempted;loadingmeans in-flight;readymeans good to show;cooldownmeans a recent failure backed off;showingmeans already on screen. - Verify your real ad unit IDs are not paused or pending review in the AdMob/AppLovin dashboard.
- AppLovin specifically: check that test mode is enabled for your device (
dash.applovin.com → MAX → Test Mode).
Why does the app appear to crash when the user backgrounds during an ad? #
If you see two 🚀 AdManager singleton CREATED markers in your logcat session, Android killed and restarted your process while the user was viewing an ad with the app backgrounded. This is OS behavior — the SDK cannot prevent it directly, but you can mitigate by:
- Reducing the number of ads cached simultaneously (e.g., disable banner preload during interstitial show)
- Implementing state restoration so the user lands back on the same screen after the cold restart
- Showing fewer or shorter ads on memory-constrained device classes
If you see only one 🚀 CREATED marker but the app still appears to crash, check that android:taskAffinity="" is not set on your MainActivity (see Pitfalls above).
What happens if the user revokes VIP halfway through a session? #
The SDK listens to VipManager.activeListenable. On true → false transition, it kicks all four ad slots into preload so the next user-triggered show finds an ad ready. The user's first ad after losing VIP may take 1-2 seconds to load (test ads load fast; real ads vary).
I see "Throttle: wait 0s" — is that a bug? #
That was a bug in 1.0.14 — sub-second waits truncated to zero. Fixed in 1.0.15: now displays "wait 645ms" or "wait 1.5s" depending on magnitude.
Migration #
See MIGRATION.md for a step-by-step guide.
- 1.0.14 → 1.0.15 — no breaking change. Update the version, run
flutter pub get, optionally removeandroid:taskAffinity=""fromMainActivity. - 1.0.1x → 1.0.19 — no breaking change. New optional
AdManager().requestAtt()for iOS ATT (call in splash before UMP); addNSUserTrackingUsageDescriptiontoInfo.plistif targeting iOS. iOS App-Open watchdog fix is automatic. - 1.0.19 → 1.0.20 — no breaking change, no API change (example-only update).
- 1.x → 2.x — backwards-compatible (deprecations, not removals). Old call sites compile and behave the same.
Support #
- Bug reports: open an issue on GitHub with
roy93~log output, SDK version, and provider (admob/appLovin) - Demo app:
packages/ad_sdk/example/lib/main.dart— 11 self-contained demo pages, one per feature - Architecture deep-dive:
doc/architecture.md— state machine, splash flow, safety gate, memory management
License #
MIT — see LICENSE file.