unified_ads π±
One Dart API for banner, interstitial and rewarded ads across AdMob, AppLovin MAX, Unity Ads, ironSource LevelPlay, InMobi, Start.io and Facebook Audience Network, linking only the ad SDKs you choose.
await UnifiedAds.init(AdConfig(
testMode: true,
waterfall: [AdNetwork.admob, AdNetwork.unity, AdNetwork.inmobi],
networks: {
AdNetwork.admob: NetworkConfig(appId: 'β¦', interstitialAdUnitId: 'β¦', rewardedAdUnitId: 'β¦'),
AdNetwork.unity: NetworkConfig(appId: 'β¦', interstitialAdUnitId: 'β¦', rewardedAdUnitId: 'β¦'),
AdNetwork.inmobi: NetworkConfig(appId: 'β¦', interstitialAdUnitId: 'β¦', rewardedAdUnitId: 'β¦'),
},
));
final ad = InterstitialAd(onClosed: (ad) => print('served by ${ad.servedBy}'));
if ((await ad.load()).isSuccess) await ad.show(); // tries AdMob β Unity β InMobi
The core package of the unified_ads family: UnifiedAds, the ad classes, UnifiedBannerWidget, the waterfall, cache,
frequency caps, AdConfigLoader, and the iOS ATT helper. It depends on no ad SDK: add only the adapter packages for
the networks you want, and only those SDKs are linked into your app.
π Install
dependencies:
unified_ads: ^1.1.0
# add only the networks you want:
unified_ads_admob: ^1.1.0
unified_ads_unity: ^1.1.0
| Adapter | Network |
|---|---|
unified_ads_admob |
Google AdMob (+ UMP consent provider) |
unified_ads_applovin |
AppLovin MAX |
unified_ads_unity |
Unity Ads |
unified_ads_ironsource |
ironSource / Unity LevelPlay |
unified_ads_inmobi |
InMobi |
unified_ads_startapp |
Start.io |
unified_ads_facebook |
Facebook Audience Network, branded Meta (bidding-only: test ads direct, revenue via mediation) |
package:unified_ads/unified_ads.dart re-exports the whole platform interface (AdConfig, NetworkConfig, AdError,
AdEvent, ConsentState, β¦), so apps never import unified_ads_platform_interface directly. Tests:
package:unified_ads/testing.dart (FakeAdNetworkAdapter).
Everything about configuration, the API, every network's setup, consent, architecture and SDK versions follows below.
β¨ Features
- π§© Opt-in networks: each network is its own package. Depend on
unified_ads_admob+unified_ads_unityand only those SDKs end up in your APK / IPA. CI checks this on every push:tool/verify_opt_in.shbuilds an app with AdMob + Unity + InMobi and fails if any other network's Gradle artifact or Swift package is resolved. See Architecture β Opt-in linking. - π Waterfall fallback with per-network timeouts, preloading, one
AdErrormodel, and unified events. - β±οΈ Smart ad pacing: a cooldown between interstitials, "show one every N user actions", an initial delay after launch, and sliding-window frequency caps, configured per format (Pacing and ad-free).
- π Pro users / ad-free mode: one call (
UnifiedAds.setAdFree(true)) collapses banners and blocks interstitials and app open ads; rewarded ads stay available unless you block them too. - π‘οΈ Safe test mode: a global
testModeflag (overridable per network) that requests test ads wherever the network supports it. - π§― Never crashes the host app: every native failure becomes an
AdError. - π Consent and ATT: a Google UMP provider, a pluggable
ConsentProvider, and an App Tracking Transparency helper (Consent and ATT). - π― No hardcoding, config in code or in
pubspec.yaml:AdConfig, or a declarativeunified_ads:section turned intoads_config.jsonbydart run unified_ads:generate_config.
π Contents
- Packages Β· Feature matrix Β· Requirements
- Quick start Β· Configuration Β· API overview
- Getting your ad IDs Β· Platform setup
- Consent and ATT Β· Architecture
- SDK status Β· Single-package mode
- Example app Β· Migration notes Β· Development Β· Further reading
π¦ Packages
| Package | Purpose |
|---|---|
unified_ads |
Public API: UnifiedAds, InterstitialAd, RewardedAd, RewardedInterstitialAd, AppOpenAd, BannerAd, UnifiedBannerWidget |
unified_ads_platform_interface |
Adapter contract + shared models (re-exported by unified_ads) |
unified_ads_admob |
Google AdMob + UMP consent |
unified_ads_applovin |
AppLovin MAX |
unified_ads_unity |
Unity Ads |
unified_ads_ironsource |
ironSource / Unity LevelPlay |
unified_ads_inmobi |
InMobi |
unified_ads_startapp |
Start.io |
unified_ads_facebook |
Facebook Audience Network, branded Meta (bidding-only: test ads direct, revenue via mediation) |
ποΈ Feature matrix
| Network | Banner | Interstitial | Rewarded | Rewarded interstitial | App open | Meta bidding Β³ | Android | iOS | Package min (Android API / iOS) | Test mode | Device-verified (Android) |
|---|---|---|---|---|---|---|---|---|---|---|---|
| AdMob | β | β | β | β | β | β opt-in | β (GMA Next-Gen) | β³ CI | 24 / 13 | test devices | β test ads serve (all 5 formats load; Next-Gen) |
| AppLovin MAX | β | β | β | β | β | β opt-in | β | β³ CI | 24 / 13 | test devices | needs your SDK key |
| Unity Ads | β | β | β | β | β | β | β | β³ CI | 24 / 13 | flag | β test ads serve |
| LevelPlay | β | β | β | β | β | β opt-in | β | β³ CI | 24 / 13 | test suite only Β² | initializes; fill needs your app key + networks |
| InMobi | β | β | β | β | β | β | β | β³ CI | 24 / 13 | dashboard only Β² | initializes; fill needs your placements |
| Start.io | β | β | β | β | β | β | β | β³ CI | 24 / 13 | flag | β test ads serve |
| Facebook Audience Network ΒΉ | β | β | β | β | β | n/a | β | β³ CI | 24 / 15 | test devices | β banner + interstitial test ads; rewarded needs your placement |
ΒΉ Bidding-only: a direct load serves test ads but is not expected to fill in production; for revenue use
Meta bidding.
Β² testMode cannot force test ads for this network; init logs a warning. Use the vendor's dashboard or test suite.
Β³ Meta (Facebook) demand through the mediation platform's bidding. You opt in by adding the vendor's Meta adapter to your
app; the package detects it and forwards privacy. No Meta SDK is linked otherwise. See
Meta bidding.
"β" = the SDK has no such format; the waterfall skips that network for it.
Column legend:
- β Android: compiled against the pinned SDK, with Kotlin and Dart unit tests.
- β³ CI (iOS): Swift sources are built by the CI macOS job (
flutter build ios --no-codesign, Swift Package Manager only since 1.1.0). iOS hasn't run on a physical device yet.
RewardedInterstitialAd and AppOpenAd use the same waterfall, cache, frequency caps and callbacks as the other formats.
β Requirements
| Minimum | |
|---|---|
| Flutter / Dart | Flutter 3.44+ / Dart 3.12+ |
| Android | minSdk 24 (AdMob and AppLovin MAX need it), compileSdk 35+, Kotlin 2.x (InMobi needs Kotlin 2.x and AGP β₯ 8.9.3; Unity Ads 4.19+ uses Kotlin 2.1) |
| iOS | 13.0, or 15.0 with unified_ads_facebook or any Meta bidding adapter; Xcode 26.2+ |
| iOS build system | Swift Package Manager only (since 1.1.0). CocoaPods is not supported. SPM is on by default on stable (flutter config --enable-swift-package-manager) |
Two rules to know up front:
- Don't enable Unity Ads and LevelPlay together. The Unity SDK initializes once per process, so unified_ads skips
unitywithconfigConflict. Get Unity demand through LevelPlay mediation instead. - AdMob on Android uses the GMA Next-Gen SDK, which can't share an app with the legacy
play-services-ads(for example the officialgoogle_mobile_adsplugin, or a Google mediation adapter that pulls it). Exclude the legacy SDK if something brings it in (see Migration notes).
π Quick start
- Add the core and the networks you want:
Adapters register themselves (dependencies: unified_ads: ^1.1.0 unified_ads_admob: ^1.1.0 unified_ads_unity: ^1.1.0dartPluginClass); nothing else to wire up. - Do each network's platform setup (manifest / Info.plist entries, SKAdNetwork IDs, privacy): see Platform setup.
- At startup: ATT β consent β init:
await UnifiedAds.requestTrackingAuthorization(); // iOS prompt; no-op on Android await UnifiedAds.gatherConsent(const AdmobConsentProvider()); // Google UMP form if required final result = await UnifiedAds.init(config); // never throws; see result.failed / skipped - Show ads:
final rewarded = RewardedAd(onEarnedReward: (ad, reward) => grant(reward.amount)); if ((await rewarded.load()).isSuccess) await rewarded.show(); final banner = BannerAd(size: const BannerSize.adaptiveAnchored()); // in build(): UnifiedBannerWidget(ad: banner, anchored: true) - Optional: pace interstitials and switch ads off for Pro users:
See Pacing and ad-free.// In AdConfig: pacing: {AdFormat.interstitial: AdPacing(minInterval: Duration(seconds: 15), showEveryActions: 3)} await UnifiedAds.recordAction(); // after each user action if (await interstitial.canShow()) await interstitial.show(); await UnifiedAds.setAdFree(isProUser); // before init, and after a purchase
βοΈ Configuration
In code: AdConfig
AdConfig(
testMode: true, // global; NetworkConfig.testMode overrides per network
waterfall: [AdNetwork.admob, AdNetwork.unity, AdNetwork.inmobi], // priority order
networks: {
AdNetwork.admob: NetworkConfig(
appId: PlatformValue.select(android: 'ca-app-pub-β¦~β¦', ios: 'ca-app-pub-β¦~β¦'),
bannerAdUnitId: 'β¦',
interstitialAdUnitId: 'β¦',
rewardedAdUnitId: 'β¦',
rewardedInterstitialAdUnitId: 'β¦', // optional stretch formats
appOpenAdUnitId: 'β¦',
enabled: true, // per-network switch
enabledFormats: {AdFormat.banner, AdFormat.interstitial, AdFormat.rewarded}, // per-format switch
testDeviceIds: ['<hashed device id>'],
extras: {}, // network-specific options (reward amount, userId, β¦)
),
},
loadTimeout: Duration(seconds: 10), // per waterfall attempt
initTimeout: Duration(seconds: 20), // per network
showTimeout: Duration(seconds: 15), // show() gives up if the SDK never answers
preload: PreloadPolicy(formats: {AdFormat.interstitial, AdFormat.rewarded}, onInit: false),
frequencyCaps: {AdFormat.interstitial: FrequencyCap(maxShows: 3, per: Duration(hours: 1))},
pacing: {AdFormat.interstitial: AdPacing( // cooldown, every N actions, launch delay
minInterval: Duration(seconds: 15), showEveryActions: 3, initialDelay: Duration(seconds: 30),
)},
cacheTtl: Duration(hours: 1), // cached ads expire and reload transparently
)
appIdholds each network's app-level credential: AdMob App ID, Unity Game ID, MAX SDK key, LevelPlay App Key, InMobi Account ID, Start.io App ID (Facebook needs none). See Getting your ad IDs.- Use
PlatformValue.select(android: β¦, ios: β¦)for any ID that differs per platform (most do). - Build-time IDs: the AdMob App ID must also be in
AndroidManifest.xmlandInfo.plist. - Test mode is best effort per network (see Test-mode capability). Never ship test mode or test IDs in production.
Declarative: pubspec.yaml β ads_config.json
unified_ads:
testMode: true
waterfall: [admob, unity]
frequencyCaps:
interstitial: {maxShows: 3, perSeconds: 3600}
pacing:
interstitial: {minIntervalSeconds: 15, showEveryActions: 3, initialDelaySeconds: 30}
networks:
admob:
appId: {android: 'ca-app-pub-β¦~β¦', ios: 'ca-app-pub-β¦~β¦'}
interstitialAdUnitId: 'β¦'
unity:
appId: 'β¦'
interstitialAdUnitId: 'β¦'
flutter:
assets:
- assets/ads_config.json
dart run unified_ads:generate_config # writes assets/ads_config.json; --set-exit-if-changed for CI
final config = (await AdConfigLoader.fromAsset('assets/ads_config.json')).valueOrNull!;
await UnifiedAds.init(config);
The schema matches AdConfig one to one: any ID may be a string or {"android": β¦, "ios": β¦}, and network keys are
admob, applovin, unity, ironsource, inmobi, startapp, facebook. The generator and loader validate the
section and report errors with a JSON path (invalidConfig: $.networks.foo: unknown network). No build_runner, and it
works with hot restart. You can also write assets/ads_config.json by hand; AdConfigLoader.fromJson /
fromJsonString / toJson are available too.
π§ API overview
// Init: never throws.
final InitResult result = await UnifiedAds.init(config);
result.ready; // networks that initialized
result.failed; // network β AdError
result.skipped; // network β AdError (adapter missing, disabled, Unity+LevelPlay conflict, MAX + COPPA)
UnifiedAds.isInitialized; // true once at least one network is ready
// Full-screen formats: InterstitialAd, RewardedAd, RewardedInterstitialAd, AppOpenAd.
final ad = InterstitialAd(
forceNetwork: null, // set to bypass the waterfall
onLoaded: (ad) {}, onFailedToLoad: (ad, error) {},
onShown: (ad) {}, onFailedToShow: (ad, error) {},
onImpression: (ad) {}, onClicked: (ad) {}, onClosed: (ad) {},
);
final loaded = await ad.load(); // AdResult: isSuccess / error
ad.isReady; // loaded and not expired
await ad.canShow(); // ready, not ad-free, cap + pacing met (no side effects)
await ad.show();
ad.servedBy; // AdNetwork that actually served it
final rewarded = RewardedAd(onEarnedReward: (ad, RewardItem reward) => print('${reward.amount} ${reward.type}'));
// Banner
final banner = BannerAd(size: const BannerSize.adaptiveAnchored(), onLoaded: (ad) {});
UnifiedBannerWidget(ad: banner, anchored: true, placeholder: const SizedBox(), collapseWhenFailed: true);
banner.dispose();
// Pacing and ad-free (Pro users)
await UnifiedAds.recordAction(); // counts toward AdPacing.showEveryActions
UnifiedAds.actionCount;
await UnifiedAds.setAdFree(true, blockRewarded: false);
UnifiedAds.isAdFree;
UnifiedAds.adFreeChanges.listen((adFree) {});
// Cache / preload, events, logging, lifecycle
await UnifiedAds.preload(AdFormat.rewarded);
UnifiedAds.hasCachedAd(AdFormat.interstitial);
UnifiedAds.events.listen((AdEvent e) => print('${e.network} ${e.format} $e'));
UnifiedAds.logger = const AdsLogger.console(level: AdsLogLevel.debug); // silent by default
await UnifiedAds.dispose();
| Concept | Details |
|---|---|
| Banner sizes | BannerSize.standard (320Γ50), .largeBanner (320Γ100), .mediumRectangle (300Γ250), .leaderboard (728Γ90), .adaptiveAnchored({width}), .adaptiveInline({width, maxHeight}). The widget passes the available width, native resolves the real size, and the widget animates to it. Networks without adaptive sizes fall back to 320Γ50. |
| Anchoring | anchored: true wraps the banner in SafeArea; inline banners are laid out by the parent (works in scrolling lists). Taps go to the native view; drags still scroll the parent. |
| Events | sealed AdEvent: AdLoaded, AdFailedToLoad, AdFailedToShow, AdShown, AdImpression, AdClicked, AdClosed, AdEarnedReward(RewardItem), BannerSized(width, height). Each carries network, adId, format, timestamp. Order per ad: loaded β shown/impression β clicked* β earnedReward? β closed. |
| Waterfall | networks = waterfall β© ready β© supports(format) β© enabled formats, each tried with loadTimeout. If all fail you get AdError(noFill) with an attempts list (one AdError per network). |
| Cache + preload | one slot per full-screen format; formats in PreloadPolicy.formats reload automatically after closed / failedToShow; onInit: true preloads right after init; the next load() takes the cached ad; expired ads (cacheTtl) are destroyed and reloaded. |
| Frequency caps | FrequencyCap(maxShows, per) per format (sliding window). show() returns AdError(frequencyCapped) without touching native code. |
| Pacing | AdPacing(minInterval, showEveryActions, initialDelay) per full-screen format, on top of caps. show() returns frequencyCapped with a message naming the rule; the ad stays loaded. |
| Ad-free | UnifiedAds.setAdFree(true): banners collapse, interstitial / app open load(), show() and preload() return adsBlocked, cached ads are released. Rewarded formats are blocked only with blockRewarded: true. |
| Errors | AdError { code, message, network, nativeCode, attempts }. nativeCode always keeps the SDK's original code. |
| Logging | AdsLogger levels verbose < debug < info < warning < error < none; silent by default; plug in a custom sink (e.g. Crashlytics). Ad unit IDs are partially masked. |
| Lifecycle | UnifiedAds.init first disposes the previous session (hot restart safe); dispose() is idempotent; banners destroy their native view on dispose. |
| Testing | package:unified_ads/testing.dart β FakeAdNetworkAdapter (scriptable: succeed, fail, timeout, delayed events); pass it with UnifiedAds.init(config, adapters: [...]). |
Pacing, frequency caps and ad-free (Pro users)
Three layers decide whether a full-screen ad may show. show() checks all of them before contacting the network,
and a blocked ad stays loaded for the next opportunity.
1. Pacing (AdPacing): per format, in AdConfig.pacing (or pacing: in pubspec.yaml / ads_config.json):
AdConfig(
pacing: {
AdFormat.interstitial: AdPacing(
initialDelay: Duration(seconds: 30), // no interstitial in the first 30 s after the first init
minInterval: Duration(seconds: 15), // cooldown between two interstitials
showEveryActions: 3, // at least 3 UnifiedAds.recordAction() calls between shows
),
AdFormat.appOpen: AdPacing(minInterval: Duration(minutes: 5)),
},
)
// Record what counts as an "action" in your app: a finished level, a saved item, a screen changeβ¦
await UnifiedAds.recordAction();
// Then, at a natural break, show only if every rule allows it, and skip silently otherwise.
if (await interstitial.canShow()) {
await interstitial.show(); // onClosed continues your flow
} else {
continueFlow();
}
- The initial delay is measured from the first
UnifiedAds.initof the process; re-initializing doesn't restart it. - The action count and the last show times are kept in the
FrequencyStorepassed toUnifiedAds.init. The default store is in memory; extendFrequencyStore(for example on top ofshared_preferences) and overridereadActionCount/writeActionCount/readActionMark/writeActionMarkto keep them across launches. - When a rule blocks
show(), it fails withAdErrorCode.frequencyCappedand a message such asinterstitial cooldown: 9 s leftorinterstitial needs 3 actions between shows (1 so far).
2. Frequency caps (FrequencyCap): at most maxShows shows within any sliding window per, for example
FrequencyCap(maxShows: 3, per: Duration(hours: 1)). Caps and pacing apply together.
3. Ad-free (UnifiedAds.setAdFree): for Pro subscribers, or any user who shouldn't see ads.
await UnifiedAds.setAdFree(true); // banners, interstitials, app open off
await UnifiedAds.setAdFree(true, blockRewarded: true); // rewarded + rewarded interstitial off too
await UnifiedAds.setAdFree(false); // back on: banners reload by themselves
UnifiedAds.isAdFree; // current state
UnifiedAds.isBlocked(AdFormat.interstitial); // per-format check
- Banners shown by
UnifiedBannerWidgetremove their native view and collapse to zero size; they reload when ads are switched back on. load(),show()andUnifiedAds.preload()of a blocked format returnAdErrorCode.adsBlockedwithout a network request, and preloaded ads of blocked formats are released.- Rewarded ads stay available by default because the user chooses to watch them.
- The switch can be set before
initand survives re-initialization, but not an app restart. Call it at startup with the user's purchase state, so nothing is preloaded for a Pro user.
AdErrorCode
| Code | Raised when |
|---|---|
notInitialized |
an API is used before UnifiedAds.init |
invalidConfig |
config / JSON validation fails (message includes the field path), or the SDK rejects an ID |
configConflict |
Unity + LevelPlay are both enabled; MAX is used with COPPA |
adapterMissing |
the config enables a network whose adapter package isn't installed |
initializationFailed |
the native SDK init failed or timed out |
unsupportedFormat |
the network doesn't support the format |
unsupportedNetwork |
a documented stub adapter (none of the seven networks currently) |
formatDisabled / networkDisabled |
disabled by config |
noFill |
no ad available (the waterfall aggregates attempts) |
timeout |
load exceeded loadTimeout (or show exceeded showTimeout) |
networkError |
connectivity or server errors reported by the SDK |
notReady |
show was called with no loaded or valid ad |
alreadyShowing |
show was called while another full-screen ad is visible |
frequencyCapped |
a FrequencyCap or an AdPacing rule (cooldown, actions, initial delay) isn't met yet |
adsBlocked |
ads of the format are switched off with UnifiedAds.setAdFree (Pro users) |
showFailed |
the SDK reported a show failure |
noActivity |
no foreground Activity or ViewController is available |
internal |
anything unexpected (the original exception is kept in message) |
Never-crash guarantees: Kotlin and Swift wrap every host-API body in try/catch; Pigeon errors become
PlatformException, which the adapter maps to AdError; core wraps every adapter call in a guard that reports
internal; a throwing user callback is logged and doesn't break the ad pipeline.
π Getting your ad IDs
Every network needs an app-level credential, and most also need one ID per ad format:
| Network | Console | App-level credential β NetworkConfig.appId |
Per-format IDs β bannerAdUnitId / interstitialAdUnitId / rewardedAdUnitId |
Also put the app ID in |
|---|---|---|---|---|
| AdMob | admob.google.com | App ID ca-app-pub-XXXXXXXXXXXXXXXX~YYYYYYYYYY (one per platform) |
Ad unit IDs ca-app-pub-XXXXXXXXXXXXXXXX/NNNNNNNNNN |
AndroidManifest.xml + Info.plist (required) |
| AppLovin MAX | dash.applovin.com | SDK key (account level) | MAX ad unit IDs (bound to your package name / bundle ID) | β |
| Unity Ads | cloud.unity.com | Game ID: one for Android, one for iOS | Placement IDs | β |
| LevelPlay | platform.ironsrc.com | App Key (one per app/platform) | Ad unit IDs | β |
| InMobi | publisher.inmobi.com | Account ID | Placement IDs (numeric) | β |
| Start.io | portal.start.io | App ID | none: optional free-form ad tags | β |
| Facebook Audience Network | Monetization Manager (Meta) | none | Placement IDs (one per format) | Test device hash |
AdMob and AppLovin MAX also take rewardedInterstitialAdUnitId (AdMob) and appOpenAdUnitId (AdMob, MAX) for the
stretch formats.
Tips for every network
- Create one app per platform (Android and iOS get different IDs) and use
PlatformValue.select. - Use the exact package name (Android
applicationId) and bundle ID (iOS). Several networks bind ads to it. - New apps and units can take hours to days to start serving (approval, app review, propagation). Use test mode meanwhile.
- Publish an
app-ads.txtfile on your developer website. AdMob requires it for new apps, and many advertisers won't bid without it. - Never ship test mode or test IDs in a production build.
AdMob
You need: App ID per platform + one ad unit ID per format.
- Account: sign in at admob.google.com, choose your country (can't be changed later), accept the terms, verify a phone number and submit payment details. Verification takes up to 24 hours.
- App: Apps β Add app β Android or iOS. Published: search the store listing. Not published: enter a name and link the store later. Each app goes through a readiness review (2β3 days) once it's published and linked.
- App ID: Apps β View all apps β copy the App ID column. Format
ca-app-pub-β¦~β¦(note the~). - Ad units: Apps β your app β Ad units β Add ad unit β Banner, Interstitial or Rewarded (set the
reward amount and item; they become your
RewardItem) β Create ad unit. Formatca-app-pub-β¦/β¦(note the/). For the stretch formats, also create Rewarded interstitial (rewardedInterstitialAdUnitId) and App open (appOpenAdUnitId) units. - Testing: Settings β Test devices, or put the hashed ID the SDK prints to logcat / Xcode console into
testDeviceIds. Google's demo ad units always serve test ads. - Consent: Privacy & messaging β create a GDPR message (and a US state regulations message if needed).
AdmobConsentProvidershows them. - app-ads.txt: Apps β View all apps β app-ads.txt β copy your line
(
google.com, pub-XXXXXXXXXXXXXXXX, DIRECT, f08c47fec0942fa0) tohttps://<your-domain>/app-ads.txt.
AdNetwork.admob: NetworkConfig(
appId: PlatformValue.select(android: 'ca-app-pub-β¦~AAAA', ios: 'ca-app-pub-β¦~IIII'),
bannerAdUnitId: PlatformValue.select(android: 'ca-app-pub-β¦/1111', ios: 'ca-app-pub-β¦/2222'),
interstitialAdUnitId: PlatformValue.select(android: 'ca-app-pub-β¦/3333', ios: 'ca-app-pub-β¦/4444'),
rewardedAdUnitId: PlatformValue.select(android: 'ca-app-pub-β¦/5555', ios: 'ca-app-pub-β¦/6666'),
),
AppLovin MAX
You need: SDK key + one MAX ad unit ID per format and platform.
- Account: sign up at dash.applovin.com/signup. Account approval may be required (use the same email as your store listing). Set payments and your website domain.
- SDK key: Account β General β Keys β copy the SDK Key (
appId). - Ad units: MAX β Mediation β Manage β Ad Units β Create Your Ad Unit β name, platform, format (Banner,
Interstitial, Rewarded, or MRec for
BannerSize.mediumRectangle) β find your app or Manually Add Your Package Name. The package name / bundle ID is case-sensitive and must match exactly.AppOpenAdneeds an App Open unit (appOpenAdUnitId; β AppLovin's help page doesn't list App Open; ask your account manager if your dashboard doesn't offer it). - Testing: put your GAID / IDFA in
testDeviceIdswithtestMode: true; the Mediation Debugger can force test ads. If every MAX load times out, the SDK key or package name is wrong (MAX reports no error for an invalid key).
AdNetwork.applovin: NetworkConfig(
appId: '<SDK key>',
bannerAdUnitId: PlatformValue.select(android: 'β¦', ios: 'β¦'),
interstitialAdUnitId: PlatformValue.select(android: 'β¦', ios: 'β¦'),
rewardedAdUnitId: PlatformValue.select(android: 'β¦', ios: 'β¦'),
testDeviceIds: ['<your GAID / IDFA>'],
),
Unity Ads
You need: Game ID per platform + placement ID per format.
Use Unity Ads directly only if you don't use LevelPlay. With LevelPlay, enable Unity Ads as a bidder inside LevelPlay.
- Project: cloud.unity.com β Projects β Create project (the COPPA designation can't be changed later) β open Monetization and enable Unity Ads β add your store IDs in project Settings.
- Game IDs: project Settings β Game IDs: one for Android, one for iOS.
- Placements: every project has six default bidding placements (usually
Banner_Android,Interstitial_Android,Rewarded_Androidand the_iOSversions). Since 11 Aug 2026 new placements are bidding only. - Testing:
testMode: true, or Monetization β Settings β Test devices β Override client test mode β Force test mode ON for all devices (register single devices with Create test device).
AdNetwork.unity: NetworkConfig(
appId: PlatformValue.select(android: '<Android Game ID>', ios: '<iOS Game ID>'),
bannerAdUnitId: PlatformValue.select(android: 'Banner_Android', ios: 'Banner_iOS'),
interstitialAdUnitId: PlatformValue.select(android: 'Interstitial_Android', ios: 'Interstitial_iOS'),
rewardedAdUnitId: PlatformValue.select(android: 'Rewarded_Android', ios: 'Rewarded_iOS'),
extras: {'rewardAmount': 10, 'rewardType': 'coins'}, // Unity rewards carry no amount
),
LevelPlay (ironSource)
You need: App Key + ad unit ID per format + at least one demand network.
ironSource Ads direct demand was sunset on 30 April 2026. LevelPlay mediation continues, but fill comes only from the networks you enable in LevelPlay (Unity recommends Unity Ads bidding). An app with no networks enabled gets no fill.
- Account: platform.ironsrc.com/partners/signup; answer the verification emails.
- App + App Key: New App β import from the store URL or enter a temporary name β set COPPA / CCPA β the App Key is on the Apps page.
- Ad units: Ad units β Create ad unit β name β format β Save. The ad unit ID is in the table.
- Demand: Mediation β Setup β SDK Networks β e.g. Unity Ads (API Key + Organization core ID) β per app Add bidder. Other networks: Instances β Add instance β Active. Each enabled network's LevelPlay adapter must also be added to your app (Gradle / Swift package on the Runner target); LevelPlay's Unity adapter bundles the Unity Ads SDK from 5.15.0.
- Testing: LevelPlay β Settings β Test devices, or the integration test suite (
is_test_suitemeta-data).
AdNetwork.ironsource: NetworkConfig(
appId: PlatformValue.select(android: '<Android App Key>', ios: '<iOS App Key>'),
bannerAdUnitId: PlatformValue.select(android: 'β¦', ios: 'β¦'),
interstitialAdUnitId: PlatformValue.select(android: 'β¦', ios: 'β¦'),
rewardedAdUnitId: PlatformValue.select(android: 'β¦', ios: 'β¦'),
),
InMobi
You need: Account ID + numeric placement ID per format.
- Account: InMobi publisher dashboard β Create Account β verify email. Add an app and a payment method to reach Pending Approval (about 24 working hours).
- Account ID: top-left corner, under your account name.
- App: Inventory β Inventory Settings β Add Inventory β Mobile App (store URL) or Unpublished App.
- Placements: expand the app β + Add a placement β Banner / Interstitial / Rewarded Video (with a
reward key/value, e.g.
Coins=1000). The long numeric Placement ID is under the name. - Testing: no code-level test flag. Turn test mode on per placement (Global or Device) and register devices
under Integration β Integration Testing. With
testMode: truethe adapter logs at DEBUG so your advertising ID is printed. Turn test mode off before release.
AdNetwork.inmobi: NetworkConfig(
appId: '<Account ID>',
bannerAdUnitId: PlatformValue.select(android: '<numeric id>', ios: '<numeric id>'),
interstitialAdUnitId: PlatformValue.select(android: 'β¦', ios: 'β¦'),
rewardedAdUnitId: PlatformValue.select(android: 'β¦', ios: 'β¦'), // a Rewarded Video placement
),
Start.io
You need: App ID only.
- Account: register at the Start.io publisher portal.
- App ID: Add New App β paste the store link, or Haven't published your app yet? β name + platform. The App ID is shown right away and under My Apps.
- Ad tags (optional): there are no ad unit IDs. A value in
bannerAdUnitId/interstitialAdUnitId/rewardedAdUnitIdis sent as a free-form ad tag (letters, β€ 200 chars) that appears in reports (Android: every format; iOS: interstitial and rewarded). - Testing:
testMode: trueenables test ads. Disable it in production, or the app won't monetize. Minimum payout $50.
AdNetwork.startapp: NetworkConfig(
appId: PlatformValue.select(android: '<Android App ID>', ios: '<iOS App ID>'),
interstitialAdUnitId: 'levelend', // optional ad tag
extras: {'rewardAmount': 1, 'rewardType': 'reward'}, // Start.io rewards carry no amount
),
Facebook Audience Network
You need: placement IDs only.
- Sign in to Monetization Manager β Create Property (Business Manager required) β add the Android and/or iOS platform.
- Create placement per format (Banner, Interstitial, Rewarded video). The Placement ID
(
<number>_<number>) is in the Placements table. A placement only serves its own format (a mismatch fails with 1011/1203 βinvalidConfig). - Testing: prefix the placement ID with
IMG_16_9_APP_INSTALL#for a test creative, or add the device hash the SDK logs (Test mode device hash: β¦) totestDeviceIdswithtestMode: true. Drop the prefix in release builds. - Production: Audience Network is bidding-only; direct requests aren't expected to fill. Add it as a bidder in AdMob, MAX or LevelPlay (Meta bidding).
AdNetwork.facebook: NetworkConfig(
bannerAdUnitId: 'IMG_16_9_APP_INSTALL#<banner placement id>',
interstitialAdUnitId: 'IMG_16_9_APP_INSTALL#<interstitial placement id>',
rewardedAdUnitId: 'IMG_16_9_APP_INSTALL#<rewarded placement id>',
testMode: true,
extras: {'rewardAmount': 1, 'rewardType': 'coins'},
),
Testing without your own IDs
| Network | Public test credentials | Result on a real Android device |
|---|---|---|
| AdMob | demo App ID ca-app-pub-3940256099942544~3347511713 (Android) / ~1458002511 (iOS); demo units on Android / iOS |
β all formats serve test ads |
| Start.io | demo App ID 205489527 (android-sdk-demo) |
β all formats serve |
| Unity Ads | sample Game ID 14851, placements bannerads, rewardedVideo |
β banner + rewarded (no interstitial placement in the sample) |
| LevelPlay | demo app key 25b63cf85 + demo ad units (Mediation-Demo-Apps) |
β initializes, but no fill |
| InMobi | sample account 8ba05e170e914971a0b45d977809a6cf, placement 1476878485960 |
β initializes, NO_FILL |
banner IMG_16_9_APP_INSTALL#2312433698835503_2964944860251047, interstitial IMG_16_9_APP_INSTALL#2312433698835503_2650502525028617 |
β banner + interstitial; no public rewarded placement | |
| AppLovin MAX | none public | needs your SDK key + ad units |
Use these only for development, in the example or a test build.
π² Platform setup
Android (all networks)
minSdk = 24inandroid/app/build.gradle.kts. R8 / ProGuard rules ship with each adapter and SDK; nothing to add.- Permissions (
INTERNET,ACCESS_NETWORK_STATE,AD_ID) are merged from the SDK manifests.
| Network | AndroidManifest / Gradle | Notes |
|---|---|---|
| AdMob | required <meta-data android:name="com.google.android.gms.ads.APPLICATION_ID" android:value="ca-app-pub-β¦~β¦"/> inside <application> |
GMA Next-Gen; exclude legacy play-services-ads if anything pulls it |
| AppLovin MAX | none (SDK key in code) | google() + mavenCentral() |
| Unity Ads | none (Game ID in code) | use only the lite protobuf variant |
| LevelPlay | declare com.google.android.gms.permission.AD_ID when targeting API 33+ |
the SDK pulls adquality-sdk with an open range; pin it for reproducible builds |
| InMobi | keep android:hardwareAccelerated="true" |
Kotlin 2.x, AGP β₯ 8.9.3; needs a foreground Activity to show |
| Start.io | COPPA only via <meta-data android:name="com.startapp.sdk.CHILD_DIRECTED" android:value="true"/> |
App ID in code; splash/return ads disabled by the adapter |
required network security config allowing cleartext to 127.0.0.1 only (below) |
otherwise AdError 7003 on Android 9+ |
<!-- android/app/src/main/res/xml/network_security_config.xml (Facebook / any Meta bidding adapter) -->
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">127.0.0.1</domain>
</domain-config>
</network-security-config>
<!-- AndroidManifest.xml: <application android:networkSecurityConfig="@xml/network_security_config" ...> -->
iOS (all networks)
- Swift Package Manager only (Flutter 3.44+, Xcode 26.2+). Each adapter's
ios/<package>/Package.swiftpins its vendor's official Swift package, so only the adapters you depend on are resolved (checkios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved). - Deployment target on the Runner target (Xcode β Runner β Build Settings β iOS Deployment Target): 13.0, or
15.0 with
unified_ads_facebook/ a Meta bidding adapter. -ObjC: InMobi and LevelPlay require it and their own Swift packages can't set it, so add-ObjCto Runner β Build Settings β Other Linker Flags when you use either (the adapters' manifests pass it too). Add it as well if MAX fails with undefined symbols or unrecognized selectors. InMobi's manifest also linkslibz,libsqlite3.0andWebKit, as InMobi requires.- App Transport Security: InMobi and LevelPlay recommend
NSAllowsArbitraryLoads = YES(HTTP creatives). It's optional for this plugin, and App Review may ask about it. - Another plugin that's still CocoaPods-only can keep its
Podfile(Flutter's mixed mode); never add pods for unified_ads networks.
| Network | Required Info.plist keys | Own SKAdNetwork ID(s) | Full list |
|---|---|---|---|
| Every app | NSUserTrackingUsageDescription (iOS terminates the app on the ATT prompt without it), SKAdNetworkItems |
β | β |
| AdMob | GADApplicationIdentifier = your iOS App ID (missing β invalidConfig for AdMob, no crash) |
cstr6suwn9.skadnetwork |
3p SKAdNetworks |
| Unity Ads | none | 4dzt52r2t5.skadnetwork |
JSON |
| AppLovin MAX | none | ludvb6z3bs.skadnetwork |
JSON |
| LevelPlay | none (optional: NSAllowsArbitraryLoads, NSAdvertisingAttributionReportEndpoint = https://postbacks-is.com/) |
su67r6k2v3.skadnetwork |
LevelPlay SKAdNetwork ID manager |
| InMobi | none (optional: NSAllowsArbitraryLoads) |
wzmmz9fp6w.skadnetwork |
JSON |
| Start.io | none | 5l3tpt7t6e.skadnetwork |
JSON |
| none | v9wttpbfk9.skadnetwork, n38lu8286q.skadnetwork (the whole list) |
Meta SKAdNetwork |
<!-- ios/Runner/Info.plist -->
<key>GADApplicationIdentifier</key>
<string>ca-app-pub-XXXXXXXXXXXXXXXX~IIIIIIIIII</string>
<key>NSUserTrackingUsageDescription</key>
<string>This identifier will be used to deliver personalized ads to you.</string>
<key>SKAdNetworkItems</key>
<array>
<dict><key>SKAdNetworkIdentifier</key><string>cstr6suwn9.skadnetwork</string></dict>
<dict><key>SKAdNetworkIdentifier</key><string>4dzt52r2t5.skadnetwork</string></dict>
<!-- β¦ one dict per ID β¦ -->
</array>
Privacy manifests: every vendor SDK and every unified_ads package ship their own PrivacyInfo.xcprivacy (the
packages collect nothing). The app still needs ios/Runner/PrivacyInfo.xcprivacy declaring NSPrivacyTracking = true, NSPrivacyTrackingDomains, NSPrivacyCollectedDataTypes (at least DeviceID and AdvertisingData with purpose
ThirdPartyAdvertising) and NSPrivacyAccessedAPITypes for your own code (e.g. UserDefaults, reason CA92.1).
Checker (pure Dart, runs anywhere): from a clone of the repo,
dart run tool/verify_ios_plist.dart --plist ios/Runner/Info.plist --networks admob,unity,inmobi \
--privacy ios/Runner/PrivacyInfo.xcprivacy
dart run tool/verify_ios_plist.dart --list-skadnetwork-ids --networks admob,unity
Meta (Facebook) bidding through mediation (opt-in)
Audience Network fills only through bidding. To earn Meta demand, add the mediation platform's Meta adapter to your
app. unified_ads_admob, unified_ads_applovin and unified_ads_ironsource declare no Meta dependency; they detect
the adapter at init (Meta bidding adapter detected (β¦) at info level) and forward privacy (ccpaOptOut β Limited Data
Use, coppa β mixed audience) before the platform initializes it. Every Meta adapter needs iOS 15.0 and the
127.0.0.1 cleartext config on Android.
| Mediation | Android (android/app/build.gradle.kts) |
iOS Swift package (Xcode β File β Add Package Dependencies β Runner target) |
|---|---|---|
| AdMob | implementation("com.google.ads.mediation:facebook:6.22.0.1") and exclude com.google.android.gms:play-services-ads + play-services-ads-lite in configurations.configureEach |
https://github.com/googleads/googleads-mobile-ios-mediation-meta, commit 0a3d0597402162334fb5e11816ca612478180095 (tag 6.22.000), product MetaAdapterTarget |
| AppLovin MAX | implementation("com.applovin.mediation:facebook-adapter:6.22.0.1") |
https://github.com/AppLovin/AppLovin-MAX-Swift-Package-Meta, exact 6220100.0.0, product AppLovinMediationFacebookAdapter |
| LevelPlay | implementation("com.unity3d.ads-mediation:facebook-adapter:5.6.0") (brings Audience Network 6.22.0) |
https://github.com/ironsource-mobile/LevelPlay-Facebook-Adapter-Swift-Package, exact 5.6.0, product FacebookAdapter |
Then select Meta as a bidder in the mediation dashboard and list that platform as the mediation partner in Monetization
Manager. Don't also run unified_ads_facebook for the same placements. Verify with
META_BIDDING=admob|applovin|ironsource bash tool/verify_opt_in.sh android.
π Consent (GDPR / US privacy / COPPA) and ATT
unified_ads separates collecting consent from applying it. A ConsentProvider (Google UMP or your own CMP)
returns a network-neutral ConsentState; UnifiedAds hands it to every enabled adapter, which translates it to its SDK's
privacy APIs. The core never depends on a consent SDK.
Startup order
await UnifiedAds.requestTrackingAuthorization(); // 1. iOS ATT prompt (no-op on Android)
await UnifiedAds.gatherConsent(const AdmobConsentProvider()); // 2. CMP form if required
await UnifiedAds.init(config); // 3. SDKs start with consent applied
- ATT first: Apple requires the prompt before any tracking. (If you configure UMP's IDFA explainer in the AdMob console, UMP shows the ATT prompt itself; skip step 1.)
- Consent before
init, so no SDK starts or requests ads before the user's choice is known. - A later
gatherConsent/updateConsentre-applies the state to every running adapter.
ConsentState
| Field | Meaning | Set by |
|---|---|---|
gdprApplies |
the user is in a GDPR region (null = unknown) |
CMP |
consentGiven |
the user consented to personalised ads (null = unknown) |
CMP |
tcString |
IAB TCF v2 string | CMP |
gppString |
IAB GPP string | CMP |
ccpaOptOut |
the user opted out of sale/sharing (US state laws) | CMP or app |
coppa |
child-directed treatment (COPPA, or under age of consent) | app |
gatherConsent keeps the app-level signals: a coppa: true you set stays set, and your ccpaOptOut is kept when the
provider reports none.
await UnifiedAds.updateConsent(const ConsentState(coppa: true)); // e.g. after an age gate
Google UMP (AdmobConsentProvider, in unified_ads_admob)
const provider = AdmobConsentProvider();
await UnifiedAds.gatherConsent(provider); // shows the form only when UMP requires it
if (await provider.isPrivacyOptionsRequired()) { // "Privacy settings" entry in your app
await provider.showPrivacyOptions();
}
if (!await provider.canRequestAds()) { /* don't load ads yet */ }
// Testing from outside the EEA:
AdmobConsentProvider(debugGeography: UmpDebugGeography.eea, testDeviceHashedIds: ['<hash from logcat / Xcode>']);
Create the consent message in the AdMob console (Privacy & messaging); without one UMP reports a form error, which is logged, and the flow continues with the last known state. UMP writes the IAB TCF / GPP strings, which AdMob, InMobi, AppLovin MAX and LevelPlay read themselves.
Using another CMP
class MyCmpConsentProvider extends ConsentProvider {
const MyCmpConsentProvider();
@override
Future<ConsentState> gather({bool forceForm = false}) async {
// Never throw: on failure return the last known state.
final result = await myCmp.showIfRequired(force: forceForm);
return ConsentState(
gdprApplies: result.gdprApplies,
consentGiven: result.personalisedAdsAllowed,
tcString: result.tcString,
ccpaOptOut: result.doNotSell,
);
}
@override
Future<void> showPrivacyOptions() => myCmp.showPreferences();
@override
Future<bool> canRequestAds() async => myCmp.hasAnswered;
}
Works for OneTrust, Didomi, Usercentrics or your own dialog. If the CMP runs outside Flutter, call
UnifiedAds.updateConsent(state) whenever the state changes.
What each adapter does with the state
| Network | consentGiven / GDPR |
ccpaOptOut |
coppa |
|---|---|---|---|
| AdMob | TCF/GPP read by the SDK | gad_rdp (restricted data processing) |
AgeRestrictedTreatment.CHILD |
| Unity Ads | UnityAds.userConsent |
userOptOut |
nonBehavioral |
| AppLovin MAX | setHasUserConsent |
setDoNotSell |
MAX is skipped (configConflict; MAX dropped COPPA support in 13.0) |
| LevelPlay | LevelPlayPrivacySettings.setGDPRConsent |
setCCPA |
setCOPPA |
| no API; Meta relies on your CMP / TCF | setDataProcessingOptions(["LDU"], 0, 0) |
mixed-audience flag | |
| Start.io | setUserConsent(β¦, "pas", β¦) |
IABUSPrivacy_String (1YYN / 1YNN) |
manifest meta-data only (Android) |
| InMobi | InMobi GDPR consent object (TCF also read) | InMobiPrivacyCompliance.setDoNotSell |
InMobiSdk.setIsAgeRestricted |
All are applied before the SDK initializes where the SDK requires it.
App Tracking Transparency (iOS 14.5+)
final status = await UnifiedAds.requestTrackingAuthorization();
// notDetermined, restricted, denied, authorized, notApplicable (Android / iOS < 14), unavailable
final current = await UnifiedAds.trackingAuthorizationStatus(); // no prompt
- The helper lives in the core package's iOS code (no ad SDK needed);
AppTrackingTransparencyis weak-linked so iOS 13 builds still work. Info.plistmust containNSUserTrackingUsageDescription, or iOS terminates the app when the prompt is requested.- iOS shows the prompt only while the app is active: call it after the first frame, not in
main()beforerunApp. - Audience Network (
unified_ads_facebook, or Meta bidding through AdMob / MAX / LevelPlay): on iOS 15β16 the adapters setFBAdSettings.setAdvertiserTrackingEnabledfrom the ATT status, as Meta requires below iOS 17; on iOS 17+ Audience Network 6.15+ reads the status itself.
ποΈ Architecture
Package structure
graph TD
App[Host app]
Core["unified_ads<br/>(core public API)"]
PI["unified_ads_platform_interface<br/>(contract + models)"]
A1[unified_ads_admob]
A2[unified_ads_unity]
A3[unified_ads_applovin]
A4[unified_ads_ironsource]
A5[unified_ads_facebook]
A6[unified_ads_startapp]
A7[unified_ads_inmobi]
App --> Core
App -. opt-in .-> A1
App -. opt-in .-> A2
App -. opt-in .-> A7
Core --> PI
A1 & A2 & A3 & A4 & A5 & A6 & A7 --> PI
A1 --> S1[(Google Mobile Ads + UMP)]
A2 --> S2[(Unity Ads)]
A3 --> S3[(AppLovin SDK)]
A4 --> S4[(LevelPlay SDK)]
A5 --> S5[(Audience Network SDK)]
A6 --> S6[(Start.io SDK)]
A7 --> S7[(InMobi SDK)]
| Package | May depend on | Must never depend on |
|---|---|---|
unified_ads_platform_interface |
flutter, plugin_platform_interface, meta |
core, any adapter, any ad SDK |
unified_ads (core) |
platform interface | any adapter, any ad SDK (Gradle/SPM) |
unified_ads_<network> |
platform interface, its own native SDK | core, other adapters, other SDKs |
| Package | Contents |
|---|---|
| platform interface | AdNetwork, AdFormat, AdConfig, NetworkConfig, BannerSize, AdError/AdErrorCode, RewardItem, sealed AdEvent, AdNetworkAdapter, BridgedAdNetworkAdapter, AdapterRegistry, ConsentProvider/ConsentState, TrackingAuthorization, AdsLogger, TestModeSupport |
| core | UnifiedAds facade, ad classes, UnifiedBannerWidget, waterfall engine, cache, frequency caps, AdConfigLoader, the iOS ATT helper |
| adapter | the Dart adapter, Pigeon schema + generated code, Kotlin + Swift implementation, banner PlatformView factory, consumer-rules.pro, Package.swift declaring only its SDK |
Adapter registration
Each adapter is a Flutter plugin with both a native pluginClass and a dartPluginClass. At engine startup the
generated Dart plugin registrant calls registerWith(), which registers the adapter in AdapterRegistry;
UnifiedAds.init then picks the adapters for the configured networks and warns about missing ones (adapterMissing).
# packages/unified_ads_admob/pubspec.yaml
flutter:
plugin:
platforms:
android:
package: dev.arovyx.plugin.unifiedads.admob
pluginClass: UnifiedAdsAdmobPlugin
dartPluginClass: AdmobAdapter
ios:
pluginClass: UnifiedAdsAdmobPlugin
dartPluginClass: AdmobAdapter
Why not federated implements: unified_ads? Flutter selects one implementation of a federated plugin per platform,
and we need N adapters active at once, so adapters extend AdNetworkAdapter instead. Escape hatch:
UnifiedAds.init(config, adapters: [AdmobAdapter()]) registers adapters explicitly (unit tests, background isolates).
Opt-in linking: how excluded SDKs never reach the binary
A native SDK is declared only in its own adapter's native build file. If the app doesn't depend on that adapter, the build never sees the file.
Android (Gradle):
flutter pub getwrites.flutter-plugins-dependencies, listing only plugins in the app's dependency graph.- The Flutter Gradle plugin includes one Gradle subproject per listed plugin.
packages/unified_ads_admob/android/build.gradle.ktsis the only place with the AdMob artifact (com.google.android.libraries.ads.mobile.sdk:ads-mobile-sdk); each adapter is the only place with its SDK.- Core's
android/build.gradle.ktshas no ad-SDK dependency.
Without unified_ads_unity there's no :unified_ads_unity project, no com.unity3d.ads resolution, no classes in the
dex and no merged manifest entries. R8 rules travel with each adapter (consumerProguardFiles) and disappear with it.
iOS (Swift Package Manager):
- Flutter generates
FlutterGeneratedPluginSwiftPackage, which adds each plugin'sios/<plugin>/Package.swiftonly for plugins listed in.flutter-plugins-dependencies. - Each adapter's manifest is the only one with its vendor package (exact pin), e.g.
.package(url: "https://github.com/googleads/swift-package-manager-google-mobile-ads", exact: "13.11.0"). - Core's
Package.swiftdepends only onFlutterFramework.
An excluded adapter means no package resolution, no download, no linker input and no linker warnings. Linker flags
(-ObjC for InMobi and LevelPlay) live in the adapter's own manifest, never in core.
Verification (CI, every push): tool/verify_opt_in.sh builds an AdMob + Unity + InMobi app and checks
./gradlew :app:dependencies --configuration releaseRuntimeClasspath and apkanalyzer dex packages for any excluded
group, and on iOS that Package.resolved contains only the opted-in vendor packages.
The AdNetworkAdapter contract
abstract class AdNetworkAdapter {
AdNetwork get network;
Set<AdFormat> get supportedFormats; // empty for documented stubs
TestModeSupport get testModeSupport; // flag | testDevices | testSuiteOnly | none
bool get requiresAdUnitId => true; // false for app-ID-only networks (Start.io)
Future<AdResult<void>> initialize(NetworkConfig config, {required bool testMode}); // idempotent, never throws
Future<AdResult<AdHandle>> load(AdFormat format, String adUnitId);
Future<AdResult<void>> show(AdHandle handle);
bool isReady(AdHandle handle);
Future<void> destroy(AdHandle handle);
String get bannerViewType; // 'dev.arovyx.plugin.unifiedads/<network>/banner'
Map<String, Object?> bannerCreationParams(BannerRequest request);
Future<void> applyConsent(ConsentState state);
Stream<AdEvent> get events; // broadcast; every event carries network + adId
Future<void> dispose(); // safe to call twice; initialize may follow
}
| Concern | Contract |
|---|---|
| Errors | adapters never throw; native exceptions and SDK codes map to AdError(code, message, network, nativeCode) |
| Unsupported | a format not in supportedFormats returns unsupportedFormat without touching native code |
| Ordering | adapters normalise native quirks (e.g. buffer a reward the SDK fires after close) |
| Show completion | show completes when the ad is presented (or failed to); closing is signalled by AdClosed |
| Threading | all native SDK and UI calls run on the platform main thread |
All seven native adapters extend BridgedAdNetworkAdapter, which implements the contract once (never throwing,
error mapping, loaded-ad tracking, event translation), so each adapter only bridges its native calls.
Native bridge (Pigeon 29): each adapter owns pigeons/messages.dart β @HostApi() <Network>HostApi
(initialize, load, show, isReady, destroy, applyConsent, dispose) and @FlutterApi() <Network>EventsApi
(onAdEvent). Channels are dev.flutter.pigeon.unified_ads_<network>.β¦, so adapters never collide. Kotlin gets
suspend methods; Swift gets async throws methods delegating to @MainActor code; native β Dart events go through an
ordered main-thread queue.
Runtime flows
sequenceDiagram
participant App
participant UA as UnifiedAds
participant A as Adapter (each, in parallel)
App->>UA: init(config)
UA->>UA: dispose previous session (hot restart safe), validate config
UA->>UA: guards: missing adapter β skip; unity+ironsource β skip unity; MAX + coppa β skip MAX
par per network, with initTimeout
UA->>A: applyConsent(currentConsent)
UA->>A: initialize(networkConfig, testMode)
end
UA-->>App: InitResult {ready, failed, skipped}
sequenceDiagram
participant App
participant W as Waterfall
participant N1 as Network 1
participant N2 as Network 2
App->>W: InterstitialAd.load()
W->>N1: load(format, unitId) with loadTimeout
N1-->>W: AdError(noFill) or timeout
W->>N2: load(format, unitId)
N2-->>W: AdHandle
W-->>App: loaded (servedBy = network 2), AdLoaded event
Banner (PlatformView) and lifecycle
| Aspect | Design |
|---|---|
| Android | PlatformViewLink + Hybrid Composition (correct touch, accessibility, click-through for WebView ads) |
| iOS | UiKitView hosting the SDK banner; rootViewController resolved at load time |
| Waterfall | UnifiedBannerWidget asks the waterfall for a network; on failure the view is disposed and the next network tried |
| Plugin lifecycle | Android FlutterPlugin + ActivityAware (weak Activity ref; loaded ads survive config changes); iOS looks up the key window's view controller at show time |
| Rotation | full-screen ads handled by the SDK; banners re-resolve their adaptive size on a width change |
| Engine detach / hot restart | every handle and view is destroyed; SDK-global init is kept (SDKs can't de-init) |
| Threading | Handler(Looper.getMainLooper()) / DispatchQueue.main |
π SDK status
Verified against official vendor sources (docs, release notes, Maven Central, vendor GitHub). Ad SDKs change monthly; re-verified before every adapter release.
| Network | Android artifact | iOS SDK | Swift package | Min Android API | Min iOS | B | I | R | RI | App open | Status |
|---|---|---|---|---|---|---|---|---|---|---|---|
| AdMob | GMA Next-Gen com.google.android.libraries.ads.mobile.sdk:ads-mobile-sdk:1.6.0 |
GoogleMobileAds 13.11.0 |
googleads/swift-package-manager-google-mobile-ads |
24 | 13 | β | β | β | β | β | Active; legacy Android SDK in maintenance mode, so the adapter uses Next-Gen |
| Google UMP | com.google.android.ump:user-messaging-platform:4.0.0 |
GoogleUserMessagingPlatform 3.1.0 |
googleads/swift-package-manager-google-user-messaging-platform |
23 | β | Active (AdMob's ConsentProvider) |
|||||
| Unity Ads | com.unity3d.ads:unity-ads:4.21.0 |
UnityAds 4.21.0 |
Unity-Technologies/Unity-Ads-Swift-Package |
β€ 23 | 13 | β | β | β | β | β | Active; static API deprecated (4.19), scheduled for removal in 5.0.0 (not released); adapter uses instance APIs |
| ironSource / LevelPlay | com.unity3d.ads-mediation:mediation-sdk:9.7.0 |
IronSourceSDK 9.7.0 |
ironsource-mobile/LevelPlay-Swift-Package (product UnityMediationSDK) |
β€ 23 | 12 | β | β | β | β | β | Renamed LevelPlay; 9.0 removed all IronSource.* APIs; ironSource Ads direct demand sunset 30 Apr 2026 |
| AppLovin MAX | com.applovin:applovin-sdk:13.6.4 |
AppLovinSDK 13.6.4 |
AppLovin/AppLovin-MAX-Swift-Package |
24 | 12 | β | β | β | β | β | Active; COPPA support removed in 13.0 |
| Facebook Audience Network | com.facebook.android:audience-network-sdk:6.22.0 |
FBAudienceNetwork 6.22.1 |
facebook/FBAudienceNetwork |
21 | 15 | β | β | β | β | β | Bidding-only since 2021; direct load serves test ads only; 6.22 is the last CocoaPods release |
| Start.io | com.startapp:inapp-sdk:5.4.0 |
StartAppSDK 4.15.0 |
StartApp-SDK/StartAppSDK-SwiftPackage (product StartApp) |
23 | 11 | β | β | β | β | β (splash only) | Active; rebranded, artifacts keep the StartApp names |
| InMobi | com.inmobi.monetization:inmobi-ads-kotlin:11.5.1 |
InMobiSDK 11.5.0 |
InMobi/InMobiSDK-Swift-Package |
21 | 12 | β | β | β | β | β | Active; Java artifact inmobi-ads abandoned at 10.1.4 |
B/I/R = Banner/Interstitial/Rewarded, RI = Rewarded Interstitial. Package floors are minSdk 24 and iOS 13 (15 with Facebook).
Meta (Facebook) bidding adapters (all built for FAN 6.22.x, all need iOS 15):
| Mediation | Android (Maven) | Brings FAN? | Detected class (Android / iOS) |
|---|---|---|---|
| AdMob | com.google.ads.mediation:facebook:6.22.0.1 |
yes, plus legacy play-services-ads which must be excluded |
com.google.ads.mediation.facebook.FacebookMediationAdapter / GADMediationAdapterFacebook |
| AppLovin MAX | com.applovin.mediation:facebook-adapter:6.22.0.1 |
yes | com.applovin.mediation.adapters.FacebookMediationAdapter / ALFacebookMediationAdapter |
| LevelPlay | com.unity3d.ads-mediation:facebook-adapter:5.6.0 |
yes (since 5.6.0) | com.ironsource.adapters.facebook.FacebookAdapter / ISFacebookAdapter |
On iOS the Meta Swift packages pin Audience Network exactly: Google's (6.22.000) pins 6.22.0, AppLovin's
6220100.0.0 and LevelPlay's 5.6.0 pin 6.22.1, the same as unified_ads_facebook 1.1.0. So unified_ads_facebook
1.1.0 can't share an iOS app with AdMob's Meta adapter (Meta discourages running both paths anyway).
Test-mode capability per network
| Network | Mechanism | TestModeSupport |
|---|---|---|
| AdMob | test device IDs (+ demo unit IDs) | testDevices |
| Unity Ads | withTestMode(true) |
flag |
| LevelPlay | test suite / dashboard | testSuiteOnly |
| AppLovin MAX | test device GAIDs | testDevices |
| Start.io | setTestAdsEnabled(true) |
flag |
| InMobi | dashboard only | none |
addTestDevices (hashed IDs) or IMG_16_9_APP_INSTALL# placements |
testDevices |
AdConfig.testMode is best effort per network; core logs a warning at init for networks that can't force test ads.
Cross-cutting findings
- Unity Ads vs LevelPlay: no duplicate symbols, but Unity "initializes only once per process. Whichever integration
initializes first wins, and the other fails silently." Core reports
configConflictand skipsunity. - Consent propagation: UMP's TCF/GPP strings are read automatically by InMobi (10.7.5+), MAX (12.0+) and LevelPlay (7.7+); Unity and Start.io get explicit setters.
- Toolchain: minSdk 24, Kotlin β₯ 2.1 (2.3.20 used), compileSdk β₯ 35 (36 used), AGP 9 + R8 (Unity Ads 4.20+), Xcode β₯ 26.2 (AdMob iOS 13.4+).
- Unity placements are bidding-only since 11 Aug 2026.
- Re-verified 2026-10-28 (1.1.0) against every vendor's current Android and iOS guide; the iOS names were checked
against the shipped
.swiftinterface/ headers of Unity Ads, LevelPlay, InMobi, Start.io and Audience Network. - Verification state: Android code of every adapter is compiler-verified against the pinned SDKs; AdMob, Unity Ads, Start.io and Facebook served test ads on a physical device. iOS is built and smoke-tested in CI on simulators; it hasn't run on a physical device yet.
π§± Single-package mode (alternative, Android-only)
For teams that want one dependency, the design (unified_ads_all, not shipped) selects networks with flags that must
agree:
| Layer | Flag | Effect |
|---|---|---|
| Dart | --dart-define=ADS_NETWORKS=admob,unity,inmobi, expanded by a helper into one const boolean per network (ADS_ADMOB=true β¦) so the compiler can fold it |
only listed adapters register; others are tree-shaken |
| Android | unifiedAds.networks=admob,unity,inmobi in android/gradle.properties |
only listed source sets and SDK deps compile |
| iOS | β | unsupported since 1.1.0: SPM manifests can't read build flags |
| Federated (recommended) | Single package | |
|---|---|---|
| Selecting networks | add or remove a package | flags that must match |
| Misconfiguration | impossible | flags drift β adapterMissing or "class not found" |
| iOS | SPM | unsupported |
| Versioning | per adapter | one version for every SDK change |
| Tree-shaking risk | none (code absent) | relies on const-evaluation and source-set filtering |
The public Dart API is the same in both modes, so switching only changes dependencies and flags (plus flutter clean).
π± Example app
The example/ app is a full test bench: it initializes every enabled network and has one card per
network (plus a "Waterfall (auto)" card) with Banner, Load/Show interstitial and Load/Show rewarded
buttons, a banner-size selector, a Settings tab (enable networks, reorder the waterfall, paste your own IDs; saved
with shared_preferences and re-initialized, plus an Ad-free (Pro user) switch and interstitial pacing fields),
a Record action button, and a Log tab with every callback and its timestamp.
cd example
flutter run
It starts with public test credentials: AdMob, Facebook (banner + interstitial), Unity Ads and Start.io serve test ads immediately; InMobi initializes but returns no-fill; AppLovin MAX and LevelPlay need your own keys.
π Migration notes
- 1.1.0: pacing and ad-free (additive). New
AdConfig.pacing/AdPacing,UnifiedAds.setAdFree,UnifiedAds.recordActionandFullScreenAd.canShow(). Two things can affect existing code:AdErrorCodehas a new value,adsBlocked, so an exhaustiveswitchover it needs a new case; andFrequencyStorehas four new methods with default bodies, so a store declared withimplements FrequencyStoremust add them (one thatextendsit compiles unchanged). - 1.1.0: iOS is Swift Package Manager only. Podspecs were removed. Enable SPM (Flutter 3.44+, Xcode 26.2+), set the
deployment target on the Runner target, and remove unified_ads pods from your
Podfile. Android and the Dart API are unchanged. Can't move yet? Pin'>=1.0.0 <1.1.0'. - 1.1.0 also refreshes the SDKs (no API change): GMA Next-Gen 1.6.0, LevelPlay 9.7.0, InMobi Android 11.5.1, Audience Network
iOS 6.22.1. On iOS add Start.io's SKAdNetwork ID
5l3tpt7t6e.skadnetwork, and-ObjCon the Runner target if you use InMobi or LevelPlay. With Meta bidding through LevelPlay on Android,facebook-adapter:5.6.0brings Audience Network itself; on iOS use AppLovin's Meta package6220100.0.0/ LevelPlay's5.6.0(both pin FAN 6.22.1). - 1.0.0: AdMob Android β GMA Next-Gen SDK. The Dart API and the manifest App ID are unchanged; set
NetworkConfig.appIdto the same ID (a mismatch is logged).AdError.nativeCodeon Android is now an enum name (NO_FILL,NETWORK_ERROR, β¦). Exclude legacyplay-services-adsif anything pulls it. - 1.0.0:
metaβfacebook.AdNetwork.metaβAdNetwork.facebook,unified_ads_metaβunified_ads_facebook,MetaAdapterβFacebookAdapter, JSON key"meta"β"facebook"("meta"is rejected).
π οΈ Development
flutter pub get
dart run melos run format # dart format --set-exit-if-changed
dart run melos run analyze # --fatal-infos, every package
dart run melos run test # Dart unit + widget tests
dart run melos run build:example && dart run melos run test:kotlin
dart run melos run verify:opt-in # AdMob + Unity + InMobi app links only those SDKs
dart run melos run test:tool # tool/ tests (verify_ios_plist)
dart run melos run verify:ios-plist # example Info.plist + privacy manifest
bash tool/ios_sim_smoke.sh all # macOS: simulator build + ad smoke test (also: a network id)
CI (.github/workflows/ci.yaml) runs all of these, plus the iOS build (SPM), the iOS
opt-in check, a publish dry-run per package, and pana. .github/workflows/ios.yml builds
the example and a scratch app per adapter for the simulator on free GitHub macOS runners (no signing), runs
example/integration_test/ad_smoke_test.dart against public test ads, and uploads screenshots and logs.
Releases: merges to master run CI and iOS (nothing runs on PRs or development); after a green merge the maintainer publishes every
package whose version changed to pub.dev by hand (guide). Contributions: CONTRIBUTING.md.
π Further reading
Everything above is complete on its own; these files go deeper:
- ARCHITECTURE.md: full design, every contract detail and testing strategy
- SDK_STATUS.md: per-network research, API names, sources
- doc/getting_ids.md: every dashboard step with official help links, CI test IDs
- doc/consent_and_att.md Β· doc/ios_setup.md Β· doc/setup/ (per network)
- doc/single_package_mode.md Β· doc/migration/ Β· IMPLEMENTATION.md Β· CHANGELOG.md
π License
MIT, see LICENSE.
Libraries
- testing
- Test utilities for apps and adapters built on unified_ads.
- unified_ads
- One Dart API for banner, interstitial, rewarded, rewarded interstitial and app open ads across AdMob, Unity Ads, AppLovin MAX, LevelPlay, Facebook Audience Network, Start.io and InMobi.