applovin_admob_sdk 1.0.23 copy "applovin_admob_sdk: ^1.0.23" to clipboard
applovin_admob_sdk: ^1.0.23 copied to clipboard

PlatformiOS

Dual-provider ad SDK for Flutter (AdMob + AppLovin MAX). Adapter pattern, state machine, VIP redeem with Cupertino dialog, GDPR/COPPA/CCPA flags, exponential backoff, AdEvent stream, debug overlay.

applovin_admob_sdk #

pub.dev Flutter License: MIT

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 #

  1. Why this SDK
  2. What's new in 1.0.19
  3. Quick start (copy-paste in 6 steps)
  4. Configuration reference
  5. VIP system
  6. Consent & compliance (GDPR / COPPA / CCPA)
  7. Debugging
  8. Pitfalls — read before filing a bug
  9. Public API surface
  10. FAQ
  11. Migration from older versions
  12. Support
  13. License

Why this SDK #

You want The SDK gives you
Switch between AdMob and AppLovin without rewriting code One AdConfig.provider flag
First-session ad-free experience for new installs (boost D1 retention) firstInstallVipGrace — auto-grants VIP for 24 hours on first install
GDPR-compliant consent UI without integrating a third-party CMP Built-in Cupertino consent dialog, auto-shown post-splash
Google UMP form for EEA users AdManager().requestUmpConsent() — wraps google_mobile_ads's built-in ConsentInformation API
Anti-fraud protection so AdMob doesn't suspend your account Multi-layer safety gate: per-session/hour/day caps, throttle, CTR threshold, click-spam detection, progressive cooldown
Banner that pauses on navigation and resumes on return buildBanner() — hooks into the navigator and adapter lifecycle automatically
Revenue tracking for LTV analytics Stream<AdEvent> emits AdRevenueEvent per impression
Sane behavior when Android kills the process under memory pressure Smart App-Open timeout (lifecycle-aware), process-restart marker, detached state warning

What's new in 1.0.19 #

1.0.20 is an example-only release — the bundled example's splash now demos the recommended requestAtt() → requestUmpConsent() → initialize() ordering. No library / public-API change vs 1.0.19.

Backwards-compatible with 1.0.1x. Recent additions:

  • iOS App Tracking Transparency (1.0.19)AdManager().requestAtt() / requestAttIfNeeded() show the ATT prompt when needed and return a structured AttResult { status, idfa, allowsTracking } (AttStatus enum). No-op on Android; never throws. Call it in the splash before UMP. See Consent → Option 0.
  • iOS App-Open watchdog fix (1.0.19) — the lifecycle-aware show timeout no longer force-dismisses on iOS. On iOS the ad shows while the app stays resumed, so the Android-only "foreground = hung" heuristic was force-closing every iOS App Open at ~10 s; iOS now relies on the native hidden/displayFailed callbacks plus the 90 s hard cap.
  • First-install anti-bypass guard (1.0.17) — the first-install VIP grace is protected against uninstall/reinstall bypass (iOS Keychain flag; Android Auto Backup of SharedPreferences).

Earlier, the 1.0.15 release added:

  • Cupertino consent dialog — opt-in via AdConfig.autoShowConsentDialog: true (the default). Auto-shows on the home screen ~1 second after the splash flow completes, never during splash. Skipped automatically for VIP users. Persists the user's choice; surfaces the choice via ConsentManager.instance for re-show from a Privacy settings page.
  • Google UMP wrapperAdManager().requestUmpConsent(...) calls into google_mobile_ads's built-in UMP API (no extra dependency needed since google_mobile_ads 6.x). Returns a structured UmpConsentResult { canRequestAds, status, formShown, error }.
  • First-install VIP graceAdConfig.firstInstallVipGrace: FirstInstallVipGrace.auto (default). Auto-grants a one-time VIP entry on the very first SDK init for this install. Default: 30 seconds in debug builds, 24 hours in release. Tracked via SharedPreferences so the grant fires exactly once per install.
  • Smart App-Open timeout — replaces a fixed 10-second timeout that produced false-positive force-dismisses when users clicked an ad and were sent to a browser for 20+ seconds. The timeout polls the app lifecycle every 5 seconds (re-arms while paused), with a 90-second hard cap. On Android it force-dismisses when the app is foreground for two consecutive ticks without onAdHiddenCallback (= hung overlay). On iOS the ad shows while the app stays resumed, so foreground is ignored and only the native callbacks + 90 s hard cap apply (fixed in 1.0.19).
  • Slot-state dismiss watcher — replaces the brittle adapter-callback timestamp writes that used to fire at the wrong moment for rewarded ads (rewarded onDone fires when the reward is earned, not when the user actually dismisses). The watcher hooks every fullscreen slot's state.value and records the dismiss instant on showing → !showing. Source of truth for the resume guard.
  • VIP auto-expire timerVipManager now schedules a Timer for the soonest expiresAt. When it fires, the manager purges the expired entry, refreshes the active flag, and AdManager (listening to vip.activeListenable) preloads all four ad slots so the next user-triggered show finds an ad ready.
  • Granular diagnostic logging — every gate (adapter null, VIP, no network, slot showing, safety reason, recent dismiss) emits an explicit ⏭️ skipped — <reason> log instead of returning silently. Process-restart marker 🚀 AdManager singleton CREATED fires once per process so two markers in the same logcat session indicate Android killed and restarted the app. Lifecycle observer logs full state (prev → current, slot states, VIP, splash flag, backgrounded duration).

