Skip to content

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.

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.

SettingWhat it does
minBackgroundDurationIgnore quick app switches. A return sooner than this does not trigger a show.
cooldownBetweenShowsMinimum gap between two app-open impressions. Duration.ZERO disables the cooldown.
preloadOnStartLoad an ad as soon as the coordinator starts, so the first eligible return has inventory.
showOnColdStartWhether a cold start may show an ad. Read the KDoc before enabling — a cold-start ad competes with your own splash.
coldStartTimeoutHow 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 finished

Set 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.

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 under ConsentMode.SkipConsent.
  • The background interval was shorter than minBackgroundDuration.
  • The cooldown has not elapsed.
  • isBlocked is still true because 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 uses ProcessLifecycleOwner.

The full symptom table is on Troubleshooting.

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.