In-App Ad × Popup Collision — Partner Guide
Audience: Partners integrating InAppAdManager (interstitial / native-full touchpoints).
Problem class: A modal popup (permission request, system dialog, alert, bottom sheet) is triggered from a destination screen's onCreate while an interstitial ad from the previous screen is still showing or in the middle of dismissing. Result: popup renders on top of the running ad, the ad is visually overlapped/canceled, impression is lost, and UX is broken.
1. Why It Happens
InAppAdManager.show(activity, tp) { onNextAction } runs the following sequence:
- t0 — Caller activity launches interstitial Activity (AdMob
FullScreenContentCallbackopens). - t1 —
onNextActionfires (startActivity(NextActivity)) — before the ad Activity finishes. - t2 —
NextActivity.onCreate()runs while the ad is still on top. - t3 — If
NextActivity.onCreate(or first composition) immediately calls something modal —requestPermissions(...),AlertDialog.show(),BottomSheet.show()— that popup is queued on the new Activity's window and immediately overlays the ad. - t4 — User dismisses popup → ad is gone, impression lost, sometimes the AdMob session is left in an inconsistent state.
This is not a bug in the SDK — it is the standard Android Activity lifecycle. The fix lives in partner integration code.
2. The Rule
Never trigger blocking UI from
onCreate/ first composition of a screen that is reached viaInAppAdManager.show(..., onNextAction = { startActivity(...) }).
Defer the popup until the ad has fully dismissed and the destination is foregrounded.
3. Recommended Pattern — repeatOnLifecycle(RESUMED) + small delay + AtomicBoolean one-shot
Works for permission requests, alert dialogs, bottom sheets — anything modal. The delay(200) absorbs the final frames of ad-Activity teardown so the popup never races the dismiss animation.
class PhoneActivity : BaseMviActivity() {
// AtomicBoolean — survives config change if scoped to the ViewModel,
// or use `private val` on the Activity if you want it re-armed on recreate.
private val oneActionInResume = AtomicBoolean(true)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { PhoneScreen(...) }
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
delay(200) // let the interstitial Activity finish tearing down
if (oneActionInResume.getAndSet(false)) {
if (!hasStoragePermission()) {
shouldGoSettingsForFallback = false
handlePermissionRequest()
}
}
}
}
}
}
Why this works:
repeatOnLifecycle(RESUMED)only runs the block when the Activity is actually RESUMED — i.e. the ad Activity has finished and this Activity is on top of the task. It auto-cancels onSTARTED → CREATEDtransitions, so no leaks.delay(200)covers the residual frames where the ad Activity is still finishing its dismiss animation /onDestroy. Without this, on slow devices the popup can still land while the ad's window is mid-removal.AtomicBoolean.getAndSet(false)is a thread-safe one-shot — survives recompositions, rotation, and re-entry into RESUMED (e.g. after user denies permission and returns from the system dialog, the block does not re-fire).
For Compose-only screens (no hosting Activity hook):
@Composable
fun PhoneScreen(...) {
val oneActionInResume = rememberSaveable { AtomicBoolean(true) }
val lifecycle = LocalLifecycleOwner.current.lifecycle
LaunchedEffect(lifecycle) {
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
delay(200)
if (oneActionInResume.getAndSet(false)) {
if (!hasStoragePermission()) {
handlePermissionRequest()
}
}
}
}
}
⚠️
delay(200)is a safety buffer, not a workaround for missing lifecycle handling. Do not tune it up to hide other bugs — 150–250 ms is the working range; anything higher is masking a different problem.
See also: docs/inapp-ad-integration.md for the full InAppAdManager API and touchpoint configuration.