See CHANGELOG.md for the full list, including all bug fixes.


Quick start #

Audience: developers integrating ads into a fresh Flutter app. No prior AdMob or AppLovin experience required. Each step is copy-paste.

Prerequisites #

  • Flutter 3.27.0 or newer
  • Android minSdkVersion 24 or newer (AppLovin MAX 13.x + AdMob requirement)
  • iOS deployment target 13.0 or newer (required by AppLovin MAX 13.x and app_tracking_transparency)
  • An AdMob account (for AdMob ad units), an AppLovin account (for AppLovin), or both. The SDK ships Google's public test ad unit IDs so you can verify integration before creating real units.

Step 1 — Add the dependency #

Edit your app's pubspec.yaml:

dependencies:
  applovin_admob_sdk: ^1.0.20

  # Optional — only if you want to use AppLovin as an AdMob mediation network.
  # Skip this line if you are using AppLovin directly via AdProvider.appLovin
  # or AdMob without mediation.
  gma_mediation_applovin:

Then run:

flutter pub get

Step 2 — Android configuration #

Open android/app/src/main/AndroidManifest.xml and add the three permissions inside <manifest>:

<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="com.google.android.gms.permission.AD_ID"/>

Inside <application>, add the two <meta-data> tags below. Replace each value with your real key from the respective dashboard. The placeholders below use Google's public test App ID (always valid) and a placeholder for the AppLovin SDK key:

<application
    android:label="My App"
    android:icon="@mipmap/ic_launcher">

    <!-- Required by google_mobile_ads even if you only use AppLovin.
         Get yours from https://admob.google.com → Settings → App ID. -->
    <meta-data
        android:name="com.google.android.gms.ads.APPLICATION_ID"
        android:value="ca-app-pub-3940256099942544~3347511713"/>

    <!-- NOTE: `applovin_max` 4.x (used by this SDK) does NOT read the SDK key
         from a manifest meta-data. The 86-character key is passed at runtime via
         `AppLovinConfig.sdkKey` → `AdManager().initialize(...)`. You do NOT need
         an `applovin.sdk.key` meta-data here; adding one is harmless but ignored.
         Get the key from https://dash.applovin.com/o/account → Account → Keys. -->

    <activity
        android:name=".MainActivity"
        android:exported="true"
        android:launchMode="singleTop"
        ...>
        <!-- ⚠️ Do NOT add android:taskAffinity="" here. See "Pitfalls" below. -->
    </activity>
</application>

Update android/app/build.gradle.kts (or build.gradle) to require Android 5.0 or newer:

android {
    defaultConfig {
        minSdk = 24
        // ...
    }
}

Step 3 — iOS configuration #

Open ios/Runner/Info.plist and add the keys below at the root <dict>. Replace YOUR_… placeholders:

<!-- AdMob App ID — must match the Android one for the same app -->
<key>GADApplicationIdentifier</key>
<string>ca-app-pub-3940256099942544~1458002511</string>

<!-- AppLovin SDK Key — must match the Android one -->
<key>AppLovinSdkKey</key>
<string>YOUR_86_CHARACTER_APPLOVIN_SDK_KEY_HERE</string>

<!-- Required since iOS 14.5: shown in the system ATT prompt -->
<key>NSUserTrackingUsageDescription</key>
<string>This identifier is used to deliver personalised ads.</string>

<!-- Required by AdMob & AppLovin on iOS 14.5+. Copy the canonical list
     from https://developers.google.com/admob/ios/ios14#skadnetwork -->
<key>SKAdNetworkItems</key>
<array>
    <!-- ~70 entries — paste from the link above -->
</array>

Update ios/Podfile to require iOS 13 or newer:

platform :ios, '13.0'

Then install pods:

cd ios && pod install && cd ..

Step 4 — Bootstrap the SDK in main.dart #

Replace your lib/main.dart with this:

import 'package:flutter/material.dart';
import 'package:applovin_admob_sdk/applovin_admob_sdk.dart';

import 'splash_screen.dart';

/// Global navigator key — required so the SDK can show consent dialogs and
/// loading buffers from a context-less callback path (e.g., from the lifecycle
/// observer when an ad dismisses).
final navigatorKey = GlobalKey<NavigatorState>();

void main() {
  WidgetsFlutterBinding.ensureInitialized();

  // ⚠️ This MUST be called before runApp(). The SDK's auto-show consent
  // dialog and app-open-on-resume buffer rely on this navigator.
  AdManager().setNavigatorKey(navigatorKey);

  runApp(MaterialApp(
    title: 'My App',
    navigatorKey: navigatorKey,
    // ⚠️ Both observers are required:
    //   - adRouteObserver: pauses banner refresh on navigation,
    //                      resumes when the route comes back to top
    //   - AdScreenRouteLogger: emits route push/pop logs (debug only)
    navigatorObservers: [adRouteObserver, AdScreenRouteLogger()],
    home: const SplashScreen(),
  ));
}

Step 5 — Initialize the SDK in splash_screen.dart #

Create lib/splash_screen.dart. Replace the five TODO ad-unit IDs with values from your AppLovin dashboard. The AdMob IDs are Google's public test units and can be left as-is for verification:

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:applovin_admob_sdk/applovin_admob_sdk.dart';

import 'home_screen.dart';

class SplashScreen extends StatefulWidget {
  const SplashScreen({super.key});

