App-open ads
What are app-open ads and when do they show?
Section titled “What are app-open ads and when do they show?”App-open ads show while the app is loading, typically when a user returns to it from the background. They are a full-screen format with its own TTL (four hours, not the one-hour default of other full-screen formats) and its own lifecycle plumbing because the trigger is process state, not user action. The format is unusually easy to get wrong because it depends on the app’s lifecycle plumbing working correctly, and the consequence of getting it wrong is an ad showing at the wrong moment — over a consent form, during a purchase, or when the user did not actually return.
AppOpenAdCoordinator is the library’s answer to that problem. It
implements the entire recommended lifecycle: preload, foreground
detection, minimum-background gating, cooldown, and reload after
consumption. Hand-rolling the same behaviour is the most common
source of app-open ad bugs, and the coordinator exists to make
the hand-rolled version unnecessary.
A note on naming: this is the only controller in the library with a coordinator layer above it. The interstitial, rewarded, rewarded interstitial, and native controllers are all single-instance — their controllers carry their own state. The app-open format is different because its trigger is process lifecycle, not user action, and the state that drives the trigger lives outside the controller. The coordinator is the bridge between the platform lifecycle APIs and the controller API.
How do I wire up AppOpenAdCoordinator?
Section titled “How do I wire up AppOpenAdCoordinator?”val placement = remember { AdPlacement( id = "app_open_main", format = AdFormat.AppOpen, androidAdUnitId = TestAdIds.ANDROID_APP_OPEN, iosAdUnitId = TestAdIds.IOS_APP_OPEN, )}
val coordinator = remember(adManager) { AppOpenAdCoordinator( manager = adManager, controller = adManager.appOpen(placement), config = AppOpenConfig( minBackgroundDuration = 4.seconds, cooldownBetweenShows = 4.hours, preloadOnStart = true, showOnColdStart = false, ), )}
LaunchedEffect(Unit) { coordinator.start(this) }start(scope) binds the coordinator to a coroutine scope and begins
observing foreground transitions. stop() tears it down. The
coordinator must be created inside remember so a recomposition
does not build a second one and double-register the lifecycle
observer.
What does each AppOpenConfig setting do?
Section titled “What does each AppOpenConfig setting do?”| Setting | What it does |
|---|---|
minBackgroundDuration | Ignore quick app switches. A return sooner than this does not trigger a show. |
cooldownBetweenShows | Minimum gap between two app-open impressions. Duration.ZERO disables the cooldown. |
preloadOnStart | Load an ad as soon as the coordinator starts, so the first eligible return has inventory. |
showOnColdStart | Whether a cold start may show an ad. Read the KDoc before enabling — a cold-start ad competes with your own splash. |
coldStartTimeout | How long a cold start may wait for an ad before giving up and continuing into the app. |
A short minBackgroundDuration maximises impressions and annoys
users who switch apps to copy a code; four seconds is a reasonable
floor. A cooldownBetweenShows of a few hours keeps the format
from feeling like an ad wall.
The judgement call on showOnColdStart: a cold-start ad competes
with the app’s own splash for the user’s attention. Most apps have
a meaningful cold-start experience (a logo, a sign-in flow, a
first-run tutorial) and disabling cold-start shows is the right
choice. Apps with a near-instant cold start can enable it, but
should still gate the show behind a short coldStartTimeout so
the user never waits on the ad network.
A note on the Duration values: 4.seconds, 4.hours,
30.seconds all come from kotlin.time.Duration.Companion. The
constructor does not clamp — passing Duration.ZERO to
cooldownBetweenShows disables the cooldown, passing a negative
value throws, and passing a very long cooldown is honoured
verbatim. The defaults are designed for the common case, not
edge cases.
How do I stop an app-open ad interrupting checkout?
Section titled “How do I stop an app-open ad interrupting checkout?”coordinator.isBlocked = true // entering checkout or onboarding// …coordinator.isBlocked = false // flow finishedSet isBlocked = true during a purchase, during onboarding, and
whenever another full-screen ad may show. The property is a plain
mutable field, so it is the caller’s job to reset it — a
try/finally around the sensitive flow is the safe shape.
The blocking check is evaluated at the moment a show would
otherwise happen, not at the moment isBlocked is set. That means
an in-flight show that has already passed the check will not be
cancelled by setting isBlocked = true afterwards. The property is
a guard against the next eligible show, not a kill switch for the
current one. In practice that distinction rarely matters, because
the gap between the check and the actual show is well under a
second; on iOS it is the gap between WillEnterForeground and the
SDK’s presentFromRootViewController: call.
A common related mistake: setting isBlocked = true inside the
onEvent callback of another full-screen ad. That works, but
only if the callback fires before the user returns to the
foreground. The safer shape is to set the flag at the entry
point of the sensitive flow and clear it at the exit, in a
try/finally so a thrown exception cannot leak the blocked
state.
Why did my app-open ad not show?
Section titled “Why did my app-open ad not show?”A short triage:
- The SDK is not
AdManagerStatus.Ready, and the coordinator deliberately shows only when it is, so it can never appear over a consent form. This also means it works underConsentMode.SkipConsent. - The background interval was shorter than
minBackgroundDuration. - The cooldown has not elapsed.
isBlockedis stilltruebecause a flow set it and never reset it.- No fresh ad was cached, in which case the coordinator reloads automatically.
- On iOS the coordinator listens to
WillEnterForeground, so system alerts and consent-form dismissals do not trigger shows. On Android it usesProcessLifecycleOwner.
The full symptom table is on Troubleshooting.
Can I control app-open ads manually?
Section titled “Can I control app-open ads manually?”val appOpen = adManager.appOpen(placement)scope.launch { appOpen.load() appOpen.showIfAvailable() // shows only when isReady()}AppOpenAdController is an ordinary FullScreenAdController plus
showIfAvailable(). The showIfAvailable() convenience is a
no-op when isReady() is false rather than returning
AdShowResult.NotReady — useful for a single-tap flow where the
caller does not want to handle a not-ready branch.
Hand-rolling the lifecycle means reimplementing foreground detection, cooldowns, minimum-background gating, and reload after consumption, which is the coordinator’s entire reason to exist. Most production code should use the coordinator; the manual path exists for tests and for apps that have a custom foreground detector they need to integrate with.
The full surface of AppOpenAdCoordinator is therefore: a
constructor taking the manager, controller, and config; start(scope)
to begin observing; stop() to release the observer; and the
mutable isBlocked field. There is no public method to
“force a show” or “reload now” — those operations are the
coordinator’s internal contract, and exposing them would let a
caller defeat the cooldown and minimum-background gating the
coordinator exists to enforce.
A short note on stop(): it is the symmetric counterpart to
start(scope) and should be called from the same lifecycle where
start was called. In a Compose app, the typical pattern is
DisposableEffect(coordinator) { onDispose { coordinator.stop() } }
so the observer is released on screen exit. Forgetting stop() is
not catastrophic — the next start will replace the observer — but
it does leak the previous WillEnterForeground / ProcessLifecycleOwner
listener until the coordinator is garbage-collected.