This is the abridged developer documentation for AdMob CMP
---
# Compose Multiplatform AdMob SDK for Android and iOS
> Six AdMob formats on Android and iOS behind one Kotlin API — suspend functions, StateFlow, and UMP consent wired into initialization.
## Quick start [Section titled “Quick start”](#quick-start) The integration has two required lines: one Maven dependency in `commonMain`, and one initialization call at the root of the app that gathers consent and starts Mobile Ads in a single step. The full setup — version catalogs, the Gradle plugin, Kotlin/Native test linking, the iOS-only Info.plist keys, and the `doctorIos` check — lives in the [Quickstart guide](/start/quickstart/). shared/build.gradle.kts
```kotlin
kotlin {
sourceSets {
commonMain.dependencies {
implementation("dev.avinya.ads:admob-cmp:1.1.0")
}
}
}
```
```kotlin
// commonMain — initialize once at the root
adManager.gatherConsentAndInitialize(
AdConfig(
androidAppId = TestAdIds.ANDROID_APP_ID,
iosAppId = TestAdIds.IOS_APP_ID,
testMode = true,
)
)
```
On iOS, the production ordering is UMP consent, then ATT tracking authorization, then initialize with `ConsentMode.InitializeOnlyIfAlreadyAllowed`. See the [iOS setup guide](/start/ios-setup/) for the two Swift Package Manager package URLs and the three `Info.plist` keys. [Continue to the full Quickstart →](/start/quickstart/) ## Capability comparison Comparison of admob-cmp against the [basic-ads](https://github.com/LexiLabs-App/basic-ads) Compose Multiplatform ads library, with the public documentation snapshot dated 31 July 2026. | Capability | AdMob CMP | basic-ads | | --------------------------------- | -------------------------------------------------------------------------- | --------------- | | Banner ads | Yes | Yes | | Interstitial ads | Yes | Yes | | Rewarded ads | Yes | Yes | | Rewarded interstitial ads | Yes | Yes | | App-open ads | Yes | Not offered | | Native ads | Yes | Not offered | | Native ad layout DSL and pooling | adLayout {} plus NativeAdPool max-size accounting | Not applicable | | UMP consent inside initialization | gatherConsentAndInitialize, three consent strategies, privacy-options form | Consent request | | iOS ATT ordering | tracking authorization between consent and initialize | Not documented | | Paid and revenue events | AdEvent.Paid with AdValue and ResponseInfo | Not documented | | Mediation adapter hooks | AdInitializationHook at three initialization points | Not documented | | Kotlin/Native test linking | Published dev.avinya.ads.admob-cmp Gradle plugin | Not addressed | | Generated API reference | Yes | Yes | | Maven Central publication | Yes | Yes | Comparison verified on 31 July 2026 from the public documentation of both projects. "Not documented" here means that basic-ads' public documentation does not describe this capability; it is a statement about documentation coverage, not a judgement on the basic-ads maintainer. basic-ads is the older and larger project in this space and was the earlier publisher of a generated API reference for a Compose Multiplatform ads library. Corrections are welcome — please [open an issue](https://github.com/Meet-Miyani/admob-compose-multiplatform/issues) on GitHub. ## Why this exists Putting ads into a Compose Multiplatform app meant leaving Compose. The Google Mobile Ads SDKs are Android and iOS libraries with callback-based APIs, so the shared module ended up holding an expect/actual seam, two sets of platform glue, and a consent flow bolted on afterwards. Samples and libraries already covered this ground, and they helped. What was still wanted was ads shaped like the rest of a Compose Multiplatform codebase: composables for the ad surfaces, suspend functions and StateFlow for the lifecycle, and consent gathered as part of initialization rather than as a step to remember. So it was built inside an app that was being written, then extracted once the shape had settled. The APIs here are the ones that had to hold up on real screens: a native ad pool with max-size accounting, retry and cache behaviour with the numbers written down, and an iOS ordering — consent, then tracking authorization, then initialize — that the library sequences for you. ## Compatibility and ordering * Kotlin 2.3.20 * Compose Multiplatform 1.11.1 * Android minSdk 26 * iOS deployment target 15.0 The Kotlin klib does not promise binary compatibility across minor versions; re-resolve the klib when you bump the SDK. ### Consent, ATT, and initialize sequence Initialization order: UMP consent, then ATT, then initializeThe required call order on iOS — gather UMP consent, request App Tracking Transparency authorisation, then initialize the ads SDK — and what Android reports instead. Initialization order: UMP consent, then ATT, then initializeCLAUDE.md invariants #5, #11[Read this diagram in words](/reference/diagrams-in-words/#init-sequence) ### Platform support matrix | Format | Android — GMA Next-Gen, API 26+ | iOS — GMA 13.x, iOS 15+ | Notes | | --------------------- | ------------------------------- | ----------------------- | ---------------------------------------------------------------------------------- | | Banner | ✔ Supported | ✔ Supported | Anchored adaptive, inline adaptive, fixed and fluid; collapsible on both platforms | | Interstitial | ✔ Supported | ✔ Supported | TTL'd FIFO cache, 1 hour | | Rewarded | ✔ Supported | ✔ Supported | RewardEarned event | | Rewarded interstitial | ✔ Supported | ✔ Supported | Same controller shape as Rewarded | | App-open | ✔ Supported | ✔ Supported | AppOpenAdCoordinator, cooldowns, blocking; cache TTL 4 hours | | Native | ✔ Supported | ✔ Supported | adLayout DSL and pooling on both; iOS renders through UIKit | | Capability | Android | iOS | Notes | | ----------------------------------------------------------------------------------------------------- | ---------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | UMP consent and privacy-options form | ✔ Supported | ✔ Supported | canRequestAds gates every request on both platforms | | App Tracking Transparency | — Not applicable | ✔ Supported | Android has no ATT: adManager.tracking always reports AdTrackingAuthorization.NotApplicable, and the AD\_ID manifest permission governs the advertising id there instead | | Mediation adapters | ✔ Supported | ✔ Supported | No adapters are bundled; add the ones you need | | Paid and revenue events | ✔ Supported | ✔ Supported | AdEvent.Paid carries AdValue and response info | | Native video events | ✖ Not available | ✔ Supported — five events | iOS emits VideoStarted, VideoPlayed, VideoPaused, VideoEnded and VideoMuted through GADVideoControllerDelegate. The Android GMA Next-Gen SDK exposes no equivalent callback surface on NativeAd, so Android emits none. This is an upstream SDK gap, not an admob-cmp omission — do not rely on native video events for cross-platform logic. | | immersiveMode, customClickGesture, publisherProvidedId, categoryExclusions, skipUninitializedAdapters | ✔ Supported | ✖ Silently ignored | Android-only request options | | customTargeting, placementId | ✔ Supported | ✔ Supported | Mapped on both platforms; placementId maps to placementID on iOS | Platform support matrix: six formats on Android and iOSCLAUDE.md invariants #11[Read this diagram in words](/reference/diagrams-in-words/#platform-matrix) ## Roadmap * Swift Package Manager dependency import Gated on four unmet upstream conditions (swiftPMDependencies); Maven-consumer propagation remains an open unknown and the project refuses to depend on an Alpha build-tool feature. * Native video events on Android Blocked on the upstream SDK. iOS exposes GADVideoControllerDelegate with five video events; no equivalent Android callback surface is available. [Full roadmap →](/project/roadmap/) * [Quickstart](/start/quickstart/) * [Installation](/start/installation/) * [Compatibility](/reference/compatibility/) * [Roadmap](/project/roadmap/) * [API reference](/api/) * [GitHub](https://github.com/Meet-Miyani/admob-compose-multiplatform) * [Apache-2.0 license](https://www.apache.org/licenses/LICENSE-2.0.txt) Built by [Meet Miyani](https://github.com/Meet-Miyani) at [Avinya](https://avinya.dev). Not affiliated with or endorsed by Google. AdMob and Google Mobile Ads are trademarks of Google LLC.
---
# What is AdMob CMP?
> AdMob CMP is a KMP AdMob library for Compose Multiplatform — six ad formats, UMP consent and mediation behind one Kotlin API on Android and iOS.
## What is AdMob CMP? [Section titled “What is AdMob CMP?”](#what-is-admob-cmp) AdMob CMP is a KMP AdMob library — a Kotlin Multiplatform wrapper over the Google Mobile Ads SDKs that ships a single Kotlin API for the formats AdMob publishers actually use, on both Android and iOS. The Android side binds the GMA Next-Gen SDK (1.3.0) and the iOS side binds GMA 13.x through Kotlin/Native cinterop, so the same `rememberAdManager()` call, the same `AdPlacement`, and the same `AdEvent` stream work on both platforms. The published coordinate is `dev.avinya.ads:admob-cmp` under the `dev.avinya.ads` group, at version 1.1.0. The Android app runs on API 26 or above and the iOS app targets iOS 15 or above. The library keeps AdMob’s vocabulary — `AdValue`, response info, adaptive banner sizes, UMP consent states, native asset names — but replaces the listener-style SDK surface with suspend functions, `StateFlow` state, and a single sealed `AdEvent` stream. You write the integration once and it runs identically on both platforms, with platform-specific behaviour isolated to thin Android and iOS adapters that the consumer never sees. Platform specifics stay where they belong: the Android app’s `AndroidManifest.xml` declares the AdMob app id, the iOS app’s `Info.plist` declares `GADApplicationIdentifier`, and everything in between is shared code. The split is deliberate. The library is published as two modules behind a single coordinate: `admob-cmp-core` holds the controllers, state machines, and platform adapters, while `admob-cmp-compose` holds the composable surface. Most consumers depend on the umbrella `dev.avinya.ads:admob-cmp` artifact and never notice the split. Apps that want the controller API without Compose Multiplatform can depend on `admob-cmp-core` directly. ## Which ad formats does it support? [Section titled “Which ad formats does it support?”](#which-ad-formats-does-it-support) Every format in the current AdMob catalogue, behind a single controller-per-format API: | Format | Controller | Composable | Guide | | -------------------------- | ------------------------------------------------------- | -------------- | ------------------------------------------ | | Banner (incl. collapsible) | `adManager.banner(placement)` | `BannerAdView` | [Banner ads](/formats/banner/) | | Interstitial | `adManager.interstitial(placement)` | — | [Interstitial ads](/formats/interstitial/) | | Rewarded | `adManager.rewarded(placement)` | — | [Rewarded ads](/formats/rewarded/) | | Rewarded interstitial | `adManager.rewardedInterstitial(placement)` | — | [Rewarded ads](/formats/rewarded/) | | App-open | `adManager.appOpen(placement)` + `AppOpenAdCoordinator` | — | [App-open ads](/formats/app-open/) | | Native | `adManager.nativeAd(placement)` (a pool) | `NativeAdView` | [Native ads](/formats/native/) | The controllers are placement-keyed and cached for the manager’s lifetime, so an app with three banner placements owns three `BannerAdController` instances — not a fresh one per recomposition. `NativeAdView` is the one exception in shape: it is a pool, because native ads are single-use. ## What does it give you that the raw SDKs do not? [Section titled “What does it give you that the raw SDKs do not?”](#what-does-it-give-you-that-the-raw-sdks-do-not) Four things the library owns so the app does not have to: 1. A placement-keyed controller cache so the same `AdPlacement` always resolves to the same instance with the same load state. 2. A TTL’d FIFO ad cache with capped exponential retry, configurable per placement through `AdCachePolicy`, `AdExpirationPolicy` and `AdRetryPolicy`. 3. A consent gate that makes a pre-consent ad request impossible by construction — every load checks `canRequestAds` and fails fast with `AdErrorCode.CONSENT_REQUIRED` when the user has not opted in. 4. One event stream instead of five callback interfaces, with `AdEvent` covering `Loaded`, `LoadFailed`, `Impression`, `Clicked`, `Paid`, `RewardEarned` and the iOS-only video family. The whole integration looks like this:
```kotlin
@Composable
fun App() {
val adManager = rememberAdManager()
LaunchedEffect(Unit) {
adManager.gatherConsentAndInitialize(
AdConfig(
androidAppId = TestAdIds.ANDROID_APP_ID,
iosAppId = TestAdIds.IOS_APP_ID,
)
)
}
val placement = remember {
AdPlacement(
id = "main_interstitial",
format = AdFormat.Interstitial,
androidAdUnitId = TestAdIds.ANDROID_INTERSTITIAL,
iosAdUnitId = TestAdIds.IOS_INTERSTITIAL,
)
}
val interstitial = remember(adManager) { adManager.interstitial(placement) }
val scope = rememberCoroutineScope()
Button(
onClick = {
scope.launch {
interstitial.load()
interstitial.show()
}
}
) { Text("Show ad") }
}
```
Two caveats worth knowing on day one. `show()` suspends for the ad’s whole on-screen lifetime, so it must run in a UI-scoped coroutine — never in `GlobalScope`. And a second `show()` on the same controller while one is still on screen returns `AdShowResult.NotReady` rather than queueing; await the first result before calling again. Both rules are documented in detail on the [interstitial guide](/formats/interstitial/). The cache and retry layer is the second thing people underestimate. A naive integration against the raw SDKs opens a TCP connection, calls GMA’s load, waits on a listener, parses the response, and only then exposes state. Every app that ships a full-screen ad eventually writes that caching, eviction, and retry layer themselves — usually wrong, and usually twice (once per platform). The library writes it once in shared code, with the policy surfaces the consumer needs (`AdCachePolicy`, `AdExpirationPolicy`, `AdRetryPolicy`, `AdTimeoutPolicy`) and nothing else. The [caching, retry and timeouts guide](/advanced/caching-retry-timeouts/) goes through every knob. ## How does it compare with other Kotlin Multiplatform AdMob libraries? [Section titled “How does it compare with other Kotlin Multiplatform AdMob libraries?”](#how-does-it-compare-with-other-kotlin-multiplatform-admob-libraries) A neutral look at capability, not at quality: | Capability | AdMob CMP | Typical Kotlin Multiplatform AdMob wrapper | | ----------------------------------------------- | ---------------------------------------------------- | ------------------------------------------ | | Banner, interstitial, rewarded | Yes | Yes | | Rewarded interstitial | Yes | Varies | | App-open ads | Yes | Usually not offered | | Native ads | Yes, with a layout DSL and an ad pool | Usually not offered | | UMP consent inside the init flow | Yes, with `ConsentMode` and the privacy options form | Usually a standalone consent call | | iOS ATT ordering handled explicitly | Yes | Varies | | Paid/revenue events and mediation response info | Yes | Varies | | Kotlin/Native test linking on iOS | Solved by a published Gradle plugin | Usually unaddressed | | iOS distribution model | Bindings-only cinterop; the app links GMA via SPM | Varies | The closest alternative in this space is [`LexiLabs-App/basic-ads`](https://github.com/LexiLabs-App/basic-ads). It covers banner, interstitial and rewarded formats. If those three formats are all a project needs and its API shape suits the codebase better, it is a reasonable choice. AdMob CMP exists for projects that also need native ads, app-open ads, consent wired into initialization, or Kotlin/Native tests that link on iOS. ## Which platforms and versions does it run on? [Section titled “Which platforms and versions does it run on?”](#which-platforms-and-versions-does-it-run-on) | Format | Android — GMA Next-Gen, API 26+ | iOS — GMA 13.x, iOS 15+ | Notes | | --------------------- | ------------------------------- | ----------------------- | ---------------------------------------------------------------------------------- | | Banner | ✔ Supported | ✔ Supported | Anchored adaptive, inline adaptive, fixed and fluid; collapsible on both platforms | | Interstitial | ✔ Supported | ✔ Supported | TTL'd FIFO cache, 1 hour | | Rewarded | ✔ Supported | ✔ Supported | RewardEarned event | | Rewarded interstitial | ✔ Supported | ✔ Supported | Same controller shape as Rewarded | | App-open | ✔ Supported | ✔ Supported | AppOpenAdCoordinator, cooldowns, blocking; cache TTL 4 hours | | Native | ✔ Supported | ✔ Supported | adLayout DSL and pooling on both; iOS renders through UIKit | | Capability | Android | iOS | Notes | | ----------------------------------------------------------------------------------------------------- | ---------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | UMP consent and privacy-options form | ✔ Supported | ✔ Supported | canRequestAds gates every request on both platforms | | App Tracking Transparency | — Not applicable | ✔ Supported | Android has no ATT: adManager.tracking always reports AdTrackingAuthorization.NotApplicable, and the AD\_ID manifest permission governs the advertising id there instead | | Mediation adapters | ✔ Supported | ✔ Supported | No adapters are bundled; add the ones you need | | Paid and revenue events | ✔ Supported | ✔ Supported | AdEvent.Paid carries AdValue and response info | | Native video events | ✖ Not available | ✔ Supported — five events | iOS emits VideoStarted, VideoPlayed, VideoPaused, VideoEnded and VideoMuted through GADVideoControllerDelegate. The Android GMA Next-Gen SDK exposes no equivalent callback surface on NativeAd, so Android emits none. This is an upstream SDK gap, not an admob-cmp omission — do not rely on native video events for cross-platform logic. | | immersiveMode, customClickGesture, publisherProvidedId, categoryExclusions, skipUninitializedAdapters | ✔ Supported | ✖ Silently ignored | Android-only request options | | customTargeting, placementId | ✔ Supported | ✔ Supported | Mapped on both platforms; placementId maps to placementID on iOS | Platform support matrix: six formats on Android and iOSCLAUDE.md invariants #11[Read this diagram in words](/reference/diagrams-in-words/#platform-matrix) Android is API 26 or above, with the GMA Next-Gen SDK and UMP arriving as transitive Maven dependencies. iOS is 15.0 or above, with the GMA and UMP frameworks linked by the app through Swift Package Manager. The full version table — Kotlin, Compose Multiplatform, Android `minSdk`, iOS deployment target, and the GMA / UMP versions each release was built against — is on [Compatibility](/reference/compatibility/). ## What does AdMob CMP deliberately not do? [Section titled “What does AdMob CMP deliberately not do?”](#what-does-admob-cmp-deliberately-not-do) A short list, because the omissions are features: * It ships zero mediation adapters by design. Adapters are platform binaries that must match the GMA SDK version and the publisher’s network contracts; bundling them would pin GMA versions for every consumer. Add them to your Android module and Xcode project directly. See [Mediation](/advanced/mediation/). * It never embeds Google’s binaries on iOS, only cinterop bindings. The app links the real GMA and UMP frameworks itself. See [Architecture](/reference/architecture/) for the rationale. * It is consumable from KMP and Gradle projects only, so a pure-Swift iOS app cannot adopt it without a Kotlin Multiplatform shim. * It does not support ad networks other than AdMob directly. Mediation covers that. ## Is AdMob CMP a consent management platform? [Section titled “Is AdMob CMP a consent management platform?”](#is-admob-cmp-a-consent-management-platform) No. In ad-tech, CMP normally means Consent Management Platform — the vendor that brokers GDPR / TCF consent. This library’s name predates that association in the project’s own history and is retained for continuity with the published Maven coordinate `dev.avinya.ads:admob-cmp`. It integrates Google’s User Messaging Platform to gather GDPR / TCF consent, but it is not itself a CMP vendor. The consent flow is documented on [UMP consent](/privacy/consent/). ## Where to next? [Section titled “Where to next?”](#where-to-next) [Quickstart](/start/quickstart/)Render a test ad in five minutes. [Installation](/start/installation/)Gradle, version catalog, and the Gradle plugin. [Native ads](/formats/native/)The layout DSL and ad pooling. [Compatibility](/reference/compatibility/)Kotlin, Compose, minSdk and iOS versions.
---
# Quickstart
> The fastest Compose Multiplatform AdMob setup — render a test ad in five minutes using Google's sample ad units, with no AdMob account required.
## What will you have at the end? [Section titled “What will you have at the end?”](#what-will-you-have-at-the-end) Five minutes from now, an Android or iOS app that renders a real banner ad inside a Compose Multiplatform screen, using Google’s public sample ad units. No AdMob account, no ad unit of your own, no App Store / Play Store ceremony. The integration is exactly the integration you will ship — only the test ids will be replaced with your own before release. The five minutes assume you already have a working Kotlin Multiplatform project with a Compose Multiplatform screen in `commonMain`. If you do not, start from the [KMP project wizard](https://kmp.jetbrains.com/) in your IDE; the only deviation from the default is adding the dependency line in the next step. ## How do I add the dependency? [Section titled “How do I add the dependency?”](#how-do-i-add-the-dependency) The quickstart deliberately uses Google’s sample ad units so no AdMob account is needed. The full installation story — version catalogs, the Gradle plugin, Kotlin/Native test linking — is on [Installation](/start/installation/). shared/build.gradle.kts
```kotlin
kotlin {
sourceSets {
commonMain.dependencies {
implementation("dev.avinya.ads:admob-cmp:1.1.0")
}
}
}
```
The Android GMA Next-Gen SDK and UMP arrive as transitive Maven dependencies, so no extra Android dependency is needed. iOS deliberately does not include the binaries — the app links them through Swift Package Manager. That sounds like extra work for a greenfield project, and it is the first time only: see [iOS setup](/start/ios-setup/) for the two package URLs and the three `Info.plist` keys. The [architecture reference](/reference/architecture/) explains why the distribution works that way. ## How do I initialize AdMob in Compose Multiplatform? [Section titled “How do I initialize AdMob in Compose Multiplatform?”](#how-do-i-initialize-admob-in-compose-multiplatform) Initialize once at the root of your app, ideally as a side effect on first composition. The library gathers consent and starts Mobile Ads in one call:
```kotlin
@Composable
fun App() {
val adManager = rememberAdManager()
LaunchedEffect(Unit) {
adManager.gatherConsentAndInitialize(
AdConfig(
androidAppId = TestAdIds.ANDROID_APP_ID,
iosAppId = TestAdIds.IOS_APP_ID,
testMode = true,
)
)
}
val status by adManager.status.collectAsState()
if (status is AdManagerStatus.Ready) {
AdScreen()
}
}
```
Each piece is doing real work. `rememberAdManager()` returns a process-wide singleton — the same one `AdMob.manager(context)` returns on Android, so there is never a second manager in a process. `gatherConsentAndInitialize` runs the UMP consent flow, invokes any initialization hooks, then starts Mobile Ads. And `status` is the gate the rest of the UI is built on: `AdManagerStatus` is `Idle`, `Initializing`, `ConsentRequired`, `Ready`, `Disabled(reason)`, or `Failed(error, retryable)`. A practical note on the gate: gating the entire ad screen on `Ready` is fine for the first integration, but the production app usually wants to preload placements in the background and only block the *first* show. The common shape is to keep showing app content while `Initializing`, and to flip the global UI gate the first time a `show()` button is pressed. The [interstitial guide](/formats/interstitial/) covers the preload-and-show pattern in detail. Initialization order: UMP consent, then ATT, then initializeThe required call order on iOS — gather UMP consent, request App Tracking Transparency authorisation, then initialize the ads SDK — and what Android reports instead. Initialization order: UMP consent, then ATT, then initializeCLAUDE.md invariants #5, #11[Read this diagram in words](/reference/diagrams-in-words/#init-sequence) The order on iOS matters. UMP consent, then App Tracking Transparency, then the first ad request — that is the only sequence that preserves the IDFA for personalised ads. [App Tracking Transparency](/privacy/app-tracking-transparency/) explains why requesting ads before ATT resolves permanently forfeits the IDFA for those requests. Caution `AdDebugOptions.testMode` configures **UMP consent debugging** only. It does **not** make GMA serve test ads. Only registering the device in `GlobalRequestConfiguration.testDeviceIds`, or using a `TestAdIds` unit, does that. See [Test safety](/advanced/test-safety/). ## How do I show my first banner? [Section titled “How do I show my first banner?”](#how-do-i-show-my-first-banner)
```kotlin
val placement = remember {
AdPlacement(
id = "home_banner",
format = AdFormat.Banner,
androidAdUnitId = TestAdIds.ANDROID_BANNER,
iosAdUnitId = TestAdIds.IOS_BANNER,
strictTestMode = true,
)
}
BannerAdView(
placement = placement,
modifier = Modifier.fillMaxWidth(),
onEvent = { event ->
when (event) {
is AdEvent.Loaded -> println("banner loaded")
is AdEvent.LoadFailed -> println("banner failed: ${event.error}")
else -> Unit
}
},
)
```
`BannerAdView` measures its own container, supplies the width, loads, attaches, refreshes and disposes the platform banner view for you. Setting `strictTestMode = true` throws at construction if the placement points at a production ad unit, which is exactly what you want in a debug build — a test build can never request live ads. The `remember` around the placement matters because controllers are placement-keyed and cached for the manager’s lifetime, so ids must be static and finite. The `onEvent` callback exposes the full `AdEvent` sealed hierarchy: `Loaded`, `LoadFailed`, `Impression`, `Clicked`, `OpenedFullScreen`, `ClosedFullScreen` and `Paid`. Most apps only log in development. Production code usually hoists ad events up to a single analytics collector — see [Revenue and paid events](/advanced/revenue-events/) for the paid-event shape and the cross-platform gotchas. ## Why is nothing rendering yet? [Section titled “Why is nothing rendering yet?”](#why-is-nothing-rendering-yet) A short triage, each item the one thing that is usually wrong: * The manager is not `AdManagerStatus.Ready` yet. Gate the UI on `status` before placing any `BannerAdView` or calling `load()`. * Consent has not been granted, so loads fail with `AdErrorCode.CONSENT_REQUIRED`. Run `gatherConsentAndInitialize` and check `adManager.consent.canRequestAds`. * The Android manifest is missing `com.google.android.gms.ads.APPLICATION_ID`. GMA crashes at startup if it is missing. See [Android setup](/start/android-setup/). * The iOS app has not added the two Swift Package Manager packages. See [iOS setup](/start/ios-setup/). * The banner placement uses `BannerRefreshPolicy.Manual` and nothing called `refresh()`. Manual policy is no auto-load; you must call `adManager.banner(placement).refresh()` yourself. * The UMP consent form was dismissed without a choice, so `canRequestAds` is still `false`. The privacy-options form must be reopened and the user must accept before any request reaches the network. When none of those explain it, the full symptom table is on [Troubleshooting](/reference/troubleshooting/). ## What changes before you ship? [Section titled “What changes before you ship?”](#what-changes-before-you-ship) Five swaps. None of them changes the structure of the integration: 1. Replace both sample app ids and every sample ad-unit id with your own. 2. Set `testMode = false` on `AdConfig`. Leaving it on means the consent debug settings still apply. 3. Keep `strictTestMode = true` in debug builds only. 4. Complete the [Play Data safety](/privacy/play-data-safety/) declaration in the Play Console. 5. Add `NSUserTrackingUsageDescription` to the iOS `Info.plist`, or every iOS request will serve non-personalised ads at materially lower eCPM. See [App Tracking Transparency](/privacy/app-tracking-transparency/). The test ids are recognisable on sight: `TestAdIds.ANDROID_BANNER` is `ca-app-pub-3940256099942544/9211549742`, which is the same id Google uses in its own sample apps. The release-time checklist above is the simplest way to keep a debug build from ever being mistaken for a release build once you are past the quickstart. ## Where to next? [Section titled “Where to next?”](#where-to-next) [Installation](/start/installation/)Version catalogs, modules, and the Gradle plugin. [iOS setup](/start/ios-setup/)SPM packages, Info.plist, ATT, and doctorIos. [UMP consent](/privacy/consent/)Consent modes and the privacy options form. [Troubleshooting](/reference/troubleshooting/)Symptom to cause to fix.
---
# Installation
> Add the Kotlin Multiplatform AdMob library dev.avinya.ads:admob-cmp with a version catalog, plus the Gradle plugin that fixes iOS test linking.
## How do I add AdMob CMP to a Kotlin Multiplatform project? [Section titled “How do I add AdMob CMP to a Kotlin Multiplatform project?”](#how-do-i-add-admob-cmp-to-a-kotlin-multiplatform-project) The artifact is a single umbrella coordinate that brings both the engine and the Compose surface. The Android GMA Next-Gen SDK and UMP arrive as transitive Maven dependencies, so no extra Android dependency is needed. iOS deliberately links Google’s frameworks in the app, not in the artifact. shared/build.gradle.kts
```kotlin
kotlin {
sourceSets {
commonMain.dependencies {
implementation("dev.avinya.ads:admob-cmp:1.1.0")
}
}
}
```
That is the whole dependency. Behind the umbrella artifact sits `admob-cmp-core` (the engine, controllers, platform adapters) and `admob-cmp-compose` (the composable surface). Most consumers depend on the umbrella `admob-cmp` and never need to know the split. Apps that want the controller API without Compose Multiplatform can depend on `admob-cmp-core` directly. The repository pattern matters less for a quickstart than for a long-lived project. A single shared module calling the ads API works fine for a small app, but most production projects split `commonMain` across modules by feature. The rules do not change with the module count: the dependency lives once in whichever module calls the controllers, and the Gradle plugin is applied in every module that compiles a Kotlin/Native test binary. ## Should I use a version catalog? [Section titled “Should I use a version catalog?”](#should-i-use-a-version-catalog) Yes for any non-trivial build. The library coordinate and the Gradle plugin id must stay on the same version, and a single `version.ref` makes that impossible to get wrong: gradle/libs.versions.toml
```toml
[versions]
admob-cmp = "1.1.0"
[libraries]
admob-cmp = { module = "dev.avinya.ads:admob-cmp", version.ref = "admob-cmp" }
[plugins]
admob-cmp = { id = "dev.avinya.ads.admob-cmp", version.ref = "admob-cmp" }
```
shared/build.gradle.kts
```kotlin
commonMain.dependencies {
implementation(libs.admob.cmp)
}
```
When `VERSION_NAME` is bumped in `gradle.properties` for a release, the catalog entry moves in lockstep and every consuming module picks up the new version on the next Gradle sync. Without the catalog, the same bump becomes a search-and-replace across every `build.gradle.kts` in the project, and one of them is always missed. The plugin id lives in the same catalog as the library for the same reason: applying the plugin and depending on the library are two versions of the same statement, and the consumer should never be in a state where one of them is one version behind. ## Which modules do I put the dependency in? [Section titled “Which modules do I put the dependency in?”](#which-modules-do-i-put-the-dependency-in) The practical rule: put it in the shared module whose `commonMain` calls the ads API, once. Because controllers are placement-keyed and the manager is a process-wide singleton, adding it to several modules does not create several managers — but it does widen the set of modules whose Kotlin/Native test binaries need the Gradle plugin. Compose Multiplatform is required only if the composable surface (`BannerAdView`, `NativeAdView`, `rememberAdManager`) is used. The controller API — `AdManager`, the controllers, `AdPlacement` — has no Compose dependency. If a project only needs the controllers, depend on `admob-cmp-core` directly and skip `admob-cmp-compose`. A common arrangement is a `feature-ads` module that owns the `AdPlacement` list and the manager wiring, plus a `:shared` module that just consumes the controllers. Both still link to the same process-wide manager — there is no second instance — and only `feature-ads` needs the Gradle plugin if it is the only module with a Kotlin/Native test binary. ## Why do my iOS tests fail to link, and what fixes it? [Section titled “Why do my iOS tests fail to link, and what fixes it?”](#why-do-my-ios-tests-fail-to-link-and-what-fixes-it) This is the section that earns the page its inbound links, because the failure looks unrelated to ads. The app links Google’s frameworks through Swift Package Manager, but tests do not. `./gradlew :yourModule:iosSimulatorArm64Test` makes the Kotlin/Native compiler link a standalone executable with no Xcode, no `.xcodeproj` and no Swift Package Manager anywhere in the picture, so every symbol must resolve at that link — including the `GAD*` and `UMP*` classes these bindings reference. That is true **even if none of the tests touch ads.** The test binary contains the module’s whole main compilation, so any production code calling `rememberAdManager`, `NativeAdView` or the consent APIs drags those references in. A `FakeAdManager` does not help, because the requirement comes from the bindings being present in the link, not from anyone calling them. For faking ad *behaviour* in tests, the SDK ships `NoOpAdManager`. The fix: shared/build.gradle.kts
```kotlin
plugins {
id("dev.avinya.ads.admob-cmp") version "1.1.0"
}
```
settings.gradle.kts
```kotlin
pluginManagement {
repositories {
mavenCentral()
gradlePluginPortal()
}
}
```
Danger Adding another Swift Package Manager package will **not** fix a `:linkDebugTestIos…` failure. Only the Gradle plugin does. See [Troubleshooting](/reference/troubleshooting/) for the full symptom table. ## What does the Gradle plugin actually do? [Section titled “What does the Gradle plugin actually do?”](#what-does-the-gradle-plugin-actually-do) The plugin’s behaviour, exactly as implemented: * It registers `downloadGmaIos` and `downloadUmpIos`, which fetch the version-stamped GoogleMobileAds and UserMessagingPlatform XCFrameworks into `build/admob-cmp-ios-frameworks/` with a SHA-256 check. * It applies linker options **to test executables only** — `-framework GoogleMobileAds`, `-framework UserMessagingPlatform`, `-framework JavaScriptCore`, plus the Swift compatibility library directory. * It supports the `iosArm64` and `iosSimulatorArm64` targets. * It deliberately leaves the shipped app framework alone, because Kotlin/Native is supposed to leave those symbols undefined there for Xcode to bind via SPM. * It registers `doctorIos`, the diagnostic task. Off macOS, the plugin contributes no linker options, so Linux CI can still configure the build — it just cannot actually link a test executable. On macOS, the linker options are applied through Kotlin/Native’s `extraOpts`, which means the production app framework stays as a dynamic framework and Xcode still binds the SPM-resolved GMA at the final app link. That separation is the entire reason the published artifact is bindings-only on iOS. ## How do I verify the installation? [Section titled “How do I verify the installation?”](#how-do-i-verify-the-installation) Three tasks, in order:
```bash
./gradlew :admob-cmp-core:doctorIos # report-only; prints per-check status
./gradlew :shared:iosSimulatorArm64Test # must link and run
./gradlew :shared:testAndroidHostTest # JVM-side check
```
`doctorIos` never fails the build — it is diagnostic only. It checks the framework download cache, whether the Xcode project references the SPM products, and whether `Info.plist` declares `GADApplicationIdentifier` and `SKAdNetworkItems`. The test tasks run the common-test suite on each platform’s runner and confirm the install is real, not just a Gradle sync. The [architecture reference](/reference/architecture/) describes what the test suite exercises. If a verification step fails, the first thing to check is the order: `doctorIos` reports missing pieces with file paths, and a test link failure prints the unresolved symbol name. The symbol name alone usually points at either the GMA package, the UMP package, or JavaScriptCore — and [Troubleshooting](/reference/troubleshooting/) has a table that maps each to the right fix. ## Where to next? [Section titled “Where to next?”](#where-to-next) [Android setup](/start/android-setup/)Manifest, AD\_ID, and Play Data safety. [iOS setup](/start/ios-setup/)SPM packages, Info.plist, ATT, and doctorIos. [Compatibility](/reference/compatibility/)Kotlin, Compose, minSdk and iOS versions. [Troubleshooting](/reference/troubleshooting/)Undefined GAD symbols and other link failures.
---
# Caching, retry and timeouts
> AdMob ad caching and retry on Kotlin Multiplatform — AdCachePolicy, AdExpirationPolicy, AdRetryPolicy and AdTimeoutPolicy, and how to size each one.
## Which knobs does AdMob CMP actually expose? [Section titled “Which knobs does AdMob CMP actually expose?”](#which-knobs-does-admob-cmp-actually-expose) Everything on this page is per-placement configuration on `AdPlacement`, so different placements can behave differently in the same app. The four policies and what each one bounds: The reason these are policies, not flags, is that the library must enforce a few invariants: `maxSize >= 1`, every TTL finite and positive, the backoff multiplier >= 1. A flag is a single value with no internal consistency; a policy is a small object whose constructor can validate the whole thing at one moment. Constructors throw, defaults are sensible, and a misconfigured placement fails at the construction site rather than at the first `load()`. | Policy | Bounds | Default | | -------------------- | ----------------------------------------------------------- | ----------------------------------------------------- | | `AdCachePolicy` | How many ads are held, and whether one reloads after a show | `maxSize = 1`, `reloadAfterShow = false` | | `AdExpirationPolicy` | How long a cached ad stays usable | 1h full-screen, 4h app-open, 1h native | | `AdRetryPolicy` | How a failed load is retried | 2 attempts, 2s initial delay, 30s cap, 2.0 multiplier | | `AdTimeoutPolicy` | How long a load or a presentation hand-off may take | 30s load, 10s presentation hand-off | ## How does the ad cache work? [Section titled “How does the ad cache work?”](#how-does-the-ad-cache-work)
```kotlin
AdPlacement(
id = "interstitial_level_end",
format = AdFormat.Interstitial,
adUnitIds = AdUnitIds(android = "…", ios = "…"),
cachePolicy = AdCachePolicy(
maxSize = 3,
reloadAfterShow = true,
expirationPolicy = AdExpirationPolicy(
fullScreenTtl = 1.hours,
appOpenTtl = 4.hours,
nativeTtl = 1.hours,
),
),
)
```
Full-screen ad lifecycle: load, cache, show, reloadCLAUDE.md invariants #1, #2, #9[Read this diagram in words](/reference/diagrams-in-words/#full-screen-lifecycle) The mechanics the diagram shows: the cache is a per-slot FIFO deque up to `maxSize`. `load()` fills it sequentially and a partial fill still reports `Loaded`. `show()` consumes the oldest entry first. Eviction happens on every touch. And `clear()` bumps a generation counter so any load or scheduled reload still in flight for the old generation is invalidated instead of repopulating a cache the caller just emptied. The constraint checks the constructors enforce, because they throw rather than clamp: `AdCachePolicy.maxSize` must be at least 1, and every duration in `AdExpirationPolicy` must be finite and positive. The cache also evicts on read, so a `load()` that finds expired entries drops them before reporting `Loaded` with the remaining valid count. A practical note on `load()` returning a partial fill. A partial fill happens when the platform SDK returns fewer ads than requested before the call resolves — usually because the network returned a smaller batch than the publisher asked for. The library still reports `Loaded` with whatever is in the cache, because the caller’s intent was “fill the cache”, and the cache is partially full. A call that returns zero ads is `Failed` with a no-fill error, not `Loaded` with an empty cache. A native-pool cross-reference in one sentence: for native placements the same `maxSize` budgets **available + in-use** ads together — see the [native ads guide](/formats/native/). ## When and how does a failed load retry? [Section titled “When and how does a failed load retry?”](#when-and-how-does-a-failed-load-retry)
```kotlin
AdPlacement(
id = "interstitial_level_end",
format = AdFormat.Interstitial,
adUnitIds = AdUnitIds(android = "…", ios = "…"),
retryPolicy = AdRetryPolicy(
maxAttempts = 2, // total attempts, including the first
initialDelay = 2.seconds,
maxDelay = 30.seconds,
backoffMultiplier = 2.0,
),
)
```
Retry timeline: capped exponential backoffCLAUDE.md invariants #9[Read this diagram in words](/reference/diagrams-in-words/#retry-timeline) `maxAttempts` counts the **initial attempt plus retries**, so the default of 2 means one retry and `maxAttempts = 1` means none. Delay grows by `backoffMultiplier` after each attempt and is capped at `maxDelay`. The constructor requires `maxAttempts >= 1`, finite positive delays, `maxDelay >= initialDelay`, and `backoffMultiplier >= 1.0`. The classification, which is the part that surprises people: | Failure | Retried? | | ----------------------------------------------------------------------------------- | --------------------------------------------------- | | Network, timeout, internal error — GMA code `0`/`2` on Android, `2`/`5`/`11` on iOS | Yes, per `AdRetryPolicy` | | No fill — GMA code `3` on Android, `1` on iOS | No. This is normal; retry later at a natural moment | | `AdErrorCode.CONSENT_REQUIRED` | No. Resolve consent first | | `AdErrorCode.SDK_NOT_READY` | No. Wait for `AdManagerStatus.Ready` | A “no” here means the library does not schedule a retry. The publisher can still call `load()` again, of course — the policy is about automatic retries, not caller-initiated ones. A note on `maxAttempts = 1`. Setting `maxAttempts` to 1 disables the retry layer entirely — the call returns whatever the platform SDK returns, including transient failures. The right shape for that policy is a caller that has its own retry mechanism (a button-driven retry, a backoff at a higher level). The wrong shape is “I want to load faster” — `maxAttempts = 1` is not faster than `maxAttempts = 2` unless the network is consistently failing on the first try, in which case the underlying problem needs investigation, not a policy tweak. ## What stops a load or a presentation hanging forever? [Section titled “What stops a load or a presentation hanging forever?”](#what-stops-a-load-or-a-presentation-hanging-forever)
```kotlin
AdPlacement(
id = "interstitial_level_end",
format = AdFormat.Interstitial,
adUnitIds = AdUnitIds(android = "…", ios = "…"),
timeoutPolicy = AdTimeoutPolicy(
loadTimeout = 30.seconds,
presentationHandOffTimeout = 10.seconds,
),
)
```
Why this policy exists at all, because it is the least obvious of the four: GMA is a listener SDK, so if a terminal callback never arrives an unbounded `load()` or `show()` suspends forever. For `show()` that is worse than a hang — the presentation token is process-wide, so one wedged presentation blocks every full-screen ad in the app until the process dies. The precise semantics: `loadTimeout` is the maximum time for one load **including retry backoff**; on expiry the state becomes `AdLoadState.Failed` and a late ad is rejected and destroyed by the generation check, exactly as after a `clear()`. `presentationHandOffTimeout` bounds the time between committing an ad to presentation and the SDK reporting anything — it does **not** bound how long a user watches an ad. A note on why `presentationHandOffTimeout` exists. The platform SDKs present ads through a delegate-based pattern: the library hands the ad to the SDK and the SDK calls back when the presentation is complete. If the SDK never calls back — a real failure mode on iOS when the app is backgrounded mid-hand-off — the presentation token never releases, and the process-wide ad presence counter is permanently off-by-one. The timeout is the safety valve that releases the token after a bounded wait even if the SDK never reports. ## How should I size all of this? [Section titled “How should I size all of this?”](#how-should-i-size-all-of-this) Concrete recommendations rather than platitudes: * Interstitials at a natural break: `maxSize = 1`, `reloadAfterShow = true`. One ad, always warm. * Rewarded ads behind a user-initiated button: `maxSize = 1` and preload when the screen opens, so the button is never dead. * Bursty full-screen moments, such as end-of-level in a game: `maxSize = 2` or `3`, but only if all of them can plausibly be shown within the one-hour TTL. * Native feeds: size to the number of ad slots that can be on screen at once, plus one. * Raise `loadTimeout` only for genuinely slow networks; lowering it below about 10 seconds mostly converts fill into failures. The [native ads guide](/formats/native/) has the `maxSize`-for-native sizing in detail, because the same value budgets available plus in-use and the calculation is different. A note on “always warm”. The recommendation `reloadAfterShow = true` makes the cache refill in the background after each show, so the next break has an ad ready. The cost is the same as a regular `load()` — the publisher pays the network round-trip for a request that may or may not be shown. For a placement that fires ten times a day, that is ten extra requests; for a placement that fires twice a day, that is two. The math favours `reloadAfterShow = true` whenever the placement is in a hot path, and `false` when the placement is shown at a moment the user is unlikely to reach twice. ## Where to next? [Section titled “Where to next?”](#where-to-next) [Interstitial ads](/formats/interstitial/)The shared full-screen controller contract. [Native ads](/formats/native/)How maxSize budgets a pool. [Architecture](/reference/architecture/)Generation counters and the state machine. [Troubleshooting](/reference/troubleshooting/)No fill, timeouts, and error codes.
---
# Mediation
> AdMob mediation on Kotlin Multiplatform — adding adapters per platform, setting network privacy flags with hooks, and verifying fill.
## Why does AdMob CMP ship zero mediation adapters? [Section titled “Why does AdMob CMP ship zero mediation adapters?”](#why-does-admob-cmp-ship-zero-mediation-adapters) Adapters are platform binaries that must match the GMA SDK version and the publisher’s network contracts. Bundling them inside a cross-platform wrapper would pin versions for everyone and break the moment two artifacts carry the same Objective-C class. The property that makes this work on iOS specifically: because the library links nothing itself and ships bindings only, adapters resolve against the single copy of GMA in the app, so there is no duplicate-class problem. The full rationale is on [Architecture](/reference/architecture/). A consequence worth being explicit about: the same is *not* true on Android if an adapter is published as part of the Android module that also depends on `admob-cmp-core`. The GMA Android SDK uses a different mechanism — adapters register against a single singleton at runtime — so the duplicate-class problem does not arise, and bundling an adapter at the consumer side is the right shape. The [architecture reference](/reference/architecture/) has the full platform-by-platform model. ## How do I add an adapter on each platform? [Section titled “How do I add an adapter on each platform?”](#how-do-i-add-an-adapter-on-each-platform)
```kotlin
// Android — app or shared Android module
dependencies {
implementation("com.google.ads.mediation:facebook:…")
}
```
On iOS, add the adapter’s Swift Package or CocoaPod to the Xcode project, alongside the `GoogleMobileAds` package added during [iOS setup](/start/ios-setup/). A few operational notes. Waterfalls and bidding are configured in the AdMob UI as normal. Mediated fill flows through every existing API with no special casing. And the adapter version must be compatible with the GMA version in the app on each platform independently, which is the main source of “it works on Android” reports. The [Play Data safety guide](/privacy/play-data-safety/) covers the disclosure side of enabling mediation. The “works on one platform” failure mode has a specific shape. An adapter version that is compatible with GMA 13.x on iOS may not be compatible with GMA Next-Gen 1.3.0 on Android, and the incompatibility surfaces as a missing-class crash at adapter init time, not as a graceful “this adapter is not loaded”. The fix is to check the adapter’s compatibility matrix per platform before adding the dependency, and to keep the GMA version consistent across the project’s two platforms even though the library does not enforce that. ## How do I set a network’s privacy flags before initialization? [Section titled “How do I set a network’s privacy flags before initialization?”](#how-do-i-set-a-networks-privacy-flags-before-initialization) State the problem first: many networks require privacy flags to be set *before* SDK initialization, and there is no cross-platform place to do that unless the ad library provides one. `AdInitializationHook` is that place.
```kotlin
val metaConsentHook = object : AdInitializationHook {
override suspend fun onPhase(phase: AdInitializationPhase, config: AdConfig) {
when (phase) {
AdInitializationPhase.BeforeConsentRequest -> Unit
AdInitializationPhase.BeforeMobileAdsInitialize -> {
// Call into the adapter's platform API behind an expect/actual:
// Android: AdSettings.setDataProcessingOptions(arrayOf("LDU"))
// iOS: FBAdSettings.setDataProcessingOptions(["LDU"])
applyNetworkPrivacyFlags()
}
AdInitializationPhase.AfterMobileAdsInitialize -> Unit
}
}
}
AdConfig(
androidAppId = "ca-app-pub-…",
iosAppId = "ca-app-pub-…",
initializationHooks = listOf(metaConsentHook),
)
```
Three properties precisely. The hook body is the caller’s code, so adapter-specific calls belong in `androidMain`/`iosMain` behind an `expect`/`actual` and are invoked from the hook. The three phases are `BeforeConsentRequest`, `BeforeMobileAdsInitialize` and `AfterMobileAdsInitialize`. And hooks run **exactly once per real native initialization attempt**, inside a detached scope, so cancelling one `initialize()` caller can never skip or duplicate a hook. The `expect`/`actual` pattern is the right shape for adapter privacy calls because the network SDKs are themselves platform-specific. The common side declares a suspending `applyNetworkPrivacyFlags()` function; `androidMain` calls `AdSettings.setDataProcessingOptions(arrayOf("LDU"))` (for Meta’s Limited Data Use flag) or the equivalent for the chosen network; `iosMain` does the same against the iOS class. The hook body stays the same on both platforms. Caution The Google Mobile Ads singleton initialises at most once per process. A second `initialize` call with the *same* effective config replays the first result; a call with a *different* app id or request configuration is **ignored with a logged warning**, not re-applied. Decide the settings before the first `initialize` ever runs. ## How do I verify an adapter is actually serving? [Section titled “How do I verify an adapter is actually serving?”](#how-do-i-verify-an-adapter-is-actually-serving)
```kotlin
// After adManager.status is AdManagerStatus.Ready
adManager.diagnostics.adapterStatuses().forEach { status ->
println("${status.adapterName}: initialized=${status.initialized} latencyMs=${status.latencyMillis}")
}
scope.launch { adManager.diagnostics.openAdInspector() }
```
`AdapterInitializationStatus` carries `adapterName`, `initialized`, `latencyMillis` and `description`. `openAdInspector()` launches Google’s Ad Inspector overlay and works on test devices, and is the authoritative way to confirm an adapter serves. And `adManager.diagnostics.sdkVersion()` reports the underlying GMA version, which is the first thing to check against an adapter’s compatibility matrix. A practical note on `latencyMillis`. It is the time the adapter took to initialise, not the time it took to win an auction. A high value here means the adapter is taking long to register itself with GMA, which is sometimes an indication of a misconfigured network. A low value does not mean the adapter is winning impressions; it only means it is ready when GMA needs it. `openAdInspector()` is the right tool for “I want to see everything” — it overlays Google’s own inspector on the running app, with the live mediation chain for the current screen, the device’s ad requests in flight, and a way to fire test requests against specific ad units. It works only on test devices and never in release builds, because Google gates it behind a debug-only call path. The [test safety guide](/advanced/test-safety/) covers which devices count as test devices. ## How do I see which network won a given impression? [Section titled “How do I see which network won a given impression?”](#how-do-i-see-which-network-won-a-given-impression)
```kotlin
LaunchedEffect(Unit) {
adManager.events.collect { event ->
if (event is AdEvent.Loaded) {
val winner = event.responseInfo?.loadedAdNetworkResponseInfo
println("won by ${winner?.adSourceName} (${winner?.adapterClassName}) in ${winner?.latencyMillis} ms")
}
}
}
```
`AdResponseInfo.loadedAdNetworkResponseInfo` identifies the winning adapter while `adNetworkResponseInfos` is the whole mediation chain including the networks that failed, each with its own `error`. The failure entries in the chain are the fastest way to diagnose a mediation waterfall that is not filling, and they appear in `AdEvent.LoadFailed` as well as on successful loads with partial fills. `AdEvent.Paid` carries the mediated revenue for an impression, which is covered in detail on [Revenue and paid events](/advanced/revenue-events/). The chain visible on a paid event is the same chain visible on a load event — the mediation network that won is the same network that paid. A common debugging pattern: log the full `adNetworkResponseInfos` list on every `Loaded` event, and look for the `error` fields. A network that consistently appears in the chain with a non-null `error` is one that is being asked to bid and failing, which is different from a network that is not being asked. The former is a configuration issue; the latter is a waterfall issue. Both look the same to a publisher who only watches `loadedAdNetworkResponseInfo`. ## Where to next? [Section titled “Where to next?”](#where-to-next) [Revenue and paid events](/advanced/revenue-events/)AdValue and the mediation chain. [Play Data safety](/privacy/play-data-safety/)Adapters add data disclosures. [Architecture](/reference/architecture/)Why iOS ships bindings only. [iOS setup](/start/ios-setup/)Where adapter packages go in Xcode.
---
# Revenue and paid events
> Track the AdMob paid event on Kotlin Multiplatform — AdValue micros, precision, response info, and sending impression-level revenue to your analytics.
## How do I observe ad events at all? [Section titled “How do I observe ad events at all?”](#how-do-i-observe-ad-events-at-all) There are two collection points. `adManager.events` is a global `SharedFlow` covering every placement, while each controller and pool exposes its own `events` for a single placement. Every `AdEvent` carries a `placementId`, which is what makes the global stream usable.
```kotlin
LaunchedEffect(Unit) {
adManager.events.collect { event ->
when (event) {
is AdEvent.Loaded -> println("loaded: ${event.placementId}")
is AdEvent.LoadFailed -> println("failed: ${event.error}")
is AdEvent.Impression -> println("impression: ${event.placementId}")
is AdEvent.Clicked -> println("click: ${event.placementId}")
is AdEvent.Paid -> println("revenue: ${event.paidEvent.value.valueMicros}")
is AdEvent.RewardEarned -> println("reward: ${event.reward.type}")
else -> Unit
}
}
}
```
The full sealed set, once so readers stop guessing: `Loaded`, `LoadFailed`, `ShowFailed`, `Impression`, `Clicked`, `OpenedFullScreen`, `ClosedFullScreen`, `RewardEarned`, `Paid`, and the iOS-only `VideoStarted`, `VideoPlayed`, `VideoPaused`, `VideoEnded`, `VideoMuted`. Note that `Impression`, `Clicked` and `Paid` also carry an optional `adInstanceId`, which is what lets a paid event be joined to the impression it belongs to. A common pattern that does not work: collecting from a controller’s `events` flow inside the same composable that holds the controller. The collector’s `LaunchedEffect` outlives the controller on a screen exit, and the `events: SharedFlow` is tied to the controller’s lifetime, so the collector silently stops receiving. The right shape is to collect from `adManager.events` (the global stream) at the root, and let the collector live for the lifetime of the manager. The per-controller `events` flow is useful for tests, where the controller’s lifetime is the test’s lifetime. ## How do I read impression-level revenue? [Section titled “How do I read impression-level revenue?”](#how-do-i-read-impression-level-revenue)
```kotlin
LaunchedEffect(Unit) {
adManager.events.filterIsInstance().collect { event ->
val value = event.paidEvent.value
analytics.logAdRevenue(
placementId = event.placementId,
valueMicros = value.valueMicros,
currency = value.currencyCode,
precision = value.precision.name,
adSource = event.paidEvent.responseInfo?.loadedAdNetworkResponseInfo?.adSourceName,
)
}
}
```
The data shapes, stated exactly: * `PaidEvent` has `placementId`, `value: AdValue`, and `responseInfo: AdResponseInfo?`. * `AdValue` has `valueMicros: Long`, `currencyCode: String`, and `precision: AdValuePrecision`. * `AdValuePrecision` is `Unknown`, `Estimated`, `PublisherProvided` or `Precise`. The unit warning explicitly, because it is the most common analytics bug in this area: `valueMicros` is **millionths of a currency unit**. Dividing by 1,000,000 gives the value in `currencyCode`. Do not sum micros across currencies. A note on precision: `Estimated` values are modelled, not billed. Aggregating them is legitimate for pacing and LTV models, but they must never be presented to a user or a finance team as revenue. `PublisherProvided` is the value the publisher passed in via `AdValue(currencyCode, valueMicros, precision = PublisherProvided)` — a legitimate path for non-AdMob networks, and a value the library forwards untouched. `Precise` is the floor of trustworthy revenue; anything below is a model. A note on currency conversion. Each `AdValue` carries a `currencyCode` — a three-letter ISO 4217 code. Two paid events from different networks may have different `currencyCode`s; the right aggregation is to convert both to a single reporting currency at the moment of logging, not to mix micros across currencies. Mixing USD micros with EUR micros is the most common cause of “the eCPM went up by 30% after we changed networks” bugs. ## What does ResponseInfo tell me? [Section titled “What does ResponseInfo tell me?”](#what-does-responseinfo-tell-me)
```kotlin
val info: AdResponseInfo? = event.paidEvent.responseInfo
println(info?.responseId) // unique response identifier
println(info?.adapterClassName) // adapter that loaded it
println(info?.loadedAdNetworkResponseInfo?.adSourceName) // winning network
info?.adNetworkResponseInfos?.forEach { network ->
println("${network.adSourceName}: ${network.latencyMillis} ms, error=${network.error?.message}")
}
```
`AdResponseInfo` carries `responseId`, `adapterClassName`, `extras`, `loadedAdNetworkResponseInfo` and `adNetworkResponseInfos`. Each `AdNetworkResponseInfo` carries `adapterClassName`, `latencyMillis`, `error`, `adSourceName`, `adSourceId`, `adSourceInstanceName` and `adSourceInstanceId`. The failure entries in the chain are the fastest way to diagnose a mediation waterfall that is not filling — see the [mediation guide](/advanced/mediation/) for the chain shape and the failure interpretation. A note on `extras`: it is a `Map` of adapter-specific metadata. Some networks use it to attach internal trace ids; Google’s own GMA uses it for the response’s correlation id under the key `ResponseInfo`. Treat it as opaque metadata, but never log the full map to production analytics — some adapters put personally identifying information in it, and that data is yours, not Google’s. A common use of `extras` is to forward the winning network’s internal id into the app’s analytics pipeline. The [mediation guide](/advanced/mediation/) has the matching configuration step. A `Map` of unknown shape should be flattened into a typed structure before forwarding — analytics systems that accept free-form fields tend to grow untyped schemas that are hard to evolve. The recommended shape for an in-house analytics target is a schema migration per network: a v1 entry for AdMob, a v2 entry for Meta, a v3 entry for the next network, and the analytics upstream keeps the historical records. Free-form forwarding is faster to ship and slower to retire. ## How should I send this to analytics? [Section titled “How should I send this to analytics?”](#how-should-i-send-this-to-analytics) Three short points, drawn from production patterns: * Collect in one place rather than per-composable so events are not double-counted across recompositions. The natural shape is a single `LaunchedEffect(Unit)` that subscribes to `adManager.events` for the lifetime of the root composition. * Key on `placementId` plus `adInstanceId` when correlating impressions and revenue. The same `adInstanceId` appears on the `Impression` event and on the `Paid` event for the same ad, which is what makes the join exact. * These events are observation — they are not the mechanism for granting rewards, which is covered on [Rewarded ads](/formats/rewarded/). The double-grant warning there applies here in a milder form: do not pay out from both a paid event and a callback. A practical note on `Loaded` versus `Paid` for revenue attribution. `Loaded` fires when the platform SDK reports a successful load; `Paid` fires when the user has actually seen the ad. The two are not the same moment, and the gap between them can be many seconds for video. Analytics that join “loads” to “revenue” without a window will over-count; the right join key is `adInstanceId`, and the right window is the lifetime of the ad. A second practical note: `Paid` events are delivered to the collector in order, but `Paid` for one ad can race with `Impression` for the same ad. The collector’s coroutine context determines which is observed first, and “first” is not necessarily “emitted first” — the platform SDKs are free to fire callbacks on background threads, and the library serialises them on `Dispatchers.Main.immediate`. The ordering guarantee is that callbacks for the same ad arrive in causal order (Loaded before Impression before Paid); cross-ad ordering is not guaranteed. ## Where to next? [Section titled “Where to next?”](#where-to-next) [Mediation](/advanced/mediation/)Reading the waterfall behind a paid event. [Rewarded ads](/formats/rewarded/)Why events are not the grant point. [Architecture](/reference/architecture/)How events flow from the platform SDKs.
---
# Test safety
> AdMob test ads on Kotlin Multiplatform — why testMode does not serve test ads, what strictTestMode guards, and how to keep live ads out of debug builds.
## Why are there two different test flags? [Section titled “Why are there two different test flags?”](#why-are-there-two-different-test-flags) The confusion this page exists to remove, stated as an `