  @override
  State<SplashScreen> createState() => _SplashScreenState();
}

class _SplashScreenState extends State<SplashScreen> {
  Timer? _hardCap;
  bool _navigated = false;

  @override
  void initState() {
    super.initState();

    AdManager().markSplashActive();
    AdManager().incrementSplashCount();

    // If the user reopens the app while the splash is still on the stack
    // (rare race), short-circuit straight to home.
    if (AdManager().countInitSplashScreen > 1) {
      WidgetsBinding.instance.addPostFrameCallback((_) => _goHome());
      return;
    }

    // Hard cap: if the SDK init or the splash app-open ad takes longer than
    // the budget (network issues, etc.), force-navigate so the user is not
    // stuck on the splash screen. Keep this in sync with
    // `AdConfig.splashMaxDuration` (default 8 s).
    _hardCap = Timer(const Duration(seconds: 8), _goHome);

    // ⚠️ Subscribe BEFORE calling initialize(). SimpleEventBus only
    // delivers fire events to listeners that registered before the fire.
    SimpleEventBus().listen((BoolEvent e) {
      if (e.value) {
        _showSplashAppOpen();
      } else {
        _goHome();
      }
    });

    WidgetsBinding.instance.addPostFrameCallback((_) {
      AdManager().initialize(
        config: AdConfig(
          // Pick one. Switch by changing this single line.
          provider: AdProvider.appLovin,

          // TODO: replace with your real keys from dash.applovin.com
          appLovin: const AppLovinConfig(
            sdkKey:        'YOUR_86_CHARACTER_APPLOVIN_SDK_KEY_HERE',
            bannerId:      'YOUR_BANNER_AD_UNIT_ID',
            interstitialId:'YOUR_INTERSTITIAL_AD_UNIT_ID',
            appOpenId:     'YOUR_APP_OPEN_AD_UNIT_ID',
            rewardedId:    'YOUR_REWARDED_AD_UNIT_ID',
          ),

          // AdMob test units — public, always valid. Replace with your real
          // ad unit IDs (from admob.google.com) before publishing the app.
          admob: const AdMobConfig(
            bannerId:       'ca-app-pub-3940256099942544/6300978111',
            interstitialId: 'ca-app-pub-3940256099942544/1033173712',
            appOpenId:      'ca-app-pub-3940256099942544/9257395921',
            rewardedId:     'ca-app-pub-3940256099942544/5224354917',
          ),

          // Optional: localise the auto-show consent dialog.
          // ConsentDialogStrings.vi for Vietnamese, or pass your own.
          // consentDialogStrings: ConsentDialogStrings.vi,

          // Optional: validate redeemed VIP keys against your server.
          // vipKeyValidator: (key) => myServer.verifyVipKey(key),
        ),
        onComplete: (success, gaid) {
          // Optional: log to your analytics here.
          debugPrint('SDK init complete: success=$success gaid=$gaid');
        },
      );
    });
  }

  void _showSplashAppOpen() {
    AdManager().loadAppOpenAd(onAdLoaded: (loaded) {
      if (_navigated || !mounted) return;
      if (!loaded) {
        _goHome();
        return;
      }
      AdLoadingDialog.showAdBuffer(context, onComplete: () {
        if (!mounted) {
          _goHome();
          return;
        }
        // Cancel the hard cap BEFORE showAppOpenAd — the ad now owns the
        // splash screen, so we should not race-fire markSplashInactive.
        _hardCap?.cancel();
        _hardCap = null;
        AdManager().showAppOpenAd(
          // bypassSafety: true is the ONE place we override safety —
          // splash app-open is a privileged placement.
          bypassSafety: true,
          onAdDismiss: (_) => _goHome(),
        );
      });
    });
  }

  void _goHome() {
    if (_navigated) return;
    _navigated = true;
    _hardCap?.cancel();
    _hardCap = null;
    AdManager().markSplashInactive();
    if (!mounted) return;
    Navigator.of(context).pushReplacement(
      MaterialPageRoute(builder: (_) => const HomeScreen()),
    );
  }

  @override
  void dispose() {
    _hardCap?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => const Scaffold(
        backgroundColor: Colors.deepPurple,
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Icon(Icons.ads_click, size: 80, color: Colors.white),
              SizedBox(height: 24),
              CircularProgressIndicator(color: Colors.white),
            ],
          ),
        ),
      );
}

Step 6 — Show ads on any screen #

Create lib/home_screen.dart. Any screen that should display ads extends AdScreen and uses AdScreenState instead of StatefulWidget and State. This gives you buildBanner(), showInterstitialAd(...), and showRewardedAd(...) automatically:

import 'package:flutter/material.dart';
import 'package:applovin_admob_sdk/applovin_admob_sdk.dart';

