applovin_admob_sdk 3.6.4
applovin_admob_sdk: ^3.6.4 copied to clipboard
Dual-provider ad SDK for Flutter (AdMob + AppLovin MAX). Offline-verified VIP codes, GDPR/COPPA/CCPA consent, safety caps, 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
- Known limitations — read before adopting
- What's new in 2.0.0
- Quick start (copy-paste in 6 steps)
- Configuration reference
- VIP system
- Server-Side Verification (SSV) for rewarded ads
- Monetization Arbitrator (opt-in)
- Fill-rate monitor (opt-in)
- Native Ad (v1)
- 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 |
| Optional first-install ad-free period | firstInstallVipGrace defaults to auto (24 hours in release); partner apps should set FirstInstallVipGrace.disabled unless product, privacy, and commercial owners explicitly approve this entitlement |
| GDPR-compliant consent for EEA/UK/Switzerland users | Google UMP, wired by default (autoRequestUmpConsent: true) — AdManager().requestUmpConsent() wraps google_mobile_ads's built-in ConsentInformation API, a certified CMP |
| 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 |
| Fixed 300×250 MREC ad with the same route-aware lifecycle | buildMrec() — same navigator/adapter hooks as buildBanner(), fixed size instead of adaptive |
| Native ad that blends into your own UI | buildNative() — v1 fixed layout (see Native Ad (v1)) |
| 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 |
Known limitations — read before adopting #
This SDK has extensive automated coverage (500+ unit/widget tests, plus a cross-platform integration_test matrix on Android + iOS × both providers), and several real bugs have been found and fixed through that process. That is not the same claim as "battle-tested in production by third parties" — be clear-eyed about the gap before depending on it for revenue:
- VIP anti-bypass is durable on iOS, weak on Android. The one-time-use
ledger for redeemed keys and the first-install trial guard are backed by the
iOS Keychain, which survives an uninstall by design. Android has no
equivalent that works without a backend: both fall back to
SharedPreferences, so clearing the app's data resets the 1-day trial and lets an already-redeemed key be used again on that device. Android Auto Backup (dataExtractionRules) covers a Play Store reinstall, not "Clear data", and only when backup is enabled and the same Google account is used. Decide with that in mind before handing out keys at scale — and prefer AVP2 keys with a short--valid-days, since a key that has expired cannot be reused no matter how the device is wiped. This is a real, unfixed limitation of the "no backend" design (round-37 audit), not a bug with a pending fix — do not sell high-value/long-duration VIP tiers on this mechanism alone; pair it with Google Play Billing (or another server-verified purchase flow) for anything where an Android user repeatedly wiping app data to keep VIP for free is a real revenue risk you care about. - A leaked key is a leaked key — mitigated, not eliminated, by the
revocation list (T95). Signature verification is offline and sound — only
the public key ships, so nobody can forge NEW keys by decompiling the app.
AVP2 (default since 2.0.0) already narrows the blast radius by embedding an
expiry and an app binding in the signed payload. On top of that,
VipManager.refreshRevocationListlets you push a small signed list of revokedkids (see Revoking a leaked key) — but a key that's shared and redeemed before you notice and revoke it is still usable by whoever redeemed it first; the list only stops further redemptions of thatkid, it does not claw back a grant already made. - AppLovin MAX has no ad-freshness concept — App Open (and other formats)
can show a long-cached creative on that provider. The
isAdFresh4h/1h staleness check that AdMob's four fullscreen slots re-verify at show time (not just at load time) has no equivalent on AppLovin: the native MAX SDK exposes no load timestamp to check against. This is a platform gap, not a bug in this package — ifAdConfig.providerisAdProvider.appLovin, areadyslot can be shown regardless of how long it has sat cached. - Ad-policy risk is not this SDK's to control. It's a thin wrapper over AppLovin MAX and Google Mobile Ads. Fill rate, fraud detection accuracy, and account-level policy enforcement (suspensions, strikes) are decided by those platforms, not by this package. The built-in safety layer (daily/ hourly caps, throttle, CTR-based fraud heuristics) reduces obviously bad behavior but has not been validated against a real policy review.
- Ad placement is entirely on you (round-29 audit).
buildBanner()/buildMrec()/showInterstitialAd()hand back a widget or trigger a show — where you put it in your layout is 100% your call, and this SDK has no way to enforce Google's ad placement policy: don't place a banner/interstitial adjacent to navigation buttons, close buttons, or other tappable controls, and don't show one on a screen the user is continuously interacting with (accidental clicks are treated as invalid traffic and can risk your AdMob account). BannerAdWidget/MrecAdWidgetauto-pause when scrolled off-screen or obscured (round-39 audit fix), via avisibility_detector-backed check — no wiring needed for that case.IndexedStackbottom-nav tabs still need manual wiring, and always will: switching theindexof anIndexedStacktriggers neither aRoutepush/pop nor aTickerModechange, and — this is the part that isn't just a missing signal —IndexedStacknever even callspaint()on its non-current child, which is exactly what the automatic visibility check depends on to notice anything changed. A banner on an inactiveIndexedStacktab that isn't wired up keeps loading/serving ads the user cannot see, which risks Google's "don't refresh ads while hidden/off-screen" rule. Fix: wrap each tab's content inVisibility(maintainState: true)instead of relying onIndexedStackalone (itsTickerModecorrectly follows visibility, and the automatic check above applies) — or, if you must keep a bareIndexedStack, passactive: selectedIndex == myIndextoBannerAdWidget/MrecAdWidgetexplicitly.- The real ad show/dismiss lifecycle is only partially automatable. Real AppLovin MAX test-ad creatives expose no accessible dismiss element, so 3 of the ~15 integration_test scenarios (app-open/interstitial/rewarded dismiss) can only be verified manually, not via CI. Everything else in the lifecycle (load, show, click, reward callbacks, VIP suppression, safety gating) is automated and re-run on every change.
- AppLovin consent writes cannot be confirmed as successful (round-33
audit).
AppLovinMAX.setHasUserConsent/setDoNotSell(from theapplovin_maxpackage) are fire-and-forgetvoidmethods over a platform channel — they don't return aFuture, soapplyConsentToProviders()cannotawaitthem or catch a failed platform-channel write the way it does for AdMob'supdateRequestConfiguration(which is properly awaited, and only recorded as applied on success). In the rare case that write silently fails — a cold/backgrounded channel, a transient native exception — AppLovin can keep serving personalised ads to a user who just withdrew consent, and this SDK has no way to detect it; this is a dependency-level limitation, not something fixable purely on the Dart side. If your app operates in the EEA/UK/California withAdProvider .appLovintraffic, be aware of this before treating the AppLovin branch ofapplyConsentToProviders()as a hard guarantee. Full analysis:doc/audit/audit_round33_consolidated.md. - AdMob rewarded test ads can get permanently stuck on Android, unrelated
to this SDK. Manually verified 2026-08-08: an
AdMobAdapter-shown rewarded ad occasionally shows a frozen countdown label and a static play-icon instead of a playing video — the video silently failed to start, so the close button (drawn entirely by the native Google Mobile Ads SDK) never renders, and neither the hardware back button nor any tap dismisses it. This reproduced withgoogle_mobile_ads 7.0.0and matches known upstream reports (googleads-mobile-flutter#633, #840): the app-side code only callsshow()and waits for the nativeonAdDismissedFullScreenContentcallback, which the native SDK never fires if the underlying video never actually played. Confirmed this is not reachable from Dart — there is no app-level close affordance to add. If you hit this, it should self-clear on the next ad load/session; there is no known reliable in-session recovery besides killing and relaunching the app. Android interstitial ads were not affected (dismissed cleanly via back button in the same test pass). Known limitation:Fixed (2026-07-19). All three now poll the slot (app_open_ad_test.dart/interstitial_ad_test.dart/rewarded_ad_test.dartcallshowXAd()twice back-to-back without waiting for the ad to finish loading first._waitForAppOpenLoaded/_waitForInterstitialLoaded/_waitForRewardedLoaded) untilisReadybefore tapping show, and fail loudly instead of silently passing on the safe "ad not ready, skip" fallback. Verified with a real run on a physical Android device against live AdMob test ad units — log-confirmed real show+dismiss (including anearned=truereward) for both cycles where the safety-throttle didn't suppress the second fullscreen show.- Limited real-world production history. As of this writing the only first-party app running the hosted pub.dev release is this repo's own host app. If you're evaluating this for a partner or a new app, check that app's live AdMob/AppLovin dashboards (fill rate, policy flags, crash reports, revenue trend) over a multi-week window before treating this as proven at scale — that's stronger evidence than any test suite here.
- Single maintainer, no SLA. There's no dedicated support team behind this package; plan integration/rollback risk accordingly.
Recommended adoption path for a new consumer (including internal partners): start with a small-traffic, time-boxed pilot on one app, watching the same dashboards above for a few weeks, rather than a wholesale integration on day one.
What's new in 2.0.0 #
Breaking. Comes out of a full audit against seven production
requirements (doc/audit/audit_claude.md), cross-checked by
three independent agents, with every finding verified against source.
autoRequestUmpConsentnow defaults totrue(wasfalse). The old default meant a host that changed nothing could silently block ad requests in release builds — the built-in consent dialog never cleared the block because it applies consent directly to providers instead of routing throughgoogle_mobile_ads'ssetConsent(). Net effect: zero ads requested, silently, since the diagnosticassertis stripped in release. Hosts that already callrequestUmpConsent()themselves are auto-detected and the automatic call skips, so UMP still runs exactly once.maxVipStackDurationnow defaults to 90 days instead ofnull(uncapped). Passnullexplicitly for the old behaviour.- Signed VIP keys default to the new
AVP2format, which embeds expiry and app binding inside the signed payload. ExistingAVP1keys still verify;tool/vip_mint.dartmints AVP2 unless--v1is passed. - New dependency:
package_info_plus(reads the bundle id for AVP2 app-binding checks).
Fixed:
- Interstitial and rewarded ads could stack on each other.
showAppOpenAdOnResumeguarded against showing over a dialog, butshowInterstitialandshowRewardedeach checked only their own slot — two full-screen ads could be requested back-to-back and briefly overlap. All three paths now share one state mutex.
See CHANGELOG.md [2.0.0] for the full list.
Notable history (see CHANGELOG.md for the complete version-by-version list) #
Two gotchas from this history aren't repeated anywhere else in this README, so they're kept here even compressed:
- Nested-Navigator gap:
AdScreenRouteLoggeronly sees routes pushed on theNavigatorit's registered on. A bottom sheet opened with plainshowModalBottomSheet(defaults touseRootNavigator: false) on a nested Navigator (bottom-nav tabs, ago_routerShellRoutebranch) goes untracked, so a resumed App Open ad can show on top of it. Fix: use the SDK'sshowAdSafeModalBottomSheet(same params, alwaysuseRootNavigator: true) for any bottom sheet in a multi-Navigator app. - Overlay-based popups are invisible to the same guard: a popup built via
a raw
OverlayEntry(third-party loading/toast/coach-mark packages,SnackBar) isn't aRouteand can't be polled automatically. CallmarkCustomOverlayOnScreen(true)before inserting your own overlay andmarkCustomOverlayOnScreen(false)after removing it — opt-in; if you never call it, nothing changes from before.
Everything else — first public release (1.1.0: Native Ad v1, MREC,
Monetization Arbitrator, fill-rate monitor), VIP time stacking and
rewarded-while-VIP (1.0.22), iOS ATT support (1.0.19), the App-Open
watchdog/timeout rework, the first-install anti-bypass guard (1.0.17), and
the non-certified Cupertino consent dialog that was removed in favor of
Google UMP — is covered in its own dedicated section below (VIP system,
Consent & compliance) or in CHANGELOG.md, not repeated here.
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.38.1 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: ^X.Y.Z # use the latest version from pub.dev
# 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>
<!-- Include only if the partner's privacy/tracking review approves showing ATT.
Text must describe the app's real use and match its localization policy. -->
<key>NSUserTrackingUsageDescription</key>
<string>PARTNER_APPROVED_TRACKING_PURPOSE_TEXT</string>
<!-- Merge the current SKAdNetwork IDs required by enabled ad providers and
mediation partners, using their current official lists. Preserve any IDs
already present in the host app; do not rely on a fixed entry count. -->
<key>SKAdNetworkItems</key>
<array>
<!-- Add/merge current provider-required SKAdNetworkIdentifier entries. -->
</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 loading buffers and
/// VIP UI 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(),
));
}
⚠️ Nested Navigators (bottom-nav tabs,
go_routerShellRoute): add a freshAdScreenRouteLogger()to every nestedNavigator's ownnavigatorObserverstoo — its dialog counter is a static/shared counter, so any instance anywhere feeds the sameisDialogOnTop. And useshowAdSafeModalBottomSheet(exported by this package) instead of the rawshowModalBottomSheetfor any bottom sheet, since the raw API defaults touseRootNavigator: falseand would otherwise push onto an unobserved Navigator, letting a resumed App Open ad show on top of it.Wrap payment, camera/QR, OTP, and password flows in
AdSensitiveScreen(child: ...). App Open ads are skipped on resume while any such wrapper remains mounted; the host must add the wrapper explicitly. Other fullscreen ad formats are unaffected.
Step 5 — Initialize the SDK in splash_screen.dart #
Shortcut:
AdReadinessSplashController(below) wraps everything in this section — the hard-cap timer,markSplashActive/incrementSplashCountbookkeeping, the buffered App Open ad — behind onestart()/onReady()call, while still letting you render your own splash UI. Read this section once to understand what it's doing, then consider using the controller instead of copying the class below by hand.Shortcut (T106):
bootstrap(AdBootstrapOptions(config: ...))wraps therequestAtt() → requestUmpConsent() → initialize()sequence below into one awaited call returningAdBootstrapResult { att, ump, initSuccess, gaid }. It composes with the splash controller above (or your own UI) — bootstrap only covers consent-then-init, not splash timing/App Open display.
initTimeout(round-32 audit fix, default 20s): bounds how longbootstrap()waits specifically oninitialize()before giving up and returninginitSuccess: false. Without it, a wedged native init (never calls back) could leave a bareawait bootstrap(...)frozen for the full ~130s worst-case retry pileup (initialize()'s own[5s, 15s, 30s]backoff across 4 attempts). This does not cancel the real init — it keeps running and still updatesAdManager's state — only this call stops waiting on it. PassAdBootstrapOptions(config: ..., initTimeout: null)to restore the old unbounded wait.
final result = await bootstrap(AdBootstrapOptions(config: myAdConfig));
if (result.initSuccess) {
// proceed — e.g. AdManager().showAppOpenAd(bypassSafety: true) or hand off
// to AdReadinessSplashController for the hard-cap/App-Open dance.
}
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;
// Held in a field so dispose() can hand the SAME callback back to
// SimpleEventBus().remove() — see initState()/dispose() below.
void Function(BoolEvent)? _initListener;
@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 does replay its
// last-fired event to a late subscriber, but this ordering is simplest
// to reason about). Keep the callback in a field — the bus is a permanent
// singleton and only ever forgets a listener you `remove()` yourself, so
// an inline closure here leaks this State object (see dispose() below).
_initListener = (BoolEvent e) {
if (e.value) {
_showSplashAppOpen();
} else {
_goHome();
}
};
SimpleEventBus().listen(_initListener!);
WidgetsBinding.instance.addPostFrameCallback((_) async {
// REQUIRED for iOS (ATT) and for the EEA/UK, Brazil, and every other
// consent-regulated market (UMP). This is not optional polish: shipping
// without it is an AdMob/AppLovin policy AND a GDPR problem. Order
// matters — see "Compliance checklist" below and `example/lib/main.dart`.
//
// Note: there's no separate requestUmpConsent() call below — you don't
// need one. AdConfig.autoRequestUmpConsent defaults to true, so
// initialize() runs the UMP flow itself, before touching either ad
// provider. Set it to false only if you want to call
// AdManager().requestUmpConsent() yourself at a different point.
await AdManager().requestAtt(); // iOS only, no-op on Android
await 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 legacy validator for redeemVip() integrations only.
// For user-entered promotion codes, use redeemSignedKey() or
// VipRedeemScreen; never ship a plaintext key-to-duration map.
),
onComplete: (success, _) {
debugPrint('SDK init complete: success=$success');
},
);
});
}
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();
// SimpleEventBus is a process-lifetime singleton: a listener it is never
// told to drop keeps this State (and its whole widget subtree) alive.
final cb = _initListener;
if (cb != null) SimpleEventBus().remove(cb);
_initListener = null;
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),
],
),
),
);
}
Per-platform ad-unit ids (T15)
bannerId/interstitialId/appOpenId/rewardedId are used on both
platforms by default — pass one id and it applies everywhere (fully backward
compatible). If Android and iOS have different ad units, add the optional
android*Id/ios*Id overrides; the SDK picks the right one via
Platform.isAndroid/Platform.isIOS when the getter is read:
admob: const AdMobConfig(
bannerId: 'ca-app-pub-.../fallback-banner', // used if no override matches
interstitialId: 'ca-app-pub-.../fallback-interstitial',
appOpenId: 'ca-app-pub-.../fallback-app-open',
rewardedId: 'ca-app-pub-.../fallback-rewarded',
androidBannerId: 'ca-app-pub-.../android-banner',
iosBannerId: 'ca-app-pub-.../ios-banner',
),
Same fields exist on AppLovinConfig. An override left null or '' falls
back to the single id above — no breaking changes for existing configs.
AdReadinessSplashController (T94) — the shortcut mentioned above
Everything the _SplashScreenState class above does by hand — subscribing
before initialize(), the hard-cap timer, markSplashActive/
incrementSplashCount/markSplashInactive, the re-entrant-splash guard, the
buffered App Open ad with bypassSafety: true — wrapped behind one
start()/onReady call. Your splash screen still renders 100% its own UI:
class _SplashScreenState extends State<SplashScreen> {
final _controller = AdReadinessSplashController(config: myAdConfig);
@override
void initState() {
super.initState();
_controller.start(context, onReady: _goHome);
}
@override
void dispose() {
_controller.dispose(); // also clears the SDK's splash-active state
super.dispose();
}
void _goHome() => Navigator.of(context)
.pushReplacement(MaterialPageRoute(builder: (_) => const HomeScreen()));
@override
Widget build(BuildContext context) => const Scaffold(
backgroundColor: Colors.deepPurple,
body: Center(child: CircularProgressIndicator(color: Colors.white)),
);
}
Pass showAppOpenOnReady: false to skip the splash App Open ad and call
onReady as soon as init completes. This is a convenience wrapper, not a
replacement for the manual flow above — if your splash needs steps this
doesn't cover (custom ATT/consent timing before initialize(), ...), write
it by hand following the _SplashScreenState example instead.
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.
//
// On a bare IndexedStack tab (all tabs stay mounted, only one
// painted), pass active: selectedIndex == myIndex explicitly —
// buildBanner()/buildMrec()/buildNative() all forward it to the
// underlying widget the same as constructing it directly would.
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(
onDemandBuffer: true,
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. If the device is in an EEA/UK/Switzerland test geography, Google UMP's own consent form appears during splash (see requestUmpConsent/UMP setup below) — skipped on subsequent launches once the user has answered. The default behaviour you get out-of-the-box:
- ⚠️ First-install VIP grace defaults to 24h in release — ads are suppressed during that period. This is an SDK default, not a universal partner recommendation. Set
firstInstallVipGrace: FirstInstallVipGrace.disabledunless the partner's product, privacy, and commercial owners explicitly approve the free entitlement and its Android backup/privacy implications. - ✅ Google UMP consent wired by default (
autoRequestUmpConsent: true) — a certified CMP, not a built-in dialog this SDK draws itself. - ✅ 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.
- ✅ App Open ad auto-skips while a dialog/modal is on top (1.0.23) — it never stacks over the UMP form, a VIP redeem confirmation, or any bottom sheet.
- ✅ Anti-fraud multi-layer safety gate protects your AdMob/AppLovin account.
Imperative inline ad control (T201) #
BannerAdWidget/MrecAdWidget automatically pause on covered routes and
manage scroll visibility. NativeAdWidget does not pause when its route is
covered and has no automatic route or visibility signal. Its active parameter
gates loading: a widget mounted with active: false does not load until it
becomes true. Pass active: false for retained hidden children (such as
unselected IndexedStack tabs), then set it to true when shown. To imperatively refresh()/pause()/
resume() one specific slot — for example, a feed ad's reload button or pausing
an ad under a video overlay — attach an InlineAdController instead
of juggling your own active: bool state variable and forcing a rebuild every
time it changes:
final _bannerController = InlineAdController();
@override
void dispose() {
_bannerController.dispose(); // idempotent — safe even if never attached
super.dispose();
}
@override
Widget build(BuildContext context) => Column(
children: [
BannerAdWidget(controller: _bannerController),
ElevatedButton(
onPressed: _bannerController.refresh,
child: const Text('Reload ad'),
),
ElevatedButton(
onPressed: _bannerController.pause,
child: const Text('Pause'),
),
],
);
refresh()re-requests an ad for that one slot — through the exact same consent/VIP/connectivity/cooldown gate an automatic reload already goes through. A refresh requested during cooldown is silently skipped, never forced past the gate.pause()/resume()reuse the same path route-away/scroll-away already use for Banner/MREC; for Native (no auto-refresh ticker to merely suspend) they dispose and reload the instance.controller.status(InlineAdControllerStatus.detached/.active/.paused) reflects whether a widget is currently attached and paused — listen to it directly (InlineAdControlleris aChangeNotifier) or wrap it in aListenableBuilder.- Pass either
activeorcontroller, never both on the same widget — the constructor asserts against it. - A command issued before any widget has attached (or between a dispose and a later re-mount) is remembered, not lost — it applies once the next widget attaches.
- One controller drives at most one mounted widget at a time; attaching it to a second widget while still attached to another is a usage error (asserts in debug).
Hosts that already observe Battery Saver / Low Power Mode can call
AdManager().setLowPowerMode(true/false). true pauses only AppLovin
Banner/MREC auto-refresh; false releases that hold without overriding
fullscreen, background, or route visibility holds. The SDK does not read battery
state itself, change AdMob's native refresh or fullscreen ads, or choose a
battery threshold for the host. The initial value is false and the latest host
value survives SDK destroy/reinitialize in the same process. Call again whenever
OS power mode or the host's battery threshold result changes.
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 autoRequestUmpConsent = true,
bool umpTagForUnderAgeOfConsent = false,
DebugGeography? umpDebugGeography,
List<String> umpTestIdentifiers = const [],
// AppLovin's own CMP flow is off by default because the SDK runs Google
// UMP for both providers — set false only if you want MAX Terms Flow.
bool disableAppLovinCmpFlow = true,
// ─── App Open trigger ───────────────────────────────────────────
AppOpenTrigger appOpenTrigger = AppOpenTrigger.both,
// Defer automatic Interstitial/Rewarded/RewardedInterstitial preload until
// the user is engaged (15s foreground, 2 navigations, or
// AdManager().markUserEngaged()). App Open and banners are unaffected.
bool smartPreloadEnabled = true,
// Wraps ad callbacks so a throw inside a host callback can't take the
// app down. Leave on unless you are debugging a swallowed error.
bool enableCrashGuard = true,
// ─── Logging ────────────────────────────────────────────────────
// Debug builds default to .verbose, RELEASE builds to .warning
// (ad_config.dart: `kDebugMode ? AdLogLevel.verbose : AdLogLevel.warning`).
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(),
// Ceiling on the total window `addVip(stack: true)` can build up to.
Duration maxVipStackDuration = const Duration(days: 90),
// 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,
})
umpDebugGeography / umpTestIdentifiers are forwarded to Google UMP when autoRequestUmpConsent: true — set them the same way you would the equivalent params on AdManager.requestUmpConsent() (e.g. umpDebugGeography: DebugGeography.debugGeographyEea to simulate an EEA device from anywhere, umpTestIdentifiers: ['<hashed-device-id>'] so Google serves the debug form for your test device).
appOpenTrigger (see AppOpenTrigger below) controls which App Open surfaces the SDK is allowed to trigger — default both preserves existing behavior.
smartPreloadEnabled (default true) stops quick-bounce sessions from spending Interstitial/Rewarded/Rewarded-Interstitial requests that are never shown: those automatic preloads wait for 15 s of continuous foreground, two navigation events after init (needs AdScreenRouteLogger() in navigatorObservers), or an explicit AdManager().markUserEngaged(). Dialogs and bottom sheets do not count as navigation, and time spent in the background does not count toward the 15 s. AdScreen screens and JourneyPrefetcher signals respect the same gate for fullscreen formats. Your own loadInterstitial() / loadRewardedAd() calls are never delayed. If you show an Interstitial or Rewarded in the first seconds of a session, call markUserEngaged() earlier or set smartPreloadEnabled: false to preload immediately as before.
Release-build safety checks (config validation) #
initialize() runs a set of pure, static "footgun" checks against your AdConfig whenever the app is built in release mode (skipped entirely in debug) and logs an 🚨-prefixed warning for each one it finds:
AdSafetyParams.dryRun == true— the entire safety layer (throttle/caps/CTR fraud) is bypassed.- AdMob ad-unit IDs still pointing at Google's public test IDs (
ca-app-pub-3940256099942544/…). firstInstallVipGracedisabled — new installs get no ad-free trial window.umpDebugGeographystill set — forces every real user into the EEA/test UMP consent flow.AppLovinConfig.sdkKeyempty whileprovider: AdProvider.appLovin— the native AppLovin MAX SDK fails to initialise.- Empty/malformed ad-unit IDs for the active provider.
Each warning goes through SafeLogger.e plus a redundant assert() for
profile builds run with --enable-asserts. They never block initialize(),
but each is exactly the kind of copy-paste-from-a-test-config mistake that
silently tanks revenue or earns a policy strike — watch release logs for the
🚨 prefix after shipping.
AppOpenTrigger #
AppOpenTrigger.both // splash App Open + resume App Open both fire (DEFAULT)
AppOpenTrigger.resumeOnly // only the background→foreground resume App Open fires
AppOpenTrigger.splashOnly // only the splash App Open fires
resumeOnly blocks the splash's showAppOpenAd(bypassSafety: true) call; splashOnly blocks showAppOpenAdOnResume(). both is a no-op on both gates.
splashOnly also stops loadAppOpenAd() from preloading once the splash has finished (tracked via markSplashActive()/markSplashInactive()) — since showAppOpenAdOnResume() always skips in this mode, any load after splash ends would never be shown, so it's skipped to avoid wasting quota/network. resumeOnly and both are unaffected — the same slot serves resume regardless of when it was preloaded.
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
Per-placement daily caps #
The caps above (daily/hourly/session) are global per ad type. To additionally
limit a specific AdPlacement — e.g. showing at most one interstitial from
your splash flow per day, on top of the global cap — set
maxPerPlacementAdsPerDay:
// Note: no `const` here — AdPlacement's custom `==` means a map keyed by it
// can't be a compile-time constant.
AdSafetyParams.production.copyWith(
maxPerPlacementAdsPerDay: {AdPlacement.splash: 1},
)
This is checked in addition to the global cap, never instead of it — a
placement with no entry has no extra limit beyond the global one, and this
whole feature is opt-in (null by default, fully backward-compatible).
Centralized placement behavior overrides (PlacementRegistry) #
AdConfig.placements lets you configure per-AdPlacement.id behavior
overrides in one place, instead of scattering if (placement == ...)
branches across your app:
AdConfig(
// ...
placements: const PlacementRegistry({
'level_complete': PlacementSpec(
format: AdSlotType.interstitial,
frequencyCapOverride: 3, // stricter than this placement's default cap
minIntervalOverrideMs: 120000, // 2 min — looser than the app-wide throttle
),
}),
)
PlacementRegistry deliberately does NOT store ad unit IDs — those stay on
AdMobConfig/AppLovinConfig as the single source of truth, avoiding a
second place that could drift out of sync. It only overrides optional
per-placement behavior knobs for that one placement:
frequencyCapOverride— wins overmaxPerPlacementAdsPerDay/maxPerPlacementAdsPerDayByIdabove.minIntervalOverrideMs— wins over the app-wideAdSafetyParams.minTimeBetweenFullscreenAdsthrottle for THIS placement's show calls (smaller = looser, larger = tighter). Applies to every fullscreen show call and the matchingcanShow*pre-check helpers when you pass the sameplacement.
null (the default, for either field) disables that override entirely —
every show call behaves exactly as it did before this feature existed.
Remote-controlled AdSafetyParams (RemoteAdSafetyProvider) #
Adjust caps/frequency from a backend (Firebase Remote Config, a self-hosted config API, ...) without an app store release. Implement the interface with whatever remote-config mechanism you already use — the SDK takes no dependency on any specific one:
class MyRemoteSafetyProvider implements RemoteAdSafetyProvider {
@override
Future<Map<String, dynamic>?> fetchSafetyParamOverrides() async {
final remote = FirebaseRemoteConfig.instance;
await remote.fetchAndActivate();
final json = remote.getString('ad_safety_params');
return json.isEmpty ? null : jsonDecode(json) as Map<String, dynamic>;
}
}
await AdManager().initialize(
config: myConfig,
onComplete: (success, gaid) { /* ... */ },
remoteSafetyProvider: MyRemoteSafetyProvider(),
);
The provider gets a 5s window; a slow, throwing, or null-returning
provider falls back to config.safety unchanged — a remote-config outage
must never block SDK init. Each returned key is independently validated
(non-negative durations/counts, suspiciousCtrThreshold inside [0, 1]) —
an unknown key or a value that fails validation is silently skipped, not
fatal, keeping the local value for just that field.
AdLogLevel #
AdLogLevel.verbose // everything (DEFAULT in DEBUG builds only)
AdLogLevel.warning // warnings + errors only (DEFAULT in RELEASE builds)
AdLogLevel.error // errors only
AdLogLevel.none // silent
VIP system #
Signed VIP keys (offline, forge-proof) — T18 #
VIP redeem codes are Ed25519-signed and verified offline against a public key embedded in the app. The matching private key never ships, so a decompiler cannot forge new valid keys. There is no server and no shared secret — a leaked legitimate key can still be reused on other devices (true global one-time-use needs a backend), but per-device reuse is blocked.
Known limitation — redeem needs connectivity. Verification is offline, but
redeemSignedKeystill rejects the attempt while the device itself is offline (deliberate anti-abuse gate, 2.0.1+). A user in airplane mode can't redeem until they reconnect.
Known limitation — Android reinstall / clear-data replay. One-time-use is enforced by
AdPreferences(wiped on uninstall) plus a durable ledger (RedeemedKeyLedger) that only survives uninstall on iOS (Keychain) — on Android it's a no-op, so a leaked key can be replayed unlimited times via reinstall or Settings → Storage → Clear data (same reset also re-grants the 1-day first-install trial). Each replay only grants that key's ownduration, uncapped in count. Mitigate with a short--valid-dayswhen minting — a data wipe can't undo an expiry. Accepted tradeoff (no backend ⇒ no reliable cross-reinstall Android signal), not a bug: everything writable on Android is user-clearable by definition.Re-confirmed in the round-72 audit (3 independent AI reviewers,
doc/audit/audit_round72_consolidated.md): both limitations above stand as deliberate zero-backend tradeoffs. The owner explicitly declined a device-fingerprint or server-backed fix for either — treat VIP codes as a transferable promotion, never as a single-use paid license, unless a real backend is added.
1. Generate a key pair once (keep the private key secret):
cd packages/ad_sdk
dart tool/vip_keygen.dart
# PUBLIC (embed in app): <base64url>
# PRIVATE (keep secret!): <base64url> ← store in a secret manager, never commit
Use bare dart tool/vip_keygen.dart, NOT dart run tool/vip_keygen.dart — on
Dart 3.10+ toolchains, dart run prints Running build hooks... to stdout
before the script's own output, silently corrupting anything you capture
with $(...).
2. Mint keys offline with the private key:
dart tool/vip_mint.dart --priv-file .vip-private-key --days 30 --valid-days 14 --bundle com.example.app --kid promo30_001
# → AVP2.<payload>.<signature>
3. Embed the public key + redeem in-app:
final result = await AdManager().vip!.redeemSignedKey(
userInput,
publicKeyBase64: kVipPublicKeyBase64, // your public key
stack: true, // add onto the current VIP window
);
switch (result.status) {
case VipRedeemStatus.success: /* granted result.entry */ break;
case VipRedeemStatus.alreadyUsed: /* this key already used on this device */ break;
case VipRedeemStatus.invalid: /* bad/forged/expired key */ break;
}
Key format: AVP2.<b64url(payload)>.<b64url(sig)>, payload = "<seconds>|<keyId>|<expEpochSeconds>|<bundleId>". The VIP duration is read
from the key; keyId drives per-device one-time-use. Use
verifySignedVipKey(code, publicKeyBase64: ...) directly if you only need to
inspect a key without redeeming.
Round-72 audit fix. The legacy
AVP1format (no expiry, no app binding —tool/vip_mint.dart --v1) used to verify unconditionally. BothverifySignedVipKeyandVipManager.redeemSignedKeynow reject it by default; passallowLegacyV1: trueonly if your app has already distributed real AVP1 codes and needs them to keep working.
Pre-built redeem screen (VipRedeemScreen) #
The raw redeemSignedKey/redeemVip calls above are the low-level API. Don't
hand-roll a redeem UI around them: the SDK ships a complete, ready-to-use
screen (status, redeem field, watch-ad-to-extend, revoke, Do Not Sell toggle)
that the example app itself uses as-is:
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => VipRedeemScreen(
publicKeyBase64: kVipPublicKeyBase64, // your public key
onPrivacyPolicyTap: () => launchYourPrivacyPolicyUrl(),
onPrivacyOptionsTap: () => AdManager().showPrivacyOptions(),
),
),
);
Only publicKeyBase64 is required. Everything else is optional: strings: for
localization (VipRedeemStrings), onDoNotSellChanged/doNotSellValue to wire
up the CCPA toggle, rewardWatchAdDuration for the watch-ad-to-extend grant
length. See example/lib/main.dart's VipDemoPage for the full reference
usage, including the demo (never-ship) keypair.
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),
);
Moving a VIP grant to a new device #
No dedicated "transfer" API, deliberately — existing features already cover it:
- A signed key is scoped per device, not globally single-use. The
one-time-use ledger only stops the same device redeeming the same key
twice. If the user still has the original string and it hasn't expired,
entering it on a new device redeems it there too, no code change
needed. (Expiry is wall-clock
expiresAtin the payload — moving devices doesn't reset or extend it.) - The SDK never stores the raw key string after redemption — only
keyId/duration survive. A "view my code again" screen is on your app: keep your own copy of what the user typed when they first redeemed it. - User genuinely lost the key: revoke it in the CRL (see "Revoking a
leaked key (CRL)" below), mint a fresh one with
tool/vip_mint.dart, have them redeem it on the new device. A support-process question, not a missing feature.
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
);
A/B testing AdMob vs AppLovin MAX (pickProviderCohort) #
Provider is fixed for the whole session once initialize() runs — pick a
cohort BEFORE building AdConfig:
final provider = AdManager().pickProviderCohort(); // deterministic 50/50
await AdManager().initialize(
config: AdConfig(
provider: provider,
admob: myAdMobConfig, // both declared — only the picked one loads
appLovin: myAppLovinConfig,
),
onComplete: (success, gaid) { /* ... */ },
);
Comparing eCPM/fill-rate between the two cohorts needs no new plumbing —
every event on AdManager().events already carries providerTag
('[AdMob]'/'[AppLovin]'), so your own analytics pipeline can group
AdLoadEvent.success/AdRevenueEvent.valueMicros by that field across your
install base. Built on experimentBucket below — same GAID/install-id
fallback guarantee.
Session-alternate exploration for WaterfallTuner/SelfHealingObserver (pickSessionProvider) #
pickProviderCohort above assigns a provider ONCE, at install — every
session of that install runs the same provider forever. WaterfallTuner
and SelfHealingObserver (below) can only ever compare the two providers
on a single device if SOME sessions genuinely run the alternate one —
pickSessionProvider is that mechanism:
final installProvider = AdManager().pickProviderCohort(); // stable per install
final sessionProvider = await AdManager().pickSessionProvider(
installCohortProvider: installProvider,
explorationRate: 0.05, // 5% of eligible sessions explore — keep this LOW
);
await AdManager().initialize(
config: AdConfig(provider: sessionProvider, admob: ..., appLovin: ...),
onComplete: (success, gaid) { /* ... */ },
);
Read this before enabling: an explored session is a REAL session on the
alternate provider — real requests, real fills, real revenue for that
session's users, not a shadow request. explorationRate defaults to 0
(never explores); anything above 0 is an explicit tradeoff, and should stay
low (5% above is a starting point, not a universal recommendation).
Rate-limited to once/day/device regardless of explorationRate, and never
fires during a VIP session.
Cross-provider revenue integrity (RevenueIntegrityLedger) #
Exact match when available, time-window heuristic otherwise. Both
AdShowEvent and AdRevenueEvent carry an optional requestId, a per-load
correlation ID both adapters stamp — when a revenue event's requestId
matches a pending show's, that show resolves EXACTLY, no guessing.
requestId is null for banner/mrec/native and older adapter versions, so
the original heuristic still runs whenever it's missing on either side:
expect a same-(providerTag, type, placement) AdRevenueEvent within
matchWindow after every show; a miss is flagged via
AdManager().incidentRecorder as a possible gap — usually just a late
callback, not proof of fraud or a lost impression.
final ledger = RevenueIntegrityLedger(matchWindow: const Duration(seconds: 60));
// ... later, read incidents the same way any other IncidentRecorder
// entry is read — see the "Debugging a decision" / dispute-kit sections
// above for the export path.
No new reporting mechanism: flags land in the same IncidentRecorder
AdManager().exportDisputeKit()/exportSignedIncidentBundle() already
export. Completely on-device — only consumes events the SDK already
emits, no third-party API calls.
Expiry during idle and app suspension. A one-shot timer checks the
oldest pending deadline while the app isolate is active, even if no further ad
event arrives. Deadlines use wall-clock time; AdManager also sweeps
registered ledgers when the app resumes, so elapsed sleep time counts. A wall
clock correction can shift an expiry; the matching window remains a heuristic,
not proof of fraud or lost revenue. OS suspension can delay the callback until
resume, where the explicit sweep runs.
Zero-shadow dual-provider failover (ProviderFailoverAdvisor) #
Still exactly one provider per session by design — a live concurrent
dual-adapter runtime is a bigger change this package doesn't make.
ProviderFailoverAdvisor is narrower and cheaper: recommend switching to
the OTHER provider for the NEXT initialize() once the current one has
failed to load consecutiveFailureThreshold times in a row (no successful
load in between — a merely intermittent pattern never trips this). Unlike
WaterfallTuner.recommendation(), needs no accumulated data for the
provider it recommends switching TO — "zero shadow requests".
final advisor = ProviderFailoverAdvisor(consecutiveFailureThreshold: 5);
AdManager().enableProviderFailoverAdvisor(advisor);
// ... later, building next session's config:
final provider = AdManager().applyProviderFailover(
installProvider, // from pickProviderCohort()/pickSessionProvider()
advisor: advisor,
);
await AdManager().initialize(
config: AdConfig(provider: provider, admob: ..., appLovin: ...),
onComplete: (success, gaid) { /* ... */ },
);
Persists by default (persist: true) — the whole point is surviving the
app restart between "this session failed repeatedly" and "the host reads
that before starting the next one". Never switches anything itself; it
only recommends, same as WaterfallTuner/SelfHealingObserver.
A/B testing local knobs (experimentBucket) #
Deterministic bucket assignment for A/B testing AdSafetyParams/arbitrator
thresholds without a remote-config backend (lighter than
RemoteAdSafetyProvider above — purely local, no network):
final bucket = AdManager().experimentBucket('daily_cap_experiment', buckets: 2);
final safety = bucket == 0
? AdSafetyParams.production
: AdSafetyParams.production.copyWith(maxFullscreenAdsPerDay: 8);
await AdManager().initialize(config: myConfig.copyWith(safety: safety), ...);
Same result every call for the same (key, buckets) on this install —
prefers the real GAID when available, falls back to a lazily-generated
pseudonymous id persisted locally when GAID is empty/all-zeros (Limit Ad
Tracking / no ATT permission), so opted-out users still get distributed
across buckets instead of all colliding into bucket 0.
Rewarded Interstitial (AdMob only) #
Google's "Rewarded Interstitial" format — shown at a natural transition point
(between levels, after a task completes, ...) rather than behind an explicit
"watch ad" tap, while still granting a reward. Set
AdMobConfig(rewardedInterstitialId: '...') and use the matching
load/show/canShow trio:
Policy: this format requires an intro screen. Google mandates that a rewarded interstitial is announced before it plays — the user must be told an ad is coming and what the reward is, and be given a way to decline. Serving it without one puts your AdMob account at risk, not the SDK's.
AdScreenState.showRewardedInterstitialAd()renders that screen for you and is the recommended entry point.AdManager().showRewardedInterstitialAd()is the raw call and does not announce anything — if you use it directly, the intro screen is yours to build.
AdManager().loadRewardedInterstitialAd();
// From an AdScreenState — announces the ad, then plays it.
showRewardedInterstitialAd(
// English fallbacks; pass your own localised strings.
disclosureTitle: 'Xem quảng cáo để nhận thưởng',
disclosureSubtitle: 'Một quảng cáo ngắn sẽ phát. Nhận thưởng sau khi xem xong.',
disclosureButtonLabel: 'Xem',
disclosureCancelLabel: 'Bỏ qua',
onDone: (shown, earned) {
if (earned) grantCoins(10);
},
);
// Already rendering your own intro screen? Take the obligation on explicitly:
showRewardedInterstitialAd(
showDisclosure: false,
onDone: (shown, earned) { /* ... */ },
);
if (AdManager().canShowRewardedInterstitialAd()) { /* e.g. enable a CTA */ }
shown is true whenever the ad was displayed, regardless of whether
the user stayed to the reward point — billed either way. Declining the intro
screen reports (false, false) and costs nothing.
AppLovin MAX has no equivalent ad unit type — documented no-op there:
loadRewardedInterstitialAd() never has anything to load,
showRewardedInterstitialAd() always calls back (false, false). No
VIP-bypass-to-extend or SSV params for this ad type either.
On-demand buffer when rewarded ad is not ready #
Normal preloading remains default. If the user explicitly requests a rewarded ad
before its slot is ready, pass onDemandBuffer: true to show a blocking loading
dialog and let SDK try loading for up to 15 seconds. No fill, network failure or
timeout returns earned: false. This can recover an otherwise missed view, but
may add a short wait and does not guarantee an ad will be available.
AdManager().showRewardedAd(
onDemandBuffer: true,
onEarnedReward: (earned) {
if (earned) grantReward();
},
);
AdScreenState.showRewardedAd() forwards onDemandBuffer too; its usual
readiness pre-check is bypassed only when the flag is true.
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.
AdScreenState.showRewardedAd() (the documented AdScreen helper — see
Step 6 — Show ads on any screen) forwards
bypassVipGuard, onDemandBuffer and onDemandLoadTimeout the same way, so
this flow works identically through either entry point.
Waiting for VIP to be ready #
AdManager().vip is null until SDK init completes. If a screen can render
before that (e.g. it doesn't gate on the splash's init-completion event),
listen to vipReady instead of polling vip != null yourself:
ValueListenableBuilder<bool>(
valueListenable: AdManager().vipReady,
builder: (_, ready, __) {
if (!ready) return const SizedBox.shrink();
return ValueListenableBuilder<bool>(
valueListenable: AdManager().vip!.activeListenable,
builder: (_, active, __) =>
active ? const VipBadge() : const SizedBox.shrink(),
);
},
)
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();
Revoking a leaked key (CRL) — T95 #
Signature verification is fully offline, which is great for forge-proofing,
but means a leaked key normally stays redeemable forever (or until its AVP2
--valid-days expiry). VipManager.refreshRevocationList closes that gap
with a small, also offline-signed revocation list (CRL) — no server, no
new key material, same private key that mints VIP keys mints the CRL too.
- Mint the CRL offline whenever you learn a
kidleaked (same private key astool/vip_mint.dart, never commit it):
(baredart tool/vip_crl_mint.dart --priv-file .vip-private-key --kids leaked-kid-1,leaked-kid-2 # → CRL1.<payload>.<signature>dart tool/..., notdart run tool/...— see the vip_keygen note above; "Host the raw output" below means this exact string, so build-hook noise prepended to it would break every consumer that fetches it.) - Host the raw output anywhere you like (a static JSON/text endpoint,
Firebase Remote Config, ...) — implement
VipRevocationProviderto fetch it:class MyCrlProvider implements VipRevocationProvider { @override Future<String?> fetchSignedCrl() async { final resp = await http.get(Uri.parse('https://example.com/vip_crl.txt')); return resp.statusCode == 200 ? resp.body.trim() : null; } } - Refresh periodically (once/day is plenty — this is a slow-moving
blocklist, not a live check):
Timer.periodic(const Duration(hours: 24), (_) { AdManager().vip?.refreshRevocationList( publicKeyBase64: myVipPublicKey, // same key(s) passed to redeemSignedKey revocationProvider: MyCrlProvider(), ); });
Verified CRLs are cached to disk (re-verified against the public key on every
read, never trusted un-signed) so a revoked kid stays blocked across app
restarts without a fresh fetch. Fails open on every error — no provider,
fetch throws, fetch returns null, bad signature, or a replayed/older CRL
(anti-rollback: an older signed CRL can never undo a newer revocation already
applied) — a network hiccup must never block a legitimate redemption. This
only stops future redemptions of a revoked kid; a device that already
redeemed it before the CRL update keeps its granted VIP window (see the
Known limitations note above).
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.
Server-Side Verification (SSV) for rewarded ads #
This SDK does not run a server and does not verify anything itself. Real
SSV happens entirely outside it: you configure a callback URL for the
rewarded ad unit in the AppLovin/AdMob dashboard; on reward,
AppLovin's/AdMob's servers POST a signed postback directly to your own
backend, which verifies it and grants the reward server-side — resistant
to the client-side tampering a bare onEarnedReward callback isn't.
What this SDK does — pure plumbing:
showRewardedAd(...)takes optionalssvCustomData/ssvUserIdto attach an identifier to the show. Omit both for today's fully client-side behavior.- Forwarded verbatim to the native field: AppLovin's
customData:(one combined string — usessvUserIdif you don't need a separate payload) or AdMob'sServerSideVerificationOptions(userId:, customData:). pendingServerConfirmation(onRewardResultandAdRewardEvent) istrueonly when you supplied one of those two params — an informational flag ("your backend's postback is authoritative here"), not something the SDK polls or tracks itself.
AdManager().showRewardedAd(
ssvUserId: currentUser.id, // → forwarded to AppLovin/AdMob's SSV field
onEarnedReward: (earned) {
if (!earned) return;
// Optimistic client-side UI update only. If you've configured an SSV
// callback URL in the AppLovin/AdMob dashboard, YOUR backend receives
// the authoritative postback independently of this callback.
},
);
Monetization Arbitrator (opt-in) #
Default OFF, production-safe — no kDebugMode gating like the debug-only
RevenuePanel overlay; it stays off purely because enableArbitrator was
never called, and runs identically in release once it is. An opt-in veto that
runs after every existing gate (including safety) on each fullscreen show
attempt: show the ad, or nudge the host to upsell VIP instead. A simple
configurable eCPM-threshold rule, not machine learning — compares a
trailing eCPM estimate against an optional VIP-conversion-likelihood signal
your app supplies (the SDK can't compute that itself — no purchase-funnel
visibility).
AdManager().enableArbitrator(MonetizationArbitrator()); // ~$5 eCPM default threshold
// Optional: tell the arbitrator how likely this user is to buy VIP (0.0–1.0).
// Without this, it falls back to a plain "trailing eCPM below threshold" check.
AdManager().arbitrator!.registerVipLikelihoodEstimator(() => myFunnelScore());
Interstitial and rewarded can use different eCPM thresholds via
perSlotThresholdMicros — a rewarded ad is worth more to most users than an
interstitial, so it's reasonable to require a higher trailing eCPM before
showing one:
AdManager().enableArbitrator(MonetizationArbitrator(
ecpmThresholdMicros: 3000000, // fallback for any slot not listed below
perSlotThresholdMicros: {
AdSlotType.rewarded: 5000000, // rewarded needs a higher bar to show
},
));
A maxVetoRate guardrail (default 0.5) protects against a threshold set too
high (or a genuine eCPM crash) starving users of ads indefinitely: once the
veto rate over the trailing decisionWindowSize decisions (default 20)
exceeds maxVetoRate, the arbitrator forces showAd regardless of the
eCPM/likelihood heuristic, and recovers automatically once the veto rate drops
back down. A single warning logs the first time this trips per streak.
showInterstitial/showRewardedAd/showRewardedInterstitialAd (T89's
AdMob-only slot) only consult arbitrator when it's non-null — byte-for-byte
no-op until enableArbitrator is called. When the arbitrator vetoes a show,
the SDK emits an ArbitratorNudgeEvent (type, placement,
estimatedEcpmMicros) on AdManager().events instead of showing the ad, so
the host app can react with its own VIP upsell UI — the SDK never draws this
UI itself, purely a signal (T99). The veto is skipped for the VIP
watch-ad-to-extend-VIP bypass path (bypassVipGuard: true) — that flow is
the user already spending their own time to earn more VIP, so vetoing it
would defeat its purpose.
There is no disableArbitrator for host apps — it exists only as a
@visibleForTesting seam, since a session normally either wants the
arbitrator on for its whole lifetime or not at all.
Debugging a decision (decideWithContext) #
decide() (used internally by the SDK) only ever returns the
ArbitratorDecision enum — showAd or nudgeVip, with no explanation. If
you want to log or debug WHY a slot keeps getting vetoed (which threshold it
was compared against, what the trailing eCPM actually was, whether the
maxVetoRate guardrail forced the outcome), call decideWithContext
instead — it runs the exact same logic as decide() (same result, same
internal bookkeeping) and additionally returns an ArbitratorDecisionDetail:
final detail = AdManager().arbitrator!.decideWithContext(AdSlotType.interstitial);
myLogger.log(
'arbitrator: ${detail.decision} — ${detail.reason} '
'(trailing eCPM ${detail.trailingEcpmMicros}µ vs threshold ${detail.thresholdMicros}µ, '
'guardrail: ${detail.guardrailTripped})',
);
Call either decide() or decideWithContext() per real decision point, not
both for the same one — each call advances the arbitrator's internal
decision history (used by the maxVetoRate guardrail above), the same way
decide() alone always has.
Fill-rate monitor (opt-in) #
Default OFF, production-safe — same as the arbitrator above, off until
enableFillRateMonitor is called, then identical in debug and release. A
FillRateMonitor watches the trailing load success rate per AdSlotType
and alerts when it drops abnormally low — useful for catching a
mediation/network outage without waiting on a dashboard. It does not
load a second provider to compare against ("shadow eCPM", extra policy risk
and wasted quota for a number) — only observes the AdLoadEvents the SDK
already emits.
final monitor = FillRateMonitor(); // 30% threshold, 20-event rolling window
AdManager().enableFillRateMonitor(monitor);
monitor.alerts.listen((alert) {
// alert.type, alert.fillRate, alert.threshold
});
// Read the current rate for a slot at any time:
monitor.fillRate(AdSlotType.interstitial);
An alert fires once the first time a slot's trailing fill rate drops below
lowFillRateThreshold within a full rolling window, then stays silent while
the drop persists — it fires again only after the rate recovers above
threshold and later drops a second time, so it never spams one continuous
outage.
There is no disableFillRateMonitor for host apps — same reasoning as the
arbitrator above, it's a @visibleForTesting seam only.
7-day fill-rate/eCPM baseline regression detector (T97) #
FillRateMonitor above only ever looks at the current session (a trailing
rolling window). FillRateBaselineMonitor answers a different question:
"is THIS session unusually bad compared to what this exact device normally
sees?" — entirely on-device, no backend, no shadow ad requests, consistent
with the SDK's offline-first design elsewhere (VIP Ed25519 signing, the
client-side safety layer).
await AdManager().enableFillRateBaselineMonitor(
regressionThreshold: 0.2, // session metric 20%+ worse than baseline → alert
minSamples: 5, // need at least 5 samples on BOTH sides to compare
);
AdManager().fillRateBaselineMonitor?.alerts.listen((alert) {
// alert.type, alert.sessionFillRate, alert.baselineFillRate,
// alert.sessionAvgRevenueMicros, alert.baselineAvgRevenueMicros,
// alert.fillRateRegressed, alert.revenueRegressed
});
// One-shot snapshot (what the debug overlay renders):
AdManager().fillRateBaselineMonitor?.activeAlerts;
It persists a rolling 7-calendar-day history per AdSlotType (attempts,
successes, and average revenue-per-ad from AdRevenueEvent.valueMicros)
locally via AdPreferences, and compares it against THIS session's tally so
far — excluding today's own in-progress day from the baseline, so a session
never gets (dis)compared against itself. Needs minSamples on both sides
before it trusts a comparison, and — like FillRateMonitor — fires once per
slot on a new regression, then stays quiet while it persists. Already wired
into AdManager.diagnostics() (fillRateRegressionBySlot) and the built-in
DebugAdOverlay, so enabling it is the only integration step needed to see
it in the panel.
Other advanced opt-in modules (brief) #
The two above (arbitrator, fill-rate monitor) aren't the only opt-in modules this SDK ships — these are lower-profile, advanced, or niche enough that they don't need a full section, but are real public API a host app can reach for:
-
AdSafetyConfig— the anti-fraud engine itself (throttle, session/hourly/daily caps, CTR monitoring, progressive cooldown). Query live state any time viaAdSafetyConfig.getStatus()(string) or.getStatusSnapshot()(structuredAdSafetySnapshot) — seeRemoteSafetyDemoPagein the example app for a working usage. -
AdRetryPolicy— per-slot retry/backoff tuning that distinguishes no-fill from network/invalid-request/timeout failures, since a misconfigured ad unit will never self-resolve by retrying while a network blip often should retry sooner than the default backoff window. -
BypassAuditTrail— always-on audit log proving everybypassSafety/bypassVipGuardcall only ever fired at this SDK's own documented call sites (splash App Open, VIP watch-to-extend), not somewhere patched in to farm impressions. -
IncidentRecorder— a short rolling window of recent top-level state transitions, for diagnosing "why didn't this ad show" races that a single point-in-time diagnostics snapshot can't explain by itself. -
MonetizationDigitalTwin— deterministic, read-only replay overAdEventLoghistory for analytics; never issues an ad request or touchesAdSafetyConfig's live state. -
JourneyPrefetcher— opt-in, engagement-signal-driven prefetching viaAdManager().enableJourneyPrefetcher(...); nothing is tracked/preloaded unless the host callsnotifySignal, or opts intoautoRouteSignalTypeso a route push does it automatically. Rolling time-to-show averages persist across restarts by default (persist: true, stores only durations between a signal string and an ad type, never the signal's content) — passpersist: falseto opt out, orawait prefetcher.readyto guarantee last session's data before the first call.final prefetcher = JourneyPrefetcher( autoRouteSignalType: AdSlotType.interstitial, // opt-in ); AdManager().enableJourneyPrefetcher(prefetcher); // ... MaterialApp( navigatorObservers: [ adRouteObserver, AdScreenRouteLogger(), prefetcher.routeObserver!, // only non-null when autoRouteSignalType is set ], );Auto-mode fires
notifySignal(routeName, autoRouteSignalType)for every NAMED route push (ModalRoute.settings.name); an unnamed route is skipped. Only ONE format is auto-signaled per instance — a journey with more than one fullscreen format should still callnotifySignalby hand for the others; manual and auto calls are never deduplicated against each other (two calls = two signals, by design). -
WaterfallTuner— per-provider eCPM score tracking meant for cross-install mediation experiments (e.g. deciding a new install'sAdConfig.providerfrom server-side analytics) — not a within-install auto-switcher. -
TopToast— the small built-in toast this SDK uses internally for "ad not ready" messaging (TopToast.show(context, icon: ..., message: ...)), reusable directly by a host app that wants the same look.
SelfHealingObserver is intentionally omitted above — its own doc comment
notes it stays dormant on any single real install by design; treat it as
experimental/advanced rather than something to wire up by default.
Diagnostics & integration self-check #
AdManager.diagnostics() is a one-shot, read-only snapshot that combines
mediation waterfall, fill rate, arbitrator stats, active baseline-regression
alerts, and revenue-integrity signals (pending checks and recent incidents)
into a single AdDiagnostics object. Disabled opt-in subsystems report empty
or null values; this call never enables them.
final diag = AdManager().diagnostics();
diag.lastWaterfallBySlot; // most recent mediation waterfall per AdSlotType
diag.fillRateBySlot; // FillRateMonitor.fillRate per slot (empty map if monitor disabled)
diag.arbitratorEstimatedEcpmMicros; // null if arbitrator disabled
diag.arbitratorVetoRate; // null if arbitrator disabled
diag.fillRateRegressionBySlot; // active regressions vs. the 7-day baseline
diag.pendingRevenueChecks; // null if revenue-integrity ledger is off
diag.recentRevenueIntegrityIncidents; // incidents already recorded locally
diag.toJson(); // raw snapshot for local debugging
// For a support bundle, use the bounded, redacted, checksummed export:
final safeJson = await AdManager().exportSafeDiagnostics();
final ok = await AdDiagnostics.verifySafeJsonString(safeJson);
The snapshot has no per-field freshness timestamp or severity and does not label findings as observed, inferred or unknown. The SHA-256 checksum detects payload edits but does not authenticate who created the export.
AdManager.runIntegrationSelfCheck() is a debug-only "integration
doctor" checklist (init → consent → per-ad-type load → VIP wiring →
navigator key → route observer → ATT plugin) so a partner doesn't have to
manually click through every demo page to confirm their AdConfig and
app-level wiring work. No-op outside debug builds. Never calls destroy()
or grants/revokes VIP (would mutate live state) — the one exception is the
per-ad-type checks, which DO attempt real ad loads, same as the demo pages.
final result = await AdManager().runIntegrationSelfCheck(
loadTimeout: const Duration(seconds: 15), // per-slot load timeout
);
result.allPassed; // true if no item has SelfCheckStatus.fail
result.items; // List<SelfCheckItem>(name, status, detail)
Checks added in T98 (all read-only, never trigger a real ad load or an ATT prompt):
- Navigator key wired — fails if
setNavigatorKeywas never called;skipped(notfail) if it's set but not yet attached to a liveNavigator(e.g. the check ran before the first frame). - Route observer wired —
AdScreenRouteLoggeronly ever receivesdidPush/didPop/etc. callbacks if it's really innavigatorObservers, so a non-zero navigation-event count is real evidence of correct wiring.skipped(notfail) if no route has pushed yet. - ATT status readable (iOS) — confirms the
app_tracking_transparencynative plugin responds at all (catches a broken iOS embed early). Deliberately never callsrequestTrackingAuthorization()— that shows the real system prompt, which a passive diagnostic must never trigger.skippedon non-iOS.
Deliberately out of scope for this pass (a genuinely different, much
larger engineering effort — flagged here rather than silently claimed done):
SKAdNetwork/Info.plist entries, Android manifest permissions/meta-data, and
cross-referencing the CocoaPods dependency graph against the pinning-wall
constraints (see "Publishing to pub.dev" in CLAUDE.md / tool/ check_pinning_wall.sh). None of these are readable from plain Dart at
runtime — they'd need new native (Swift/Kotlin) platform-channel code to
read the bundled Info.plist/AndroidManifest.xml, and the pod graph is a
build-time concept with no runtime representation at all. doctor only
ever reports on state this package can already see from Dart.
The results are rendered directly in the built-in DebugAdOverlay — tap "🩺
Run integration doctor" in the panel (not auto-run on open, since the
per-ad-type checks attempt real loads). See also the example app's
"Diagnostics & self-check" demo page for both APIs wired to a live UI.
Native Ad (v1) #
buildNative() (or NativeAdWidget directly, outside AdScreen) renders a
native ad with VIP/offline gating. Unlike buildMrec(), native ads do not
pause automatically when their route is covered; hosts using tab stacks or
other retained hidden children must control NativeAdWidget.active themselves.
AdMob and AppLovin use fundamentally different native-ad mechanisms (no shared Dart-side shape):
- AdMob:
NativeAd extends AdWithView(same base asBannerAd/MREC), preloaded withNativeTemplateStyle(templateType: TemplateType.medium)and shown viaAdWidget. The template draws its own "Ad"/AdChoices attribution. - AppLovin:
MaxNativeAdViewis self-contained, loading on mount fromadUnitId+ a custom Dart layout using asset views such asMaxNativeAdIconView,MaxNativeAdTitleView,MaxNativeAdMediaView,MaxNativeAdBodyView, andMaxNativeAdCallToActionView. It doesn't go through the bridge banner/MREC use, so the package draws its own "Ad" badge (mirrors MREC) to stay compliant.
NativeAdWidget.templateType uses Google's TemplateType: it selects AdMob's
rendered template, while AppLovin ignores it for rendering. The widget still
uses templateType to choose its default height for both providers: medium is
320 logical px, small is 90. Set height to override either provider's widget
height. To choose AdMob's template, set templateType before the ad loads; for
a host-registered AdMob layout, use factoryId. buildAdmobNativeView(key)
only renders the already-loaded AdMob ad. For a custom AppLovin arrangement, use
NativeAdWidget.customNativeAdBuilder or build your own MaxNativeAdView.
There is no automatic route-pause or auto-refresh.
Custom native ad layout (T228) #
NativeAdWidget (not buildNative(), which stays fixed-layout v1) accepts
two opt-in, per-provider customization params. Leaving both null (the
default) is a zero-behavior-change no-op for existing integrations — you get
exactly the v1 layout above.
AdMob — factoryId (needs native Kotlin/Swift code, not pure Dart).
Google's google_mobile_ads plugin does not support building native ad UI
out of Flutter widgets at all (its NativeAd class doc says so explicitly)
— the only way to fully customize the rendered view is a platform-side
NativeAdFactory your host app registers, outside this package (this
SDK is pure Dart and has no android//ios/ directories of its own to ship
one from). Copy the reference implementation from this package's own
example app:
- Android:
example/android/app/src/main/kotlin/.../T228CustomNativeAdFactory.ktexample/android/app/src/main/res/layout/t228_custom_native_ad.xml, registered inexample/android/app/.../MainActivity.kt'sconfigureFlutterEngine.
- iOS:
example/ios/Runner/T228CustomNativeAdFactory.swift(built entirely in code, no.xib), registered inexample/ios/Runner/AppDelegate.swift'sdidInitializeImplicitFlutterEngine.
Then, in Dart:
const NativeAdWidget(factoryId: 't228CustomNativeAd') // must match the registered factoryId
If the host forgets to register a matching factory, the native SDK reports a
normal load failure (hasError flips, same as any no-fill) — logged clearly
by this package; it does not crash. The mandatory AdChoices attribution icon
is drawn by Google's own NativeAdView/GADNativeAdView container
automatically once you call setNativeAd/assign .nativeAd — you do not
need to add it yourself, but omitting the container class entirely (i.e. not
using NativeAdView) would be a policy violation. This SDK's own
admob_adapter.dart only builds NativeAd(factoryId: ...) instead of
NativeAd(nativeTemplateStyle: ...) when you opt in — nothing else changes.
AppLovin — customNativeAdBuilder (pure Dart, no native code).
MaxNativeAdView's asset views (MaxNativeAdTitleView, MaxNativeAdBodyView,
MaxNativeAdIconView, MaxNativeAdMediaView, MaxNativeAdCallToActionView,
MaxNativeAdStarRatingView, MaxNativeAdAdvertiserView) are already plain
Flutter widgets — this just lets you arrange them yourself instead of using
this widget's built-in Row/Column:
NativeAdWidget(
customNativeAdBuilder: (context) => const Column(
mainAxisSize: MainAxisSize.min,
children: [
MaxNativeAdTitleView(style: TextStyle(fontWeight: FontWeight.bold)),
MaxNativeAdBodyView(),
MaxNativeAdCallToActionView(),
// Do NOT add MaxNativeAdOptionsView yourself — see below.
],
),
)
The SDK always overlays the mandatory MaxNativeAdOptionsView attribution
badge on top of whatever this builder returns — your builder cannot omit it,
by construction, not by convention — and automatically insets the host child
by that same 24px from top/right so ordinary host content cannot cover the
badge. Fail-safe fallback: if height leaves less than
kMinNativeAdAttributionSize (24 logical px) of room for
that badge, the SDK ignores your builder for that build and falls back to
the standard v1 layout instead (still fully compliant) — logged via
SafeLogger so you can tell it happened.
Both providers' custom-layout paths are covered end-to-end in
test/native_ad_widget_test.dart (attribution-always-present, fallback
trigger, zero-behavior-change default) plus a real on-device integration
test (example/integration_test/t228_custom_native_ad_test.dart) exercising
the actual registered Android/iOS factory.
Built-in QA test devices (read this before measuring revenue) #
This package always merges a fixed list of AdMob test-device hashes into
RequestConfiguration.testDeviceIds, in release builds too. They are the
maintainers' own QA handsets (kQaTestDeviceHashes in
lib/src/config/ad_config.dart — the list is public, on pub.dev, and readable
by anyone).
Why it exists: manual QA runs on real hardware against release builds, and a tester's tap must never be counted as a real click. Getting rate-limited for invalid activity is far more expensive than a handful of devices that serve test ads.
What it costs you: if one of those devices ever ends up in the hands of a real
user of your app, that user will only ever see test ads and will never
generate revenue. There is currently no opt-out. If that trade-off does not
work for you, fork the package and empty kQaTestDeviceHashes.
Consent & compliance #
The SDK supports two patterns for GDPR/personalization consent, plus a separate CCPA toggle. Pick whichever matches your release strategy.
There is no built-in consent dialog — a prior version shipped one (a
plain Allow/Reject sheet), but it was not a Google-certified CMP and
produced no valid IAB TCF consent string, so a "yes" it collected was not a
valid legal basis for personalized ads in the EEA/UK/Switzerland. It was
removed (round 44 audit finding 1). Use Google UMP below, or another
certified CMP wired through ConsentManager.set/setConsent.
CCPA "Do Not Sell" toggle: legally required to be an end-user choice
(Cal. Civ. Code §1798.135) — CcpaOptOutToggle (a SwitchListTile wired to
AdManager().doNotSell / setDoNotSell(bool)) is a ready-made widget for
it. Drop it into a Settings/Privacy screen for a California-facing app:
// After AdManager().initialize() has completed — e.g. your app's Settings
// or Privacy screen:
const CcpaOptOutToggle()
// Or with localised copy:
const CcpaOptOutToggle(strings: CcpaOptOutStrings.vi)
CcpaOptOutStrings, VipDialogStrings (AdConfig.vipDialogStrings, the
small redeem-confirmation dialog), and VipRedeemStrings
(VipRedeemScreen.strings, the full redeem screen — buttons, labels, and
snackbar messages) all follow the same pattern — .en, .vi, and
.resolve([locale]):
VipRedeemScreen(
publicKeyBase64: yourPublicKey,
strings: VipRedeemStrings.resolve(Localizations.localeOf(context)),
)
These three are every widget/dialog this SDK shows a real end user. DebugAdOverlay
and RevenuePanel are the only other SDK widgets that render text at all,
and both are kDebugMode-gated (render nothing, subscribe to nothing, in a
release build) — a developer debugging the SDK, not an end user, so they
stay English-only on purpose, the same way Flutter's own DevTools do.
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 1 — Google UMP form (default, required for EEA/UK/Switzerland users) #
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
Alternatively, set AdConfig(autoRequestUmpConsent: true, umpDebugGeography: ..., umpTestIdentifiers: [...]) to let AdManager().initialize() run this flow for you before the first ad request — umpDebugGeography/umpTestIdentifiers are forwarded through to the same UMP call shown above.
The raw IAB TCF v2.3 consent string Google UMP writes to native storage after a user completes the EEA form is available via await AdManager().tcfConsentString (null until a TCF session has run) — read-only, for forwarding to any third party (analytics, mediation outside AppLovin/AdMob) that needs the raw string.
F7 — why this SDK doesn't also forward the TC-string to AppLovin: Q18/setConsent only syncs the boolean hasUserConsent flag to AppLovinMAX.setHasUserConsent, not the raw TC-string — and that's intentional, not a gap. AppLovin MAX SDK 12.0.0+ (this project pins native 13.2.0.1 / Flutter applovin_max: ^4.6.4, both well above that threshold) already auto-reads IABTCF_TCString, IABTCF_gdprApplies, and IABTCF_AddtlConsent directly from the platform's shared storage per the IAB standard, the moment UMP writes them — no app code has to relay it. AdManager().tcfConsentString above exists only as a manual escape hatch for a third party outside AppLovin/AdMob that also needs the raw string.
AdMob Console setup (repeat for EVERY AdMob App ID)
UMP messages are configured and published per AdMob App ID; SDK code does not create or publish them. For each Android/iOS app ID used by the consuming app:
- Sign in to the partner's AdMob account and open Privacy & messaging.
- Create or edit the GDPR/European regulations message for the correct app ID. If the app ID is missing, add the app to the partner account first.
- Configure the partner-approved privacy-policy URL, consent choices, ad partners/purposes, and localized message text. Do not copy another company's IDs or policy URL.
- Publish the message; saving a draft is not sufficient. Repeat for every platform/app ID that the partner distributes.
- If US state privacy applies, configure the partner's approved US-state message/options separately.
- In a debug build only, test the EEA form with UMP test mode,
DebugGeography.debugGeographyEea, and this device's hashed test identifier as described in the SDK Configuration reference. Remove debug geography/test identifiers before release.
If the message is not published for the exact app ID, UMP may report no form(s) configured; record the result and resolve it in AdMob Console before claiming EEA consent is verified. Console labels can change; follow current Google UMP/AdMob UI if names differ.
Privacy Options entry point (MUST — required by Google UMP policy)
Google requires every app that gathers UMP consent to expose a durable,
always-visible way for the user to change their choice later (e.g. a
"Privacy Settings" row in your Settings screen). Wire a permanent button that
calls AdManager().showPrivacyOptions():
// Settings screen — always render this row; it's a safe no-op for users
// who were never shown a consent form (non-EEA / notRequired).
ListTile(
title: const Text('Privacy Settings'),
onTap: () async {
final result = await AdManager().showPrivacyOptions();
// result.formShown == true only when Google's UMP actually required
// and displayed the native privacy-options form. Consent changes are
// re-applied to the active ad provider (npa/RDP) automatically.
},
);
// Optional: hide/disable the row instead of always showing it.
final required = await AdManager().isPrivacyOptionsRequired();
showPrivacyOptions() is safe to call unconditionally — it no-ops (does
not show any native UI) whenever Google's ConsentInformation reports the
privacy-options form isn't required for the current user. Call it any time
after AdManager().initialize(), from user interaction only — never as part
of app-startup gating.
Option 2 — Manual flag set (you have your own UI or CMP) #
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
));
CCPA / US state privacy — what the SDK applies on its own #
If any CMP on the device has written the IAB US Privacy string
(IABUSPrivacy_String — Google UMP writes it, so do most third-party CMPs),
the SDK reads it and applies a sale opt-out to both providers by itself:
AppLovin setDoNotSell(true) and AdMob restricted_data_processing on every
request. It reconciles at SDK init and on every app resume, so an opt-out the
user makes in a CMP while your app is backgrounded lands without your code
noticing anything.
AdManager().usPrivacyOptedOut reports the same signal if you want to show it.
Two deliberate limits:
- Tighten-only. A string that says the user did not opt out — and the
absence of any string, which is the normal case outside the US — never
clears a
doNotSellyou set yourself throughsetConsent. Your own switch is treated as the newer, deliberate decision. - The GPP string is only partially decoded.
IABGPP_HDR_GppString(the multi-state signal covering Virginia, Colorado, Texas and the rest) is exposed raw viaAdManager().gppConsentStringfor your own use. As a fallback when no legacyIABUSPrivacy_Stringexists,usPrivacyOptedOutalso decodes the GPP US National section'sSaleOptOut/SharingOptOutfields only — it does not read that section's separateTargetedAdvertisingOptOutfield, and it does not decode any state-specific section (California, Colorado, Virginia, Connecticut, ...). Mis-parsing a privacy signal is worse than not reading one, so the scope stays narrow and exact; both native SDKs also read the raw string themselves. If you need targeted-advertising opt-out or per-state handling beyond sale/sharing opt-out, decode the rest of the string yourself and callsetConsent.
Consent country analytics (optional, host-supplied) #
ConsentSettings.country is an optional String? field (e.g. 'DE', 'US')
you can attach for consent analytics — it flows through to
AdEventLog/ComplianceReport as consentCountry on every logged event, and
ComplianceReport.consentCountByCountry aggregates a count per country.
This is not real geolocation. The SDK has no way to determine a user's
actual country — Google UMP only exposes an EEA/non-EEA classification (plus
a debug-only override via AdConfig.umpDebugGeography), and AppLovin exposes
nothing at all. If you want this field populated, supply it yourself from
whatever source you already trust (e.g. Platform.localeName, your own
GeoIP service, or the billing address on file):
await ConsentManager.instance.set(
ConsentManager.instance.current.copyWith(country: 'DE'),
);
Left null (the default) if you never set it — it's simply omitted from the
aggregate, no crash, no placeholder value.
Cryptographically-signed compliance report export (T96) #
AdManager().exportComplianceReport(...) builds a ComplianceReport — the
evidence bundle to hand an ad network's support team when an account gets
flagged (consent state, safety-layer snapshot, raw ad-event history for the
window). exportSignedComplianceReport(...) wraps the same report with an
on-device Ed25519 signature, so an edit made to the exported file AFTER the
SDK produced it is detectable:
final signed = await AdManager().exportSignedComplianceReport(
from: DateTime.now().subtract(const Duration(days: 7)),
);
final bundleJson = signed.toJsonString(pretty: true);
// hand bundleJson (or write it to a file) to the ad network's dispute form.
The signing key is minted once per install and persisted via
flutter_secure_storage — no configuration needed. Anyone (you, or the ad
network reviewer) can verify a bundle independently:
final ok = await verifySignedComplianceReportJson(bundleJson);
or from the command line:
dart run tool/verify_compliance_report.dart path/to/exported_bundle.json
# → VALID or INVALID
Threat model — read before treating this as proof of anything more than
internal consistency. The signing key lives on the same device that
produces the report, and travels WITH the exported bundle. This proves the
exported JSON matches exactly what the SDK generated at generatedAt — it
stops a casual after-the-fact hand-edit of the file before you submit it. It
is not non-repudiation: a device owner who controls the app also controls
the signing key, so this cannot prove the events themselves weren't
fabricated by someone with that level of access. Treat it as "this file is
unmodified since export", not "this device's history is definitely genuine".
Flight Recorder .adproof exports (AdManager().exportSignedFlightRecorderBundle())
use the same signed-JSON threat model, plus a hash chain across entries. A
support/legal/network reviewer can verify an exported .adproof file
independently, without the app or Flutter installed, via the standalone CLI
shipped in this package:
dart run tool/verify_adproof.dart path/to/exported.adproof
# → VALID (exit 0) or INVALID (exit 1); usage/read errors exit 2
VALID means both the Ed25519 signature and every entry's hash-chain link
verify — the exported file is byte-for-byte what the SDK produced and no
entry was edited, reordered, deleted, or forged after export.
Consent provenance journal (T202) #
Opt-in — pass AdConfig(enableConsentProvenanceJournal: true, ...). Default
false: it does real SHA-256 hashing (package:cryptography) on every
consent change, latency an app with no legal-audit-trail need shouldn't pay
for by default.
AdManager().consentProvenanceJournal (nullable until SDK init completes AND
until enabled, same contract as AdManager().vip) is an append-only,
tamper-evident (SHA-256 hash chain) history of consent changes — distinct
from ConsentManager.current (current state only, overwritten on every
change) and ComplianceReport (a point-in-time snapshot): this is the change
history neither of those keeps.
final journal = AdManager().consentProvenanceJournal;
for (final entry in journal?.entries ?? const []) {
print('${entry.at}: ${entry.source} set hasUserConsent=${entry.hasUserConsent}');
}
await journal?.verifyChain(); // false ⇒ persisted history was tampered with
// For long histories, read an immutable oldest-first page (default 100,
// maximum 500; throws ArgumentError for a negative offset or invalid limit).
final page = journal?.entriesPage(offset: 0, limit: 100) ?? const [];
entriesPage is read-only and does not change exportSignedConsentProvenanceJournal():
the signed export still contains the full history. A local hash chain proves
internal consistency only — it cannot prove origin or non-deletion unless the
host also stores an external anchor (see onConsentProvenanceEntryAppended).
When enabled, every ConsentManager.set/.reset call records
an entry automatically. Pass source/policyRevision to tag where a
consent change came from (free text, e.g. 'ump', 'host', 'manual' —
same convention as IncidentEntry.label); both default to values that make
sense for a plain AdManager().setConsent(...) call.
Deliberately excluded from clearSdkData()'s default sweep — see
below.
Scoped data erasure (T200) #
For a "delete my data" / GDPR-style privacy request, use
AdManager().clearSdkData(...), not AdPreferences.clearAllData()
(that call wipes the ENTIRE shared SharedPreferences instance,
including any key a host app — or a different plugin — stored in the
same namespace):
// Safe default — clears safety counters, consent settings,
// compliance/analytics history, remote-config cache, experiment id.
// Never touches VIP entitlements OR the consent provenance journal.
await AdManager().clearSdkData();
// Also erase VIP entitlements (a paying user LOSES their VIP status) —
// requires an explicit confirmation flag; throws ArgumentError without it.
await AdManager().clearSdkData(
scope: SdkDataErasureScope.allIncludingEntitlements,
confirmedEntitlementErasure: true,
);
// Also purge the consent provenance journal (T202) — a SEPARATE decision
// from entitlements, orthogonal to `scope`: some legal frameworks permit/
// require KEEPING proof that consent was asked/received even after a
// user's general erasure request, so this never happens implicitly.
await AdManager().clearSdkData(purgeConsentProvenanceJournal: true);
Only ever removes keys this SDK itself owns (across both
SharedPreferences and flutter_secure_storage, where VIP entitlements
live) — a host app's own keys in the same storage are never touched at
either scope. If the SDK is already initialised when the entitlements
scope runs, the live VipManager instance is used, so the running
session's VIP status updates immediately rather than waiting for the
next restart.
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). AdMob honours this per-request viatagForChildDirectedTreatment. AppLovin MAX 4.x has no runtime child-directed API, so (T40, 2026-07-13)AppLovinAdapterrefuses to initialize at all whentrueis known at init time (persisted from a prior session) — every AppLovin ad surface then stays unavailable for the session (exposed viaAppLovinAdapter.disabledForChildUser). Known gap: on a brand-new install with no persisted consent yet, an app that is always child-directed (no consent flow has run yet) will still see AppLovin initialize once, since there's nothing yet to gate on — don't rely on this SDK for an always-child-directed app without adding your own explicit "child app" config ahead ofinitialize().- This AdMob-vs-AppLovin handling asymmetry (per-request tag vs. full init abort) is intentional, driven purely by what each provider's native SDK exposes — AdMob has a per-request COPPA flag, AppLovin MAX 4.x does not. It is not an inconsistency to "fix"; treat AppLovin's behavior (no ads at all for a known child-directed session) as the stricter, safer default for that provider.
- ❌ If targeting EEA users, integrate UMP via Option 2 above
- ❌ UMP consent message published (not just saved as draft) for this app's specific AdMob app ID — required again for every new app ID, see "Per-app-id setup" above
- ✅
example/is not a production template (T41, 2026-07-13):example/lib/main.dart's AppLovin SDK key + ad-unit IDs areYOUR_*placeholders read viaString.fromEnvironment— pass--dart-define=APPLOVIN_SDK_KEY=...(+ per-platform_BANNER_ID_IOS/_BANNER_ID_ANDROID/etc.) to exercise real ads locally; nothing real is committed to source.example/lib/main.dart's safety preset (kDemoSafetyParams, 999 caps/CTR off) only applies with--dart-define=QA_AD_STRESS=true— default isAdSafetyParams.auto. Review both before copying this example into a real app.
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 bottom-left; tap to expand a panel of realtime SDK
state (slot states, VIP status, init/splash flags, 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
roy93~ [AppLovinAdapter] inter [AppLovin] ✅ displayed | network=AppLovin creativeId=1540789 latency=792ms
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 inherits the app's default task
affinity (the package name). With taskAffinity="" on MainActivity, the
two activities land in different Android tasks — after HOME + reopen, the
stack breaks, and dismissing the ad drops the user to the launcher. Feels
like a crash to the user.
2. iOS requires SKAdNetworkItems #
Verify the current SKAdNetworkItems requirements for every enabled ad provider and mediation partner before release. AdMob and AppLovin publish their own lists; merge the applicable IDs into the host Info.plist without deleting partner-specific entries. See AdMob's current iOS guidance and AppLovin's current list; do not rely on a fixed list size, since provider inventories change.
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.
AdMob's own test-device allowlist (RequestConfiguration.setTestDeviceIds()) needs a different, unrelated ID — not the GAID above. No public formula for
it; trigger one ad request on the physical device and read the hex hash the
native SDK prints to logcat (tag Ads), debug and release both.
AdManager().adMobTestDeviceHashHint() returns that instruction plus the
device's current GAID (labeled — mixing the two up sends a QA device live
production ads). AdManager().currentDeviceGaid exposes the raw GAID alone.
4. setNavigatorKey must be called before runApp #
If you forget, App Open on resume and AdLoadingDialog's buffer screen have no BuildContext to use and silently skip.
5. Initialize the SDK in SplashScreen, not main #
The SDK fires a BoolEvent over SimpleEventBus when initialization completes. SimpleEventBus does replay its most recently fired event to a listener that registers late, but the conventional (and simplest to reason about) pattern is still to register before triggering init:
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).
7. AppLovin banner width — loadBannerIfNeeded(widthPx) is a no-op by design #
AdProviderAdapter.loadBannerIfNeeded(widthPx) is only meaningful for AdMob
(AdSize.getCurrentOrientationAnchoredAdaptiveBannerAdSize(widthPx) picks
the size at load time). AppLovin discards widthPx — not a gap, it's
handled elsewhere: MaxAdView with isAdaptiveBannerEnabled: true (the
plugin's default) reads the live MediaQuery width itself at build time,
including on rotation. AppLovinMAX.setBannerWidth() only applies to the
native overlay banner (createBanner/showBanner), a path this SDK doesn't
use. Net effect: AppLovin banners are adaptive in practice too, just sized
at display time instead of load time.
8. Don't configure the same AppLovin ad-unit id for bannerId and mrecId #
AppLovin's load-failure callback reports the ad-unit id, not which widget
(banner vs MREC) requested it — this SDK tells them apart by comparing that
id against your configured bannerId/mrecId. Same id for both (a
plausible copy-paste mistake) means a failure can't always be attributed
with certainty; the SDK warns at initialize() and falls back to whichever
has a load in flight, but a failure while both are loading can't be told
apart. Use two distinct ad-unit ids.
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, ssvCustomData, ssvUserId})
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> {
@override bool get preloadRewardedOnMount => true; // optional: preload rewarded ad on screen mount (e.g. Shop/Gift)
void loadRewardedAd(); // manually trigger rewarded load
Widget buildBanner({placement, active}); // anchored adaptive banner
Widget buildMrec({placement, active}); // fixed 300x250 rectangle
Widget buildNative({placement, active}); // fixed layout v1, see Native Ad (v1)
void showInterstitialAd({required onDone, ...}); // pre-check + buffer + show
Future<void> showRewardedAd({ // pre-check + buffer + show
required onEarnedReward,
vipAutoGrant,
placement,
disclosureTitle, // set → confirm dialog before the ad plays
disclosureSubtitle,
disclosureButtonLabel, // default 'Watch ad'
disclosureCancelLabel, // default 'Cancel' — localize for non-English hosts
});
}
disclosureTitle is opt-in: omit it and the call is unchanged (straight to the ad after
the ready/throttle pre-check). Pass it to show a small confirm dialog naming the reward
first — declining calls onEarnedReward(false) and never reaches the ad.
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.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.
AdRevenueEvent.placement reports the placement the ad was shown from —
the value passed to showInterstitial/showRewardedAd/
showRewardedInterstitialAd/showAppOpenAd (App Open: always
AdPlacement.splash). Inline formats (banner, MREC, native) always report
AdPlacement.unspecified — nothing "shows" them, so there's no placement to
attribute.
AdRevenueEvent.mediationWaterfall (List<String>?) reports the adapter
class names tried for that impression, winner last. Full ordered waterfall on
AdMob (ResponseInfo.adapterResponses); AppLovin MAX only reports the
winner — always a single-element list. Null if the SDK returned no
response info.
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.
API stability & deprecation policy (T217) #
This package follows semantic versioning: a MAJOR
bump means a breaking change, MINOR means new backwards-compatible API,
PATCH means a fix with no API change. The "Migration" section below is the
historical record of this in practice — every version jump so far has been
"no breaking change" or "deprecations, not removals" (see 1.x → 2.x).
- Deprecating something: mark it
@Deprecated('use X instead')(never delete it outright in the same release). It stays callable, with a compile-time warning, for at least one MINOR version before an actual removal — which itself only happens in a MAJOR bump, called out explicitly inCHANGELOG.mdand the "Migration" section above. - Marking something not yet stable: new, still-evolving API is tagged
@experimental(frompackage:meta) in its doc comment while it's being shakedown-tested — a signal it may still change shape based on early feedback, lifted once it's proven out over a release or two. - Enforcement — the API golden test:
test/api_golden_test.dartwalks the fully resolved public export surface oflib/applovin_admob_sdk.dart(every class/enum/top-level symbol a host actually sees, and every public member declared on each — seetool/api_surface.dart's doc comment for exactly what counts and what's deliberately excluded, namely@internal/@visibleForTestingseams) and fails on any diff from the checked-intest/goldens/public_api_surface.txt. A hand-written changelog entry is easy to forget mid-refactor; a failing test in the sameflutter testrun as everything else is not. On a genuine, intentional API change:- Update
CHANGELOG.md. - Regenerate the golden file:
dart run tool/api_surface.dart > test/goldens/public_api_surface.txt - Review that diff like any other code change before committing it.
- Update
Migration #
See doc/AD_PROMPT_FLUTTER.MD → Appendix D for a step-by-step guide (merged from the former MIGRATION.md, 2026-08-20). That file ships inside the package from 3.6.2 on: dart pub unpack applovin_admob_sdk:<VERSION> puts it at doc/AD_PROMPT_FLUTTER.MD.
- 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.0.20 → 1.0.21 → 1.0.22 → 1.0.23 — all backwards-compatible. Just bump the version and run
flutter pub get. 1.0.21 refreshes dependencies; 1.0.22 adds the opt-instackflag (VIP stacking),bypassVipGuardonshowRewardedAd, andAdConfig.maxVipStackDuration; 1.0.23's App-Open-skip-on-dialog behaviour is automatic. (1.0.21/1.0.22 were not published to pub.dev — the public line jumped 1.0.20 → 1.0.23.) - 1.x → 2.x — backwards-compatible (deprecations, not removals). Old call sites compile and behave the same.
Support #
- Bug reports: this package's source repo is private, so there's no public issue tracker — email
loitp@skyjoy.vnwithroy93~log output, SDK version, and provider (admob/appLovin). Best-effort, single maintainer, no SLA. - Demo app:
packages/ad_sdk/example/lib/main.dart— 21 self-contained demo pages, one per feature - Architecture deep-dive:
doc/architecture.md— state machine, splash flow, safety gate, memory management (in the git repo only; the pub.dev tarball ships justdoc/AD_PROMPT_FLUTTER.MDanddoc/screenshots/, see.pubignore)
License #
MIT — see LICENSE file.
