Skip to content

Architecture

Module map: one shared core, two thin platform adaptersHow admob-cmp, admob-cmp-core and admob-cmp-compose relate, and which code lives in commonMain, androidMain and iosMain.dev.avinya.ads:admob-cmpumbrella — api(core) + api(compose)admob-cmp-coreno Compose dependencyadmob-cmp-composeCompose UI + adLayout DSLsource sets — both library modulescommonMainshared, platform-freecore · dev.avinya.adsAdManager, controllers, AdPlacement,AdConfig, AdEvent, AdError, consentcore · …ads.internalFullScreenSlotCore, BannerCore,NativePoolCore, AdRetrycore · …appopen / …nativeadcompose · …ads.uiBannerAdView, NativeAdViewcompose · …nativead.layoutadLayout {} DSL, validator, templatesandroidMainthin adapterAndroidGoogleAdManagerslots / pool / banner over theGMA Next-Gen SDK (API 26+)AdMob.manager(context)ProcessLifecycleOwner foregroundsignalbatch-assembly locking staysplatform-side (invariant 3)implements loadAd · presentAd ·destroyAd · canPresentiosMainthin adapterIosGoogleAdManagerslots / pool / banner / consent overcinterop bindings (GMA 13.x, iOS 15+)UIKit native-ad rendererNSNotificationCenter foreground signalstrong Kotlin refs hold the weakObjC delegates (invariant 4)activeLoad / activeLoads registriesstay platform-side (invariant 7)Platform classes implement only loadAd / presentAd / destroyAd / canPresent, plus the BannerPlatform and NativePoolPlatform interfaces.Load, show, cache, retry and consent gating all live in the shared core — CLAUDE.md invariants #1 and #7.
Module map: one shared core, two thin platform adaptersCLAUDE.md invariants #1, #7Read this diagram in words
PackageContents
dev.avinya.adsPublic API: AdManager, the controllers, AdPlacement, AdConfig, events, errors, consent, diagnostics
dev.avinya.ads.internalFullScreenSlotCore — the shared load/show/cache state machine — and AdRetry, capped exponential backoff
dev.avinya.ads.appopenAppOpenAdCoordinator plus the expected foreground signal
dev.avinya.ads.nativeadThe NativeAdPool contract, options, and media info
dev.avinya.ads.nativead.layoutThe adLayout DSL, AdLayoutValidator, and AdTemplates
dev.avinya.ads.uiThe expected composables BannerAdView and NativeAdView

The platform side in prose: androidMain provides AndroidGoogleAdManager with slots, pool and banner over the GMA Next-Gen SDK, the AdMob.manager(context) singleton, and a ProcessLifecycleOwner foreground signal. iosMain provides IosGoogleAdManager over cinterop bindings, a Kotlin UIKit native-ad renderer, and an NSNotificationCenter foreground signal.

The published module split for readers who look at Maven: dev.avinya.ads:admob-cmp is the umbrella artifact; admob-cmp-core holds the engine and admob-cmp-compose the Compose surface, so the controller API can be used without Compose.

The split’s design rationale: a server-side app, a CL tool, or a test harness that wants the controllers but not Compose Multiplatform depends on admob-cmp-core directly and skips admob-cmp-compose entirely. The umbrella coordinate exists for the common case, not to force Compose on consumers who do not want it.

What lives in common code, and what is platform-specific?

Section titled “What lives in common code, and what is platform-specific?”

The invariant that gives the library its shape: one state machine, two thin platform adapters. Every full-screen slot extends FullScreenSlotCore, which owns the load mutex, the TTL’d FIFO cache bounded by AdCachePolicy.maxSize, retry, consent gating, event emission and reloadAfterShow. Platform classes implement only loadAd, presentAd, destroyAd and canPresent.

Why this matters to a consumer rather than only to a maintainer: it is the reason interstitial, rewarded, rewarded interstitial and app-open behave identically across platforms, and the reason a bug fixed in caching is fixed everywhere at once. The same pattern applies to native ads through the pool, and to banners through the headless-controller path. A single cache invalidation bug, once found, is fixed in common code and ships to all formats.

The pattern is also the reason format-specific behaviour stays format-specific. A platform’s loadAd is free to call placementId differently from a banner’s, because the shared core does not know about per-format custom targeting. The shared core owns lifecycle, the platform adapter owns the SDK call, and the call site owns the format.

Why does iOS ship bindings instead of binaries?

Section titled “Why does iOS ship bindings instead of binaries?”

The iOS implementation compiles against the official GMA and UMP XCFrameworks via Kotlin/Native cinterop. The dev.avinya.ads.admob-cmp Gradle plugin downloads the zips into build/admob-cmp-ios-frameworks/ — a version-stamped cache — purely for headers. The published klib contains bindings, never Google’s binaries. The consuming app links GMA and UMP itself via Swift Package Manager.