class HomeScreen extends AdScreen {
  const HomeScreen({super.key});

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends AdScreenState<HomeScreen> {
  int _coins = 0;

  @override
  Widget build(BuildContext context) => Scaffold(
        appBar: AppBar(title: const Text('My App')),
        body: Column(
          children: [
            // Anchored adaptive banner. Auto-loads, auto-pauses on
            // navigation, auto-resumes on return, auto-skips if VIP.
            buildBanner(),

            const SizedBox(height: 24),
            Text('Coins: $_coins', style: const TextStyle(fontSize: 24)),
            const SizedBox(height: 24),

            // Interstitial — full-screen ad after a user action
            FilledButton(
              onPressed: () => showInterstitialAd(
                onDone: (shown) {
                  // Called whether or not the ad actually appeared.
                  // shown=true → ad was displayed and dismissed
                  // shown=false → blocked by safety, VIP, no network, etc.
                  debugPrint('Interstitial result: $shown');
                },
              ),
              child: const Text('Show interstitial'),
            ),

            const SizedBox(height: 12),

            // Rewarded — user opts in to watch in exchange for a reward
            FilledButton(
              onPressed: () => showRewardedAd(
                onEarnedReward: (earned) {
                  if (earned) {
                    setState(() => _coins += 10);
                  }
                },
              ),
              child: const Text('Watch ad for +10 coins'),
            ),
          ],
        ),
      );
}

That's the entire integration. Run:

flutter run

You should see the splash screen, then a splash app-open ad (if available), then the home screen. After ~1 second on the home screen, the consent dialog appears (skipped on subsequent launches once the user has answered). The default behaviour you get out-of-the-box:

