The App Open Ad Antigravity Wrote for Me Fires Every Time I Return From My Own Paywall or Rewarded Video
Ask Antigravity to add an App Open ad and it shows one the instant you return from your own rewarded video or the Google Play purchase sheet — which also brushes against AdMob policy. Here is a foreground arbiter that records why the app came back, with working Kotlin and a verification matrix.
I wanted to add an App Open ad to one of the wallpaper apps I run as an indie developer, and I let Antigravity's agent write the implementation itself. The code it produced was a few dozen lines, it worked, and an ad appeared when I returned from the home screen. But testing on a device, an App Open ad also stacked on top the moment I finished my own rewarded video and came back. It fired right after I closed the purchase sheet too. From a reader's point of view that is the worst possible experience: "I watched an ad, and here is another ad."
I cannot blame the agent. The naive implementation of an App Open ad is "show one when the app returns to the foreground," and that is exactly what it wrote. What was missing was a single idea: don't ask only whether we returned, ask why we returned. Skip that, and you also run into AdMob's policy intent of not showing an App Open ad immediately after your own full-screen content.
Why "show on every foreground return" becomes an incident
An App Open ad is meant to fill the natural gap when a user leaves the app and comes back. The trouble is that, from an Android Activity's view, "the user went to the home screen and returned" and "the user returned from a full-screen ad I showed" both look like the same onStart.
The returns I had to suppress fell into four kinds.
Returning from my own interstitial or rewarded video (ad-on-ad stacking, policy trouble)
Closing the Google Play purchase sheet (an ad right after a purchase destroys trust)
Coming back from a Chrome Custom Tab opened for an external link
Cold start (the launch splash and the App Open ad collide)
The only return you should show on is when the user left and came back of their own accord. You simply cannot make that distinction from onStart alone.
The naive implementation an agent writes, and its blind spot
Ask Antigravity to "add an App Open ad" and you roughly get this skeleton: observe the whole process going foreground via ProcessLifecycleOwner, and call show() when it returns.
// The typical first draft an agent produces (this one misfires)class MyApp : Application(), DefaultLifecycleObserver { private lateinit var appOpenAdManager: AppOpenAdManager override fun onCreate() { super<Application>.onCreate() MobileAds.initialize(this) appOpenAdManager = AppOpenAdManager(this) ProcessLifecycleOwner.get().lifecycle.addObserver(this) } // ★ The blind spot: it never asks WHY we came back to the foreground override fun onStart(owner: LifecycleOwner) { appOpenAdManager.showAdIfAvailable(currentActivity) }}
This fires on onStart identically for a home return and for a return from a rewarded video. When I review an agent's output, I have learned to ask not "does it work" but "is this correct no matter which path brought us to the foreground." For App Open ads, that question always catches this.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Stop the double App Open ad that fired every time users returned from a rewarded video or purchase sheet, by identifying the reason for the foreground return
✦Understand why a naive ProcessLifecycleOwner ON_START implementation causes AdMob policy trouble and churn, and design the suppression conditions yourself
✦Drop a Kotlin implementation into your own app that separates cold start, full-screen ads, billing flows, and external-link returns
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
The core fix: an arbiter that records the reason for the return
If you keep bolting suppression conditions onto onStart as if statements, the branches nest deeper with every new case and you lose track of which flag is in effect. What I settled on was an arbiter that records, in one place, the reason the app left the foreground, while onStart merely consults its verdict.
The arbiter holds three pieces of state.
Are we currently showing our own full-screen content (an ad, a purchase sheet)?
Did we just intentionally leave into an internal flow (Custom Tabs, etc.)?
Are we still right after a cold start?
These are updated from Application.ActivityLifecycleCallbacks and from the begin/end hooks of each full-screen surface.
// A single arbiter that owns the "reason" for a foreground returnobject ForegroundArbiter { // Showing our own full-screen content (ad, purchase sheet, full-screen dialog) @Volatile var isShowingFullScreenContent = false // Intentionally moved to Custom Tabs or an external app (don't show on return) @Volatile var isEnteringInternalFlow = false // Process has not yet finished a single organic foregrounding @Volatile var isColdStart = true /** The one and only entry point deciding whether this return may show the ad */ fun shouldShowAppOpenAd(): Boolean { if (isColdStart) return false if (isShowingFullScreenContent) return false if (isEnteringInternalFlow) return false return true } /** Call right before opening any full-screen ad or purchase sheet */ fun beginFullScreenContent() { isShowingFullScreenContent = true } fun endFullScreenContent() { isShowingFullScreenContent = false } /** Call right before an external transition such as Custom Tabs */ fun beginInternalFlow() { isEnteringInternalFlow = true } fun endInternalFlow() { isEnteringInternalFlow = false }}
The key is that the arbiter knows nothing about when to show an ad. The display decision collapses into a single predicate, shouldShowAppOpenAd(), and the onStart side only reads it. Testing becomes a matter of feeding state into that predicate to walk every path on paper.
The App Open ad manager itself (with the 4-hour expiry and loading)
Kept apart from the arbiter, loading and showing live in a dedicated class. An App Open ad expires four hours after loading, so it must remember the time it was cached and refuse to show a stale ad, reloading instead.
class AppOpenAdManager(private val app: Application) { private var appOpenAd: AppOpenAd? = null private var isLoading = false private var loadTimeMs = 0L private val adUnitId = "ca-app-pub-XXXXXXXX/XXXXXXXX" // replace with your real ad unit ID fun loadAd() { if (isLoading || isAdAvailable()) return isLoading = true val request = AdRequest.Builder().build() AppOpenAd.load(app, adUnitId, request, object : AppOpenAd.AppOpenAdLoadCallback() { override fun onAdLoaded(ad: AppOpenAd) { appOpenAd = ad isLoading = false loadTimeMs = System.currentTimeMillis() } override fun onAdFailedToLoad(error: LoadAdError) { isLoading = false // retry on the next opportunity } }) } // Within the 4-hour validity window? private fun isAdAvailable(): Boolean { val fourHours = 4 * 60 * 60 * 1000L return appOpenAd != null && (System.currentTimeMillis() - loadTimeMs) < fourHours } fun showAdIfAvailable(activity: Activity?) { // Delegate the display decision to the arbiter; the manager only checks readiness if (!ForegroundArbiter.shouldShowAppOpenAd()) { loadAd(); return } if (activity == null || !isAdAvailable()) { loadAd(); return } appOpenAd?.fullScreenContentCallback = object : FullScreenContentCallback() { override fun onAdShowedFullScreenContent() { // While we show full screen, don't re-show on the return ForegroundArbiter.beginFullScreenContent() } override fun onAdDismissedFullScreenContent() { ForegroundArbiter.endFullScreenContent() appOpenAd = null loadAd() // preload the next one } override fun onAdFailedToShowFullScreenContent(e: AdError) { ForegroundArbiter.endFullScreenContent() appOpenAd = null loadAd() } } appOpenAd?.show(activity) }}
Calling beginFullScreenContent() inside onAdShowedFullScreenContent matters because the App Open ad is itself full-screen: if another onStart fires while it is up, we must not show again. The code that shows your rewarded or interstitial ads wraps the same beginFullScreenContent() / endFullScreenContent() pair around its display.
Consulting the arbiter from the ON_START gate
The Application tracks the current Activity and asks the arbiter for a verdict on each foregrounding. ActivityLifecycleCallbacks updates currentActivity, and once the first onStart is consumed, the cold-start flag is lowered.
class MyApp : Application(), DefaultLifecycleObserver { private lateinit var appOpenAdManager: AppOpenAdManager private var currentActivity: Activity? = null override fun onCreate() { super<Application>.onCreate() MobileAds.initialize(this) {} appOpenAdManager = AppOpenAdManager(this) appOpenAdManager.loadAd() ProcessLifecycleOwner.get().lifecycle.addObserver(this) registerActivityLifecycleCallbacks(object : SimpleActivityLifecycleCallbacks() { override fun onActivityResumed(activity: Activity) { currentActivity = activity } }) } override fun onStart(owner: LifecycleOwner) { if (ForegroundArbiter.isColdStart) { // Swallow the first foregrounding after launch (avoid colliding with the splash) ForegroundArbiter.isColdStart = false return } appOpenAdManager.showAdIfAvailable(currentActivity) }}
Now the logic in onStart is only "swallow one cold start, then defer to the arbiter." The real show/no-show decision lives solely inside ForegroundArbiter.shouldShowAppOpenAd(), and the billing and rewarded-ad code integrates by wrapping two hook lines around its display.
Verification: confirm show / no-show for each return path
This is the most important part. App Open bugs surface as "sometimes shows" or "only shows on a specific path," so I built a matrix to walk each return path by hand. I once skipped this and shipped, leaving an ad that showed right after purchase, and it dragged down my review rating.
Return path
Expected
Flag in play
Home → re-foreground the app
Show
all false
Finish a rewarded video and return
No show
isShowingFullScreenContent
Close the purchase sheet and return
No show
isShowingFullScreenContent
Return from Custom Tabs
No show
isEnteringInternalFlow
Right after cold start
No show
isColdStart
In a unit test, feed the state straight into the arbiter's predicate and pin the expected value.
@Testfun rewarded_dismiss_does_not_show_app_open() { ForegroundArbiter.isColdStart = false ForegroundArbiter.beginFullScreenContent() // rewarded video shown assertFalse(ForegroundArbiter.shouldShowAppOpenAd()) ForegroundArbiter.endFullScreenContent() // just dismissed assertTrue(ForegroundArbiter.shouldShowAppOpenAd())}
On a device, stream each flag transition to adb logcat and walk all five paths above, confirming show / no-show by eye. If you let Antigravity's agent handle verification too, hand it this matrix as the acceptance criteria, which cuts down on passing through implementations that have a gap.
What changed in production, and pinning the instruction to the agent
Before I added the reason-for-return identification, App Open ads stacked on returns from the rewarded video and the purchase sheet, producing up to three unwanted impressions per session. About 60% of foregrounding events came from internal flows, and most of those were returns where the ad should never have shown. After inserting the arbiter, those unwanted impressions disappeared, along with the worst-case ad-right-after-purchase experience. From an AdMob policy angle, it now structurally prevents an App Open ad immediately after my own full-screen content.
The lesson that stuck was this: when you ask an agent for ad code, make the conditions for not showing the subject of the spec, not the showing. I now prepend a constraint to my Antigravity requests: "An App Open ad must not show on returns from our own full-screen content, billing flows, external transitions, or cold start. Create one predicate that centrally owns the reason for the foreground return, and route the display decision through it alone." My review of the generated code starts by checking whether that predicate exists.
As a next step, check whether your own app has a single predicate equivalent to shouldShowAppOpenAd(). If the branches are scattered inside onStart, that is where the next double-impression bug is waiting.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.