In May 2026, near the end of shipping v2.1.0 of the Android version of my wallpaper app, a screenshot from a test device made me do a double take. A review prompt was sitting on top of the paywall, and behind both of them, a rewarded-ad promo dialog was trying to open.
I've been an indie developer since 2014, and for most of that time my approach to dialog coordination was "it works, so it's fine." When the paywall was the only modal in the app, that was true — nothing could collide with it. The moment this update brought the count to three (PaywallDialog, ReviewInduction, and RewardedIntersDialog), the unspoken assumption collapsed.
The fix ended up being a single small class I named ModalGate, but the part that surprised me was how much a design review with Claude Code contributed along the way. If you're seeing symptoms like "dialogs stack on top of each other" or "after closing one dialog, nothing ever appears again," here are the traps I found and the implementation that resolved them.
The three traps behind overlapping modals
What looked like one bug turned out to be three separate traps tangled together.
Trap 1: Each dialog is unaware of the others
The paywall checked "not subscribed and third launch or later." The review prompt checked "seven days since install and not yet reviewed." The rewarded promo checked "ad-free credit not yet consumed." Each dialog evaluated only its own condition. When conditions are independent, a day will inevitably come when all of them are true at once. In my case, the intersection was a user who kept using the app past day seven without subscribing.
Trap 2: Priority silently depends on call order
The three checks lived next to each other in MainActivity's onResume, so "whichever is written first wins" was the de facto priority system. Later, while reorganizing initialization code, I reordered those lines — and a different dialog started appearing. Nowhere in the codebase did it say "the paywall has top priority," so no code review could have caught it.
Trap 3: Dismiss chaining and flag leaks
At one point I added a quick boolean "modal showing" flag. But the OK button is not the only way to close a dialog. Back-key presses and outside taps went through a path where the flag never got cleared, and once that happened, no modal would ever appear again. A paywall that never shows hits revenue directly, so this was worse than the overlap itself.
The root cause: dialogs that decide for themselves
The common root of all three traps is that each dialog decided on its own whether it could appear. Every time a new modal joins the cast, you need more "check everyone else's state" code scattered around, and every missed check becomes a collision.
So I inverted the policy. Each dialog only applies to be shown; the decision of whether it may show is made in exactly one place. That single place is ModalGate.
Designing ModalGate — a priority-based central gate
There are only three design rules.
- Only ModalGate decides whether a modal may show (dialogs just apply)
- Priority is explicit and numeric (paywall > rewarded promo > review prompt)
- A denied modal is not carried over (no chain-showing the next dialog right after one closes)
Here is nearly the entire implementation. It is less a clever algorithm than a small container that concentrates the decision in one place.
// ModalGate.kt — central manager for modal display (singleton)
object ModalGate {
// Tag of the modal currently showing. null means nothing is visible
private var activeTag: String? = null
// Priority definitions (lower rank wins)
enum class Priority(val rank: Int) {
PAYWALL(0), // paywall
REWARDED_PROMO(1), // rewarded-ad promo
REVIEW_INDUCTION(2) // review prompt
}
/**
* Apply to show a modal. Returns true only when granted.
* The caller may invoke show() only on a true result.
*/
@Synchronized
fun request(tag: String, priority: Priority): Boolean {
val current = activeTag
if (current != null) {
Log.d("ModalGate", "denied: $tag (active=$current)")
return false // something is showing: deny unconditionally, no chaining
}
activeTag = tag
Log.d("ModalGate", "granted: $tag (priority=${priority.rank})")
return true
}
/** Must be called from DialogFragment.onDismiss() */
@Synchronized
fun release(tag: String) {
if (activeTag == tag) {
activeTag = null
Log.d("ModalGate", "released: $tag")
}
}
/** Restore after process recreation (see the trap below) */
@Synchronized
fun restore(tag: String?) {
activeTag = tag
}
}The call site looks like this:
// Display check for the review prompt (caller side)
if (shouldShowReviewInduction() &&
ModalGate.request("review", ModalGate.Priority.REVIEW_INDUCTION)) {
ReviewInductionDialog().show(supportFragmentManager, "review")
}When the paywall and review prompt conditions become true at the same time, the log reads:
D/ModalGate: granted: paywall (priority=0)
D/ModalGate: denied: review (active=paywall)
D/ModalGate: released: paywall
One judgment call worth writing down: I considered a chaining variant where a denied review prompt would appear right after the paywall closes, and decided against it. From the user's side, that design feels like an app where closing one popup just summons the next. A denied modal simply gets re-evaluated on the next launch — and a review prompt arriving a day late costs you almost nothing. The experience is calmer for it.
The three dismiss-leak paths a Claude Code review uncovered
I got as far as calling release() from onDismiss on my own, but trap 3 had made me cautious, so I asked Claude Code for a design review with a deliberately adversarial prompt: "List every path where release() will not be called." It came back with three.
- Back key and outside taps: relying on setOnDismissListener alone can leak on the onCancel path. Consolidating into an override of DialogFragment's onDismiss() gave me a single choke point that fires no matter how the dialog closes
- Process recreation: on a device with the developer option to discard activities enabled, the FragmentManager restores the dialog on return — but the singleton's activeTag has been reset to null. The dialog is visible, yet the gate reports open. I added an onCreate pass that scans FragmentManager tags and calls restore()
- Screen rotation: if the display checks re-run during recreation, the restored dialog and a freshly requested one appear twice. Moving the checks out of onResume and into user-action triggers plus the first-launch flow avoided it
To be honest, the process-recreation case is one I would not have found alone. The mismatch between a singleton's lifetime and FragmentManager's restore timing only reproduced once I enabled the discard-activities option on a real device. My takeaway on review prompts: asking "list the paths where this is never called" finds more than asking "does this look correct?"
What changed after shipping
About a month after the v2.1.0 release, I have not seen a single overlapping-modal report in testing or production. The "nothing ever shows again" state from trap 3 is gone at the root, because flag management now lives in the single release() path. The rewarded-ad promo reliably appearing at its intended moments has also quietly helped on the AdMob revenue side.
A side benefit: adding a new modal is no longer a review burden. A new dialog adds one Priority line and goes through request() — there is no per-pair collision analysis against every existing modal.
This same v2.1.0 release also included a crash-fixing effort that ran in parallel with the modal cleanup. I wrote up that story in How I Fixed Android RecyclerView Crashes in 28 Days Using Claude Code, and the post-release monitoring setup in Automated Play Store Staged Rollout Monitoring with Claude Code — Lessons from 50+ Crashes in v2.0.0.
Where to start
ModalGate itself is under fifty lines; most of the value comes from the policy decision to concentrate display decisions in one place. As a first step, grep your project for .show( and list every site that presents a modal. If none of them check the visibility state of the others, it is worth installing this pattern before your modal count reaches three.
I hope this saves someone the debugging session it cost me.