  • First-install VIP grace 24h — the user does not see ads during their first 24 hours after install. Tunable via AdConfig.firstInstallVipGrace.
  • Cupertino consent dialog auto-shown ~1 second after splash on the home screen (skipped if VIP). Tunable via AdConfig.autoShowConsentDialog, consentDialogStrings, consentDialogPostSplashDelay.
  • Splash app-open ad with an 8-second hard cap so the user is never stuck.
  • Banner pause/resume automatically when the user navigates between screens.
  • Anti-fraud multi-layer safety gate protects your AdMob/AppLovin account.

Configuration reference #

AdConfig #

AdConfig({
  // ─── Provider selection ─────────────────────────────────────────
  required AdProvider provider,
  AppLovinConfig? appLovin,
  AdMobConfig? admob,

  // ─── First-install VIP grace ────────────────────────────────────
  FirstInstallVipGrace firstInstallVipGrace = FirstInstallVipGrace.auto,
  String firstInstallVipKey = '__FIRST_INSTALL__',

  // ─── Consent flow ───────────────────────────────────────────────
  bool autoShowConsentDialog = true,
  ConsentDialogStrings consentDialogStrings = const ConsentDialogStrings(),
  bool consentBarrierDismissible = false,
  Duration consentDialogPostSplashDelay = const Duration(seconds: 1),

  // ─── Logging ────────────────────────────────────────────────────
  AdLogLevel logLevel = AdLogLevel.verbose,
  List<String>? logTagFilter,
  AdLogSink? onLog,

  // ─── Safety / fraud protection ──────────────────────────────────
  AdSafetyParams safety = AdSafetyParams.auto,

  // ─── VIP ────────────────────────────────────────────────────────
  Future<bool> Function(String key)? vipKeyValidator,
  VipDialogStrings vipDialogStrings = const VipDialogStrings(),
  // Legacy 1.x GAID allow-list — auto-migrated to VipManager entries
  // (year-2099 expiry) on first init for the matching device only.
  List<String> vipDeviceGaids = const [],

  // ─── Splash flow ────────────────────────────────────────────────
  Duration splashMaxDuration = const Duration(seconds: 8),

  // ─── User-facing strings ────────────────────────────────────────
  String adNotReadyMessage = 'Ad not ready — please wait and try again.',
  String adLoadingMessage = 'Loading…',

  // ─── Loading buffer ─────────────────────────────────────────────
  int loadingBufferMs = 1000,
})

FirstInstallVipGrace #

Build-mode-aware presets — picks the right duration based on kDebugMode:

FirstInstallVipGrace.auto       // 30s in debug, 24h in release (DEFAULT)
FirstInstallVipGrace.disabled   // never grant
FirstInstallVipGrace.day        // force 24h in both modes
FirstInstallVipGrace.debugShort // force 30s in both modes
const FirstInstallVipGrace(Duration(hours: 12))  // custom

The grant fires exactly once per install. Calling AdManager.destroy() followed by AdManager.initialize() in the same process does not re-grant.

AdSafetyParams presets #

AdSafetyParams.auto         // production in release, debug in debug (DEFAULT)
AdSafetyParams.production   // strict caps for real users
AdSafetyParams.debug        // loose caps for QA testing
AdSafetyParams.production.copyWith(
  maxFullscreenAdsPerDay: 10,
  dryRun: kDebugMode,
)                           // override individual knobs

AdLogLevel #

AdLogLevel.verbose   // everything (DEFAULT)
AdLogLevel.warning   // warnings + errors only
AdLogLevel.error     // errors only
AdLogLevel.none      // silent

VIP system #

Conflict policy: latest-expiry-wins vs. global stacking #

The stack flag decides how a grant combines with existing VIP time:

stack Behaviour Use for
false (default) Latest-expiry-wins — when an entry with the same key exists, the new now + duration replaces it only if it expires later; otherwise the existing (longer) entry is kept. Purchases/restore where you set an absolute window.
true Global stacking (cộng dồn toàn cục)duration is added on top of the latest expiry across ALL active entries (any source). Every grant extends one growing VIP window; the granted key's entry becomes the new latest (created if new, updated if it existed) and grantedAt resets to now. "Redeem code", "watch ad → +N days" — all accumulate.
// Global stacking: grants from ANY key add to one timeline.
await vip.addVip(key: 'WATCH',  duration: const Duration(days: 6),  stack: true); // 6d
await vip.addVip(key: 'PROMO30', duration: const Duration(days: 30), stack: true); // 36d total
await vip.addVip(key: 'PROMO30', duration: const Duration(days: 30), stack: true); // 66d total

Optional cap. Set AdConfig.maxVipStackDuration to bound the total stacked window — a stacked grant is then clamped to now + maxVipStackDuration (excess dropped; the entry still extends up to the cap). null (default) = uncapped. Only the stacking path is clamped; a plain absolute addVip is never touched.

Programmatic add (purchase / restore flow) #

Use this when the user purchases a VIP unlock through your IAP flow:

await AdManager().vip!.addVip(
  key: 'PURCHASED_PREMIUM_${transactionId}',
  duration: const Duration(days: 365),
);

Cupertino dialog redeem (user inputs a key) #

Use this if you ship promo/redeem keys for VIP. The SDK shows a verifying → success/failed Cupertino dialog flow:

final didRedeem = await AdManager().vip!.redeemVip(
  context,
  key: userInputKey,
  duration: const Duration(days: 30),
  validator: (key) async {
    // Validate against your server. Return true if valid.
    final response = await myServer.verifyVip(key);
    return response.isValid;
  },
  strings: AdManager().config?.vipDialogStrings ?? const VipDialogStrings(),
  stack: true, // accumulate onto the current window instead of replacing
);

Watch a rewarded ad to EXTEND VIP (even while already VIP) #

By default the SDK suppresses every ad for a VIP member, so a rewarded ad will not play (showRewardedAd calls back with vipAutoGrant). To let a VIP voluntarily watch a real rewarded ad to top up their window, pass bypassVipGuard: true. The slot isn't preloaded while VIP, so the SDK load-on-demands it before showing:

AdManager().showRewardedAd(
  bypassVipGuard: true,            // play a real ad even for a VIP
  onEarnedReward: (earned) {
    if (!earned) return;           // only granted on a completed ad — never auto-granted
    AdManager().vip?.addVip(
      key: 'REWARDED_VIP',         // fixed key + stack → one accumulating entry
      duration: const Duration(days: 3),
      stack: true,
    );
  },
);

During the on-demand load the SDK shows a blocking loading dialog and waits up to onDemandLoadTimeout (default 15 s, tunable per call). showRewardedAd is re-entrancy-safe — a second tap while a load/show is in flight is rejected with onEarnedReward(false).

Policy note: this is compliant because a real ad is always shown. Do not instead grant VIP without an ad — that loses revenue and risks rewarded-ad policy violations. Spam is bounded by the SDK's fullscreen safety caps.

Check VIP state #

// Synchronous check
if (AdManager().vip?.isActive ?? false) {
  // User is VIP — render premium UI
}

// Reactive — rebuilds when VIP state changes
ValueListenableBuilder<bool>(
  valueListenable: AdManager().vip!.activeListenable,
  builder: (_, active, __) => active
      ? const VipBadge()
      : const SizedBox.shrink(),
)

// Stream — for analytics / side effects
AdManager().vip!.activeStream.listen((active) {
  analytics.logEvent('vip_state_changed', {'active': active});
});

Revoke #

// Specific key (e.g., user requested refund)
await AdManager().vip!.revokeVip('PURCHASED_PREMIUM_${transactionId}');

// All entries (e.g., logout)
await AdManager().vip!.revokeAll();

Disable first-install grace #

If your app does not want the 24-hour grace (some genres prefer to monetize immediately):

AdConfig(
  firstInstallVipGrace: FirstInstallVipGrace.disabled,
  // ...
)

Anti-bypass guard #

The grace is protected against the trivial bypass of "uninstall + reinstall to claim a fresh 24-hour window." The guard runs automatically inside AdManager.initialize — host apps need no code changes for the iOS side. Android requires host-app Auto Backup configuration (see below).

Platform Mechanism Bypass-blocked scenarios Limitations
iOS Boolean flag in Keychain (kSecAttrAccessibleAfterFirstUnlock, no sync) Uninstall + reinstall on same device "Erase All Content and Settings" wipes Keychain; encrypted backup → new device carries the flag
Android Host's SharedPreferences flag (isFirstInstallGraceApplied) restored from Google Cloud Auto Backup Play Store reinstall on same Google account, after the ~24 h backup window Reinstall within ~24 h of install (before Auto Backup runs); user disables cloud backup; cross-account reinstall

Android — required host-app configuration

The SDK does not bundle a Play Install Referrer plugin: per Google's docs, Install Referrer timestamps reset on reinstall, so the API cannot distinguish a fresh install from a reinstall on its own. The realistic Android anti-bypass is Google Auto Backup restoring the grace flag from SharedPreferences on Play Store reinstall.

To enable it, in android/app/src/main/AndroidManifest.xml:

<application
    android:allowBackup="true"
    android:fullBackupContent="@xml/full_backup_content"
    android:dataExtractionRules="@xml/data_extraction_rules">

Create android/app/src/main/res/xml/data_extraction_rules.xml (Android 12+):

<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
  <cloud-backup>
    <include domain="sharedpref" path="FlutterSharedPreferences.xml"/>
    <exclude domain="sharedpref" path="FlutterSecureStorage.xml"/>
  </cloud-backup>
  <device-transfer>
    <include domain="sharedpref" path="FlutterSharedPreferences.xml"/>
    <exclude domain="sharedpref" path="FlutterSecureStorage.xml"/>
  </device-transfer>
</data-extraction-rules>

And full_backup_content.xml (Android 6–11):

<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
  <include domain="sharedpref" path="FlutterSharedPreferences.xml"/>
  <exclude domain="sharedpref" path="FlutterSecureStorage.xml"/>
</full-backup-content>

FlutterSecureStorage.xml is excluded because its EncryptedSharedPreferences ciphertext is unrecoverable without the device-bound Keystore key (which is not part of any backup).

Without this configuration, Android anti-bypass is effectively disabled — uninstall + reinstall always re-grants the grace window. That is a valid choice if you want to allow the bypass; just be aware of the trade-off.

Debug builds always bypass the guard

So you can iterate on flutter run without being locked out of the grace UX. Anti-bypass validation must happen on signed release builds (TestFlight / Play Store internal track).

Fail-open

The guard never denies grace on storage errors — it fails open so a transient Keychain hiccup never punishes a legitimate first-time user.


The SDK supports three patterns. Pick whichever matches your release strategy.

Option 1 — Built-in Cupertino dialog (simplest, default) #

The SDK auto-shows a clean Cupertino dialog ~1 second after markSplashInactive, so it lands on the home screen rather than competing with the splash app-open ad. Persists the user's choice to SharedPreferences. Skipped automatically for VIP users.

No code required — this is the default. To re-show from a Privacy settings screen:

await ConsentManager.instance.showDialog(context);

To localize:

AdConfig(
  consentDialogStrings: ConsentDialogStrings.vi,  // Vietnamese pre-canned
  // or supply your own:
  consentDialogStrings: const ConsentDialogStrings(
    title: 'Privacy Preferences',
    message: 'This app shows ads to keep it free. ...',
    allowButton: 'Allow personalized ads',
    rejectButton: 'No thanks',
    privacyPolicyLabel: 'Privacy Policy',
    privacyPolicyUrl: 'https://yourapp.com/privacy',
  ),
)

To disable auto-show entirely (e.g., if you have your own consent UI):

AdConfig(
  autoShowConsentDialog: false,
)

Option 0 — iOS App Tracking Transparency (call FIRST on iOS) #

ATT is built into the SDK — do not call app_tracking_transparency directly. Call requestAtt() from your splash screen (after the first frame), before requestUmpConsent and initialize, so the IDFA availability is settled before the first ad request:

final att = await AdManager().requestAtt();
// att.status   → AttStatus.{notSupported|notDetermined|restricted|denied|authorized}
// att.idfa     → String? (only when authorized and non-zero)
// att.allowsTracking → bool (true when authorized, or non-iOS where ATT doesn't apply)
  • No-op on Android — returns AttStatus.notSupported immediately.
  • 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() before runApp — Apple rejects ATT prompts shown over a blank screen.
  • Requires NSUserTrackingUsageDescription in Info.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 call setConsent.

Option 2 — Google UMP form (required for EEA users on AdMob) #

Wrap Google's UMP API. Call this in your splash after requestAtt() and before AdManager().initialize:

final result = await AdManager().requestUmpConsent(
  testMode: kDebugMode,
  debugGeography: DebugGeography.debugGeographyEea,
  testIdentifiers: kDebugMode ? const ['<your-device-hash>'] : const [],
);

if (!result.canRequestAds) {
  // User denied consent. You can either:
  //   - Skip ad initialization entirely
  //   - Initialize with non-personalized ads only
  return;
}

// Continue with AdManager().initialize(...) as normal

Option 3 — Manual flag set (you have your own UI) #

If you already integrate a third-party CMP and just want the SDK to forward the flags to the providers:

await AdManager().setConsent(AdConsent(
  hasUserConsent: true,        // GDPR consent
  isAgeRestrictedUser: false,  // COPPA: app targets children < 13
  doNotSell: false,            // CCPA: California user opts out of data sale
));

Compliance checklist #

