Why I took the show-or-not decision away from every dialog
A paywall prompt, a review request and a rewarded-ad confirmation landed on the same frame. Here is how I moved the show-or-not decision out of each dialog into a single gate, how I rewrote the agent instructions as invariants, and how a CI check keeps it that way.
About a second after launch, the paywall prompt opened. A review request landed on top of it, and a rewarded-video confirmation was queued behind both.
It never reproduced on my test devices. It happened only on devices that had reached the category screen in a previous session, been closed there, and reopened the next day.
This was a real path through the Android wallpaper app I maintain as an indie developer.
Each dialog was correct on its own
All three owned their display conditions locally.
Dialog
Condition it owned
Correct in isolation?
Paywall prompt
Launch count above threshold, not ad-free
Yes
Review request
Days-used above threshold, not yet rated
Yes
Rewarded-video confirmation
Ad inventory loaded, not ad-free
Yes
Nothing in that table is wrong. The problem is that every condition answers only one question: "am I allowed to show?" Nowhere in the codebase did anything ask "is someone else already showing?"
Ask an agent to add one dialog at a time and you get exactly this shape, because the spec you hand over is the spec for that dialog. The agent wrote correct code for the scope it was given. What was missing was the cross-cutting condition I never wrote down.
My first instinct was to add "and nobody else is showing" to each of the three conditions.
That does not scale. Three dialogs mean six references, four mean twelve, and every addition requires opening the existing files to extend their conditions. Ask an agent to add one dialog and only that file changes — the other three quietly go stale.
A design that keeps conditions distributed breaks a little on every addition. The failure mode is "occasionally overlaps" rather than "never appears", which is exactly the kind of thing tests do not catch.
✦
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
✦You will be able to look at your own dialog code and decide whether the display rules should stay local or move into one gate
✦You will be able to catch overlapping-modal defects with a CI check instead of waiting for them to reproduce on a user's device
✦You will be able to hand UI work to an agent as invariants rather than as isolated features, so the next dialog does not reopen the same hole
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.
Trap 2: async inventory checks shift the moment of judgement
The rewarded-video dialog appeared when the ad load callback returned.
// MainActivity.kt (before)rewardedAdLoader.load { ad -> if (ad != null && !billing.isAdFree()) { // Only looks at the state at the instant the load returns RewardedIntersDialog(this, ad).show() }}
Rewarded inventory depends on what AdMob has available at that moment, so loading takes anywhere from a few hundred milliseconds to several seconds. During that window the screen may have changed, or another dialog may have opened. The moment of judgement and the moment of display were different, and the condition only knew about the first one.
No amount of tightening the individual conditions avoids this gap. The place where the decision happens has to move.
That is why it never reproduced locally. On a fast development network the load returns quickly and the overlap window is small. On a weak mobile connection the wait stretches out and the collision becomes likely.
Trap 3: a reservation made on one screen outlives that screen
The review request was scheduled to appear when the user came back from the category screen. The code assumed the destination was the home screen, but there was also a path back from settings.
A "show this next" reservation made on one screen executes without knowing anything about where it lands. This is the same judgement-versus-display gap as trap 2, wearing different clothes.
Moving the decision into a gate
All three traps disappear if you ask one question, in one place, immediately before display. So I took the decision away from the dialogs.
// ModalGate.ktpackage net.dolice.wallpapers.modalimport androidx.lifecycle.Lifecycleimport androidx.lifecycle.LifecycleOwnerimport java.util.concurrent.atomic.AtomicReferenceenum class ModalKind(val perSessionLimit: Int) { PAYWALL(1), REWARDED_INTERSTITIAL(2), REVIEW_INDUCTION(1),}sealed interface GateResult { data object Granted : GateResult data class Denied(val reason: String) : GateResult}object ModalGate { private const val COOL_DOWN_MS = 1_500L private val current = AtomicReference<ModalKind?>(null) private val shownCount = mutableMapOf<ModalKind, Int>() private var lastDismissedAtMs = 0L /** * Re-asks the display question immediately before display. * show is invoked only when the request is granted. */ @Synchronized fun request(kind: ModalKind, owner: LifecycleOwner, show: () -> Unit): GateResult { // Trap 2: re-check that the screen is still alive when the async work returns if (!owner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { return GateResult.Denied("owner is not started") } val holder = current.get() if (holder != null) { // No pre-emption even for "more important" modals (reasoning below) return GateResult.Denied("occupied by $holder") } if (System.currentTimeMillis() - lastDismissedAtMs < COOL_DOWN_MS) { // Trap 3: without this, the dismiss tap gets absorbed by the next dialog return GateResult.Denied("cool down") } val shown = shownCount[kind] ?: 0 if (shown >= kind.perSessionLimit) { return GateResult.Denied("per-session limit reached for $kind") } current.set(kind) shownCount[kind] = shown + 1 show() return GateResult.Granted } /** Must be called on dismiss. Forgetting it wedges the gate shut, so cover it in tests. */ @Synchronized fun release(kind: ModalKind) { if (current.get() == kind) { current.set(null) lastDismissedAtMs = System.currentTimeMillis() } } /** Reset at session start so counts do not survive process recreation. */ @Synchronized fun resetSession() { current.set(null) shownCount.clear() lastDismissedAtMs = 0L }}
Call sites now file a request instead of showing something.
// MainActivity.kt (after)rewardedAdLoader.load { ad -> if (ad == null) return@load val result = ModalGate.request(ModalKind.REWARDED_INTERSTITIAL, this) { RewardedIntersDialog(this, ad) { ModalGate.release(ModalKind.REWARDED_INTERSTITIAL) }.show() } if (result is GateResult.Denied) { // The reason survives, so it can be reconciled against delivery logs later Log.i(TAG, "rewarded modal skipped: ${result.reason}") }}
Note that billing.isAdFree() stays inside the dialog. The gate owns exactly one concern — that modals do not overlap. Whether a given modal deserves to exist at all remains the dialog's business. Blur that line and the gate turns into one enormous if statement holding every product rule in the app.
Rewriting the agent instruction as an invariant
A gate alone does not survive the next addition. Ask an agent to add a fourth dialog and it will read the surrounding code; if even one direct show() remains, that becomes the template.
So the instruction gained one line: never call show on a dialog directly — all display goes through ModalGate.request.
The judgement behind that kind of prohibition is the same one I worked through in what I regretted delegating to an agent. But an instruction you cannot verify is a wish. This one needed a machine check.
#!/usr/bin/env bash# tools/check_modal_gate.sh# Detects dialog display that bypasses ModalGate. Runs in CI.set -euo pipefailSRC_DIR="${1:-app/src/main/java}"VIOLATIONS=0# Restricted to the "SomethingDialog.show()" shape; matching every show() adds noise.while IFS= read -r hit; do file="${hit%%:*}" [ "$(basename "$file")" = "ModalGate.kt" ] && continue grep -q 'ModalGate\.request' "$file" && continue echo "NG $hit" VIOLATIONS=$((VIOLATIONS + 1))done < <(grep -rnE '\b[A-Za-z]+Dialog\([^)]*\)\.show\(\)|\b[A-Za-z]+Dialog\.show\(' \ "$SRC_DIR" --include='*.kt' || true)if [ "$VIOLATIONS" -gt 0 ]; then echo "$VIOLATIONS display call(s) bypass ModalGate" exit 1fiecho "OK: no display bypasses ModalGate"
The first run found one site I had missed while migrating.
NG app/src/main/java/net/dolice/wallpapers/ui/SettingsActivity.kt:212: ReviewInductionDialog(this).show()
1 display call(s) bypass ModalGate
That single line was the whole of trap 3 — the reservation made on the settings path. I had read past it three times in manual review, so without the check it would have shipped again.
Forgetting release can be closed off the same way, and more cheaply: make the dialog constructor take onDismiss as a required parameter and a missing call becomes a compile error. Where a type can hold the invariant, a type is cheaper than a runtime check.
One modal per frame beat the priority table
My first draft gave ModalKind a priority and let a higher one dismiss whatever was on screen. It was nearly finished.
I abandoned it because I mis-tapped during internal testing. I reached for "Later" on the review request, the paywall swapped in at that instant, and my finger landed on a different button at the same coordinates. Being pushed into a screen you never chose is worse than seeing two dialogs stacked.
Priority looks correct as a business weighting. From the user's side, though, something disappearing on its own is indistinguishable from a misfire of their own hand. So pre-emption never shipped. What remained was first-come-first-served plus a 1,500 ms cool-down.
The urge to control ordering can be satisfied elsewhere. Tune each dialog's own thresholds — launch count, days used — and the effective order follows. I prefer adjusting conditions over runtime pre-emption here, simply because the resulting behaviour is predictable.
Verifying it during the staged rollout
Crash rate cannot validate this change. Nothing was crashing while the dialogs overlapped.
So I cut the rollout finer than usual: 5% for two days before moving to 25%. Three things were worth watching.
Crash-free users staying at or above 99.7% — a forgotten release would wedge the gate and surface here
ANR staying under 0.20%, since @Synchronized is being touched from the UI thread
The ratio between modal skipped log volume and completed rewarded-video views
The third was the real signal. Every rejection removes a display opportunity, but those rejections were previously overlapping displays that users could not act on anyway. If completed views hold steady, the gate removed waste rather than revenue.
Count the direct Dialog(...).show() calls in your codebase. Two or fewer, and you do not need a gate; in that case a single test that detects overlap costs less than cross-referencing conditions. Past three, keeping the cross-references consistent stops being realistic.
I did not see it coming when I added the third one. What made it visible was not a support email — it was the day I mis-tapped my own app.
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.