Then the alternatives and why each was rejected, stated as engineering trade-offs:

  • A CocoaPods plugin — the ecosystem and this repository are SPM-based.
  • Embedding binaries with staticLibraries — breaks mediation adapters with duplicate Objective-C classes, pins the GMA version for every consumer, and raises redistribution-licence questions. Evaluated and rejected.

The accepted costs, stated without spin: consumers must add two SPM packages, and the bound GMA major version must match the SPM-resolved one. The iOS setup guide has the executable steps; the roadmap explains where this distribution model goes next.

A note on staticLibraries specifically. The Kotlin/Native cinterop system supports embedding the linked binary into the consumer’s framework via staticLibraries in the .def file. The library deliberately does not use this. The Android counterpart would be implementation(files("…/GoogleMobileAds.aar")) in the consumer’s build. Both shapes break mediation because the embedded GMA is a different copy from the adapter’s GMA, and the JVM and Objective-C runtimes resolve the “GMA” class non-deterministically when two copies exist in the same process.

How does the full-screen state machine work?

Section titled “How does the full-screen state machine work?”

The cache carries a generation counter, bumped by clear(). A load or scheduled reload started before a clear() checks its required generation before publishing results, so it can never repopulate a cache the caller just asked to be emptied. Presentation ownership is a one-shot handle: the core owns it until the platform slot hands it off to the SDK’s callbacks immediately before the actual show call; from then on only the SDK’s terminal callback, or a cancellation that raced in before hand-off, may close it. Two consequences observable from the public API: show() is not reentrant per controller — a second call while one presentation is active returns AdShowResult.NotReady rather than queueing — and cancelling a caller never decrements the process-wide presence signal once hand-off has happened, so AppOpenAdCoordinator never sees “not presenting” while an ad the SDK still owns is on screen.

All GMA and UMP calls are wrapped in Dispatchers.Main.immediate internally, so every public API is main-safe and callable from any dispatcher. Registries are lock-protected. On iOS, delegate objects are strongly retained by their owners for exactly the Objective-C delegate’s lifetime, because Objective-C delegates are weak references. Without that retention, a delegate set on a transient object would be deallocated before the platform SDK fires its terminal callback, and the ad would never be destroyed.

The Main.immediate choice, not Main: when a call originates on the main thread, Main.immediate runs the work without posting to the message queue, so the call resolves synchronously on the caller’s stack. When a call originates on another dispatcher, Main.immediate posts as Main would. The behaviour is transparent to the caller but matters for latency: a load() called from a UI thread on an already-Ready manager returns the cached state synchronously, which is what StateFlow.collectAsState depends on.

A related note on coroutine cancellation. Every public API is cancellable through its coroutine context. Cancelling a load() cancels the network request, the generation check, and the result-publication step. Cancelling a show() cancels the suspension — but the presentation itself, once handed off to the platform SDK, runs to completion regardless of caller cancellation. That asymmetry is deliberate: a half-presented ad is worse than a fully-presented ad the caller has stopped caring about.

platform SDK callback → slot / pool / controller
→ controller.events (per-placement SharedFlow)
→ AdManager.events (global SharedFlow)

The sealed AdEvent set is Loaded, LoadFailed, ShowFailed, Impression, Clicked, OpenedFullScreen, ClosedFullScreen, RewardEarned, Paid, and the iOS-only Video* family — see Revenue and paid events for the per-event shapes and the analytics patterns.

A note on SharedFlow versus Flow. The two event surfaces are SharedFlow<AdEvent> rather than Flow<AdEvent> so multiple collectors can attach to the same controller without each collector driving its own callback chain. The platform SDK fires a callback once; the controller re-emits it to every collector. For a single-collector pattern, the behaviour is indistinguishable from a Flow; for a multi-collector pattern — analytics + UI state + a test harness — SharedFlow is what makes the cost linear in the number of events rather than linear in the number of collectors.

DecisionRationale
Suspend functions and Flow instead of listener callbacksOne paradigm. show() suspending until dismissal collapses five callback interfaces into one call.
Placement-keyed, long-lived controllersThe caching, retry and lifecycle layer every app otherwise rebuilds badly.
Consent integrated into initializationRequesting ads before consent becomes impossible by construction.
Bindings-only iOS distributionMediation safety and version freedom.
Caching in the slot layer, not the SDK preload APIsThose APIs were beta and asymmetric across platforms at design time.
No bundled mediation adaptersAdapters are platform binaries tied to your GMA version and your contracts.
explicitApi() plus a committed ABI dumpThe public surface cannot drift silently.

The ABI dump is committed at admob-cmp-core/api/admob-cmp-core.klib.api and the matching file for the Compose surface, and checkKotlinAbi runs against it in CI. The contributing guide has the workflow when an intentional public change is part of a release.