  • app-ads.txt placed 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, before requestUmpConsent / AdManager().initialize (see Option 0)
  • ❌ If app targets children, isAgeRestrictedUser: true (COPPA)
  • ❌ If targeting EEA users, integrate UMP via Option 2 above

Debugging #

Built-in debug overlay #

Wrap your MaterialApp builder to mount a floating debug panel that appears only in debug builds:

runApp(MaterialApp(
  // ...
  builder: (context, child) {
    if (child == null) return const SizedBox.shrink();
    return Stack(children: [
      child,
      const DebugAdOverlay(),
    ]);
  },
));

A 🐛 Ad pill appears in the bottom-left corner. Tap to expand into a panel showing realtime SDK state: slot states (idle/loading/ready/showing/cooldown), VIP status, init flag, splash flag, safety status. Auto-hidden in release builds.

Verbose logs #

Every SDK log is prefixed with roy93~ [Tag] for easy grep. Examples:

roy93~ [AdManager] 🚀 AdManager singleton CREATED — new Flutter process / cold start at 2026-04-26T13:06:09.808
roy93~ [AdManager] initialize start, provider=appLovin
roy93~ [AppLovinAdapter] inter [AppLovin] ✅ displayed | network=AppLovin creativeId=1540789 latency=792ms
roy93~ [VipManager] ⏰ VIP entry expired — purging + refreshing
roy93~ [AdManager] 🛡️ interstitial dismissed — app-open suppression armed
roy93~ [AdManager] ⏭️ app-open on resume skipped — interstitial/rewarded currently showing

Pipe logs into Crashlytics / Sentry #

AdConfig(
  onLog: (level, tag, message) {
    if (level == AdLogLevel.error) {
      FirebaseCrashlytics.instance.log('[$tag] $message');
    }
    if (level == AdLogLevel.warning) {
      Sentry.captureMessage('[$tag] $message');
    }
  },
  // ...
)

Process-restart marker #

If you see two 🚀 AdManager singleton CREATED markers in the same logcat session, Android killed and restarted your app between them — typically because of memory pressure while the user had a long ad open. The user perceives this as "the app crashed". Use this signal to size your in-memory cache budget appropriately.


Pitfalls #

1. Do NOT set android:taskAffinity="" #

Flutter's flutter create template adds android:taskAffinity="" to MainActivity by default in some Flutter versions. Remove it when integrating this SDK with AppLovin:

  <activity
      android:name=".MainActivity"
      android:exported="true"
      android:launchMode="singleTop"
-     android:taskAffinity=""
      ...>

Why: AppLovin's full-screen ad activity (AppLovinFullscreenActivity) inherits the application's default task affinity, which is the package name. With android:taskAffinity="" on MainActivity, the two activities end up in different Android tasks. After the user presses HOME and reopens the app, the activity stack management breaks; when the user dismisses the ad, no activity is available to return to and Android drops the user to the launcher. The user perceives this as a crash.

2. iOS requires SKAdNetworkItems #

Without SKAdNetworkItems in Info.plist, AdMob and AppLovin will not serve ads on iOS 14.5+. Copy the canonical list from AdMob's iOS 14 guide — it has roughly 70 entries.

3. AppLovin has no public test ad units #

Unlike AdMob, AppLovin requires a real account and real ad unit IDs. To avoid being charged for development impressions, register your test device in dash.applovin.com → MAX → Test Mode. The SDK auto-registers the current device's GAID in debug builds via AppLovinMAX.setTestDeviceAdvertisingIds(...) so this is mostly handled for you.

4. setNavigatorKey must be called before runApp #

If you forget, the auto-show consent dialog has no BuildContext to use and silently skips. The dialog will eventually surface on a future launch, but better to wire it correctly the first time.

5. Initialize the SDK in SplashScreen, not main #

The SDK fires a BoolEvent over SimpleEventBus when initialization completes. Listeners must be registered before the fire — SimpleEventBus does not buffer past events for late subscribers. The conventional pattern is:

  1. splash.initState: register the listener
  2. splash.initState: schedule AdManager().initialize via a post-frame callback
  3. The init completes, BoolEvent fires, listener runs

If you initialize in main directly, the listener registration in your splash will miss the fire and the splash will hang on the hard cap.

6. Slot state after dismiss #

The SDK's _lastFullscreenDismissAt is recorded by a slot-state watcher on the showing → !showing transition, not by adapter callbacks. This is the source of truth for the resume-guard window. If you wrap or override slot state mutation, ensure the transition still fires (slot.markDismissed() or equivalent).


Public API #

AdManager singleton #

AdManager()                                // factory; returns the singleton
AdManager().setNavigatorKey(key)           // call before runApp (REQUIRED)
AdManager().initialize(config, onComplete) // call once in splash
AdManager().destroy()                      // teardown for hot-reinit / test cleanup

AdManager().markSplashActive()
AdManager().markSplashInactive()
AdManager().incrementSplashCount()

AdManager().setConsent(adConsent)          // GDPR / COPPA / CCPA flags
AdManager().requestAtt()                   // iOS ATT prompt (no-op Android) → AttResult
AdManager().requestUmpConsent(...)         // Google UMP wrapper

AdManager().showAppOpenAd(onAdDismiss)
AdManager().showInterstitial(onDoneFlow)
AdManager().showRewardedAd(onEarnedReward, {vipAutoGrant, bypassVipGuard, onDemandLoadTimeout})
AdManager().loadAppOpenAd(onAdLoaded)
AdManager().canShowInterstitial()

AdManager().isInitialised            // bool
AdManager().vip                      // VipManager? (null before init)
AdManager().consentManager           // ConsentManager?
AdManager().adapter                  // AdProviderAdapter?
AdManager().consent                  // current AdConsent flags
AdManager().events                   // Stream<AdEvent>
AdManager().initRevision             // ValueNotifier<int> — bumps on init
AdManager().processStartedAtMs       // wall-clock of singleton creation

AdScreen #

A base class for screens that display ads. Mirror replacement for StatefulWidget/State:

class HomeScreen extends AdScreen {
  const HomeScreen({super.key});
  @override State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends AdScreenState<HomeScreen> {
  Widget buildBanner();                                  // anchored adaptive banner
  void showInterstitialAd({required onDone, ...});       // pre-check + buffer + show
  void showRewardedAd({required onEarnedReward, ...});   // same
}

VipManager #

final vip = AdManager().vip!;

vip.addVip(key, duration, {stack})   // Future<VipEntry> — stack:true accumulates
vip.redeemVip(context, ..., {stack}) // Future<bool> — full Cupertino flow
vip.revokeVip(key)                   // Future<void>
vip.revokeAll()                      // Future<void>

vip.isActive                         // bool
vip.activeListenable                 // ValueListenable<bool>
vip.activeStream                     // Stream<bool>
vip.expiresAt                        // DateTime? — latest active entry
vip.entries                          // List<VipEntry> — read-only snapshot

ConsentManager #

final mgr = ConsentManager.instance;

mgr.current                          // ConsentSettings
mgr.listenable                       // ValueListenable<ConsentSettings>
mgr.hasBeenAsked                     // bool
mgr.adConsent                        // AdConsent — runtime flag projection

mgr.showDialog(context)              // re-show binary dialog
mgr.set(settings)                    // programmatic update + persist
mgr.applyToProviders()               // re-apply current to providers
mgr.reset()                          // wipe state — next init re-prompts

Stream<AdEvent> #

Pipe into Firebase / AppsFlyer / etc. for LTV tracking:

AdManager().events.listen((event) {
  if (event is AdRevenueEvent) {
    analytics.logAdRevenue(
      currency: event.currencyCode,
      value: event.value,
      network: event.networkName,
    );
  }
  if (event is AdRewardEvent) {
    analytics.logEvent('ad_reward', {'amount': event.amount});
  }
});

Event types: AdLoadEvent, AdShowEvent, AdClickEvent, AdRewardEvent, AdRevenueEvent.


FAQ #

Do I need both AdMob and AppLovin accounts? #

No. The provider you specify in AdConfig.provider determines which one is active at runtime. The other config struct (appLovin or admob) is unused but the constructor still requires the matching one to be non-null. Pass placeholder values for the unused one.

Can I switch providers at runtime? #

Not safely. Both SDKs are designed to initialize once per process. To swap, call AdManager().destroy(), change the config, and call AdManager().initialize() again — but be aware the user will see splash transitions and ad reload latency. Most apps pick one provider per build configuration.

How do I test the first-install grace? #

In debug builds, the grace defaults to 30 seconds. Wipe the app data and re-launch:

adb shell pm clear com.your.package
flutter run

The SDK logs 🎁 first-install VIP grace granted (30s, mode=debug) on a fresh install. After 30 seconds the timer fires, 🔓 VIP inactive — kicking secondary preload logs, and ads start serving.

Debug builds bypass the anti-bypass guard, so each flutter run cycle grants a fresh grace.

How do I test the anti-bypass guard? #

Anti-bypass only runs on release builds. Build a signed release and install it the way real users would:

  • iOS — TestFlight or a signed Ad Hoc build. Install, wait for grace to expire (30 s in debug, 24 h in release — temporarily set firstInstallVipGrace: FirstInstallVipGrace.debugShort in your test build to keep the cycle short), uninstall, then reinstall. Look for 🛡️ Keychain flag present — prior install detected on this device in 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? #

  1. 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.
  2. Check the DebugAdOverlay for the slot state. idle means no load attempted; loading means in-flight; ready means good to show; cooldown means a recent failure backed off; showing means already on screen.
  3. Verify your real ad unit IDs are not paused or pending review in the AdMob/AppLovin dashboard.
  4. AppLovin specifically: check that test mode is enabled for your device (dash.applovin.com → MAX → Test Mode).

Why does the app appear to crash when the user backgrounds during an ad? #

If you see two 🚀 AdManager singleton CREATED markers in your logcat session, Android killed and restarted your process while the user was viewing an ad with the app backgrounded. This is OS behavior — the SDK cannot prevent it directly, but you can mitigate by:

  • Reducing the number of ads cached simultaneously (e.g., disable banner preload during interstitial show)
  • Implementing state restoration so the user lands back on the same screen after the cold restart
  • Showing fewer or shorter ads on memory-constrained device classes

If you see only one 🚀 CREATED marker but the app still appears to crash, check that android:taskAffinity="" is not set on your MainActivity (see Pitfalls above).

What happens if the user revokes VIP halfway through a session? #

The SDK listens to VipManager.activeListenable. On true → false transition, it kicks all four ad slots into preload so the next user-triggered show finds an ad ready. The user's first ad after losing VIP may take 1-2 seconds to load (test ads load fast; real ads vary).

I see "Throttle: wait 0s" — is that a bug? #

That was a bug in 1.0.14 — sub-second waits truncated to zero. Fixed in 1.0.15: now displays "wait 645ms" or "wait 1.5s" depending on magnitude.


Migration #

See MIGRATION.md for a step-by-step guide.

  • 1.0.14 → 1.0.15 — no breaking change. Update the version, run flutter pub get, optionally remove android:taskAffinity="" from MainActivity.
  • 1.0.1x → 1.0.19 — no breaking change. New optional AdManager().requestAtt() for iOS ATT (call in splash before UMP); add NSUserTrackingUsageDescription to Info.plist if targeting iOS. iOS App-Open watchdog fix is automatic.
  • 1.0.19 → 1.0.20 — no breaking change, no API change (example-only update).
  • 1.x → 2.x — backwards-compatible (deprecations, not removals). Old call sites compile and behave the same.

Support #

  • Bug reports: open an issue on GitHub with roy93~ log output, SDK version, and provider (admob/appLovin)
  • Demo app: packages/ad_sdk/example/lib/main.dart — 11 self-contained demo pages, one per feature
  • Architecture deep-dive: doc/architecture.md — state machine, splash flow, safety gate, memory management

License #

MIT — see LICENSE file.

4
likes
140
points
313
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Dual-provider ad SDK for Flutter (AdMob + AppLovin MAX). Adapter pattern, state machine, VIP redeem with Cupertino dialog, GDPR/COPPA/CCPA flags, exponential backoff, AdEvent stream, debug overlay.

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

advertising_id, app_tracking_transparency, applovin_max, connection_notifier, flutter, flutter_secure_storage, google_mobile_ads, shared_preferences

More

Packages that depend on applovin_admob_sdk