●FABLE5 — Claude Fable 5 launches (Jun 9): the first generally available Mythos-class model, beyond Opus, with 1M-token context, 128k output, and always-on adaptive thinking●FREE-WINDOW — Fable 5 is included free on Pro, Max, Team, and Enterprise through Jun 22; usage credits required from Jun 23. API pricing is $10/$50 per MTok●SAFEGUARDS — Fable 5 falls back to Opus 4.8 on high-risk topics (under 5% of sessions); the unrestricted Mythos 5 is limited to vetted organizations●IPO — Anthropic confidentially files for an IPO (Jun 1), with a reported $65B raise, $965B valuation, and $47B annualized revenue●BILLING — 3 days to the Jun 15 change: Agent SDK, headless Claude Code, GitHub Actions, and third-party agents move to API-rate monthly credits●PLATFORM — Claude Developer Platform adds Managed Agents scheduled deployments, vault env credentials, and session thread webhook events●FABLE5 — Claude Fable 5 launches (Jun 9): the first generally available Mythos-class model, beyond Opus, with 1M-token context, 128k output, and always-on adaptive thinking●FREE-WINDOW — Fable 5 is included free on Pro, Max, Team, and Enterprise through Jun 22; usage credits required from Jun 23. API pricing is $10/$50 per MTok●SAFEGUARDS — Fable 5 falls back to Opus 4.8 on high-risk topics (under 5% of sessions); the unrestricted Mythos 5 is limited to vetted organizations●IPO — Anthropic confidentially files for an IPO (Jun 1), with a reported $65B raise, $965B valuation, and $47B annualized revenue●BILLING — 3 days to the Jun 15 change: Agent SDK, headless Claude Code, GitHub Actions, and third-party agents move to API-rate monthly credits●PLATFORM — Claude Developer Platform adds Managed Agents scheduled deployments, vault env credentials, and session thread webhook events
Untangling Android Back-Button Ad Gates: A Parallel, Priority-Ordered Redesign with Claude Code
Nested back-button ad gates fired at the wrong moments. The parallel, priority-ordered redesign we shipped in v2.1.0, with Claude Code, Kotlin, and tests.
In May 2026, while preparing v2.1.0 of the Android edition of my wallpaper app, two contradictory signals landed in the same week. Store reviews said the app showed an ad every single time they tried to leave. Meanwhile, the AdMob dashboard showed exit-interstitial impressions running at roughly half of what the frequency settings should produce. "Too many ads" and "no ads at all" were both true, for the same release, at the same time.
That combination told me the problem was not the ad SDK. It was my own branching logic. The culprit turned out to be the back-button handler: a stack of nested conditionals that had accumulated over nearly a decade of feature work. This is the record of how I rebuilt that nesting into a set of parallel, independent gates, and how I delegated the path inventory and test generation to Claude Code over roughly two weeks. If your app has an exit flow that nobody on the team wants to touch, the design and the migration order below should transfer directly.
The Symptom: One Exit Flow Producing Both "Never" and "Always"
The screen in question is the app's top-level screen. When the back button is pressed there, the app has to answer five questions.
Is this an ad-free user? Either a one-time ad-removal purchase, or a temporary ad-free window earned by watching a rewarded ad
Is another modal already visible? The exit ad must not stack on top of the review prompt or an announcement dialog
Should the review prompt take this slot? Based on launch counts and days since the last prompt
Has the frequency cap been reached? Exit interstitials have a minimum interval between impressions
Is the interstitial actually loaded? If not, there is nothing to show
Written as a list, this looks tidy. In the real code, those five concerns were buried inside nested if-statements, in whatever order each feature happened to be added. The two bug families mapped cleanly onto that structure. In one path, the review prompt's "showing" flag could get stuck and never come down — users who entered that state never saw an exit ad again, which produced the missing impressions on the AdMob side. In another path, the "ad not loaded" fallthrough skipped the frequency-cap bookkeeping entirely, so the next session showed an ad far earlier than the configured interval — which produced the angry reviews.
The Root Cause: Nesting Encodes Priority Implicitly
Here is the before state, simplified and renamed but structurally faithful.
// Before: the nested branching that had accumulated in the back handleroverride fun onBackPressed() { if (!billingManager.isPurchased) { if (interstitialAd != null) { if (!reviewInduction.isShowing) { if (frequencyCap.canShow()) { interstitialAd?.show(this) // finally, the ad } else { super.onBackPressed() // cap reached -> fall through } } // when reviewInduction.isShowing == true: do nothing (bug nursery) } else { super.onBackPressed() // not loaded -> fall through, uncounted } } else { super.onBackPressed() // purchased -> fall through }}
The real problem is not that this code contains two bugs. It is that the structure has three properties that keep producing bugs.
First, priority lives in the nesting depth, where it cannot be read as a specification. The business rules — "billing outranks the review prompt", "the review prompt outranks the ad" — exist only as the accidental order in which the ifs were written. When you add a new condition, the code gives you no basis for deciding which depth it belongs at.
Second, every added condition doubles the path count. Five booleans means 32 theoretical states, and every one of them deserves a deliberate answer. In a nested structure, the combinations you never wrote down fall into whatever else-branch happens to catch them. Whether they land on super.onBackPressed() or on "silently do nothing" is an accident of indentation.
Third, the fall-through paths are mute. When no ad appeared, the logs could not tell me whether the user was purchased, the ad was unloaded, or the cap was hit. That silence is exactly why every "the ad never shows" investigation used to take half a day.
I have been building apps as an indie developer since 2014, and I know perfectly well that "it works, so don't touch it" code is the most expensive kind. The exit flow still sat untouched for years because verifying it manually was so tedious. What finally got me moving was realizing I could hand the tedious part — enumerating the reachable paths — to Claude Code.
✦
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 diagnose 'the ad never shows' and 'too many ads' reports in minutes, because every back-button decision is logged per gate
✦You can replace years of nested if-statements with a priority-ordered, first-match gate pipeline in Kotlin, following the 5 migration steps in this article
✦You will consolidate isAdFree || isRewardAdFree into a single source of truth, so billing state and rewarded-ad unlocks can never silently disagree
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 Redesign: Parallel, Independent, Declarative Gates
The new design fits in three sentences. Each concern becomes a gate that answers exactly one question. Gates know nothing about each other. Priority is declared in a single ordered list instead of in nesting.
// After: the exit flow as a first-match-wins pipeline of independent gatesinterface ExitGate { val name: String fun evaluate(ctx: ExitContext): GateDecision}sealed class GateDecision { object Pass : GateDecision() // not my turn -> next gate data class Consume(val action: () -> Unit) : GateDecision() // handle it here, stop evaluating}class ExitFlowDispatcher(private val gates: List<ExitGate>) { /** Routes one back-press to exactly one gate. * Returns false if no gate consumed it (= defer to normal back handling). */ fun onExitRequested(ctx: ExitContext): Boolean { for (gate in gates) { when (val decision = gate.evaluate(ctx)) { is GateDecision.Consume -> { GateLog.record(gate.name) // always record who consumed it decision.action() return true } GateDecision.Pass -> continue } } GateLog.record("(pass-through)") // record the silence, too return false }}
Priority then becomes a list literal — something a code review can read as a spec.
// Priority is the order of this list. Nothing else.val exitGates = listOf( adFreeGate, // 1. ad-free users pass through, always, first modalCollisionGate, // 2. never show anything on top of another modal reviewInductionGate, // 3. the review prompt outranks the ad frequencyCapGate, // 4. interval cap -> pass through, but record it interstitialGate, // 5. show the interstitial if loaded)
Evaluation is first-match-wins: walk the list top to bottom, let the first gate that returns Consume do its work, and never evaluate the rest. "Billing outranks the review prompt" is now expressed by adFreeGate literally sitting on a higher line than reviewInductionGate. Adding a concern means writing one new gate and inserting one line — no existing gate changes.
One rule I held onto deliberately: evaluate must be side-effect free. The frequency-cap gate only answers "is the cap reached?"; incrementing the impression counter happens inside the interstitial gate's action, and only in the ad's on-shown callback. The original "unloaded fall-through skips the bookkeeping" bug existed precisely because judging and recording were tangled together.
One Source of Truth for "Ad-Free"
Extracting the gates surfaced a second structural problem. The answer to "may we show ads to this user?" was computed slightly differently in different screens. Some read billingManager.isPurchased directly. The temporary ad-free window granted for watching a rewarded ad lived in a separate manager, and some screens consulted it while others did not.
The fix was to make one class the only place that can answer the question.
// The ad-free decision lives here and nowhere else.class AdFreeStatus( private val billing: BillingManager, private val rewardWindow: RewardAdFreeManager,) { /** True if the user bought ad removal OR is inside a rewarded ad-free window */ val isAdFree: Boolean get() = billing.isPurchased || rewardWindow.isActive}class AdFreeGate(private val status: AdFreeStatus) : ExitGate { override val name = "AdFreeGate" override fun evaluate(ctx: ExitContext): GateDecision = if (status.isAdFree) GateDecision.Consume { ctx.finishNormally() } else GateDecision.Pass}
The step that paid for itself was asking Claude Code: "List every place outside the class that reads billingManager.isPurchased directly, and produce the diff that routes them through AdFreeStatus." It found nine direct references in my project. Two of them had never accounted for the rewarded unlock at all — meaning screens other than the exit flow could show ads to users who were legitimately inside an ad-free window. I could have done the grep myself, but going from "found" to "here is the reviewed diff" in one motion let me spend my attention on review instead of mechanics.
Because the rewarded window expires, rewardWindow.isActive checks the clock on every read. I considered caching the value per session and decided against it: a getter is cheap, and a stale "still ad-free" flag straddling the expiry moment is exactly the kind of inconsistency this refactor was meant to eliminate.
Wiring into OnBackPressedDispatcher, and the Double-Press Window
Overriding onBackPressed() has been deprecated since API 33, so the integration point is the OnBackPressedDispatcher. The Activity-side code shrinks to this:
// The Activity registers exactly one callback with the dispatcheronBackPressedDispatcher.addCallback(this) { val consumed = exitFlow.onExitRequested(buildExitContext()) if (!consumed) { isEnabled = false // disable self first onBackPressedDispatcher.onBackPressed() // defer to the system default isEnabled = true }}
Device testing added one more guard. Between the moment the show-ad action runs and the moment the interstitial actually covers the screen, there are a few hundred milliseconds in which the user can press back again. A 600 ms debounce at the dispatcher entry keeps a rapid double-press from running the gate evaluation twice.
There is also a forward-looking reason to keep evaluate side-effect free: predictive back on Android 14 and later. With predictive back, the preview begins before the user commits the gesture, so evaluation and commitment become separate moments. A judgment that is safe to run any number of times, with a single action that runs once at commit, is exactly the shape predictive back wants.
Working with Claude Code: Inventory, Decision Table, and Where I Said No
I delegated three areas to Claude Code on this project.
The first was path enumeration of the legacy handler. I handed it the old onBackPressed plus the related flag implementations and asked for every reachable execution path as a table of condition combinations and outcomes. It came back with 22 reachable paths. Nine of them were paths I could not map to any intended behavior. "The author cannot explain nine of his own code paths" was the single most persuasive argument for doing the refactor at all.
The second was the decision table and test generation for the new design. The request looked roughly like this:
I am sharing ExitFlowDispatcher and the five gate implementations.I want to freeze the gate evaluation order as a specification.Generate a JUnit 5 parameterized test that covers(state combination x name of the consuming gate) exhaustively.States: isAdFree / isModalShowing / reviewEligible / capReached / adLoadedOutput the decision table you derive the expectations from first.
The third was the AdFreeStatus reference consolidation described above.
Just as important were the two proposals I turned down. Claude Code initially suggested an event-bus style mechanism for gates to coordinate through, and separately suggested merging the frequency-cap gate into the interstitial gate. The first is plainly more machinery than an app this size needs; I prefer not to add a dependency for a problem a for-loop solves. The second would have quietly re-married "judging the interval" to "performing the show" — the exact coupling the refactor existed to remove. I explained the reasoning and asked it to keep the gates independent, which it did. Keeping final design judgment in human hands, and articulating why an alternative loses, matters more when pairing with an AI, not less.
Unit Tests That Freeze Priority as a Specification
Here is the cleaned-up version of the generated test. The decision table became the test data verbatim.
Beyond these six representative rows, a generated exhaustive suite walks all 32 combinations. On the first run, 2 of the 32 failed — and neither failure was a bug in the new implementation. Both were rows where the expected value had inherited the old code's unintended behavior. Sitting with the decision table and settling, row by row, what each combination should actually do felt less like writing tests and more like excavating the specification that was never written down.
The practical payoff: changing priority is now a one-line reorder in the gate list plus the matching rows in the table. When I later wanted to test "review prompt below the frequency cap," the diff was exactly that — and nothing else moved.
Migration Pitfalls
The migration had more traps than the implementation, so here they are in the order I hit them.
Ad-load timing during evaluation: if adLoaded flips mid-evaluation, results wobble. ExitContext is built as a snapshot at evaluation start, so one evaluation sees one consistent world.
Where the frequency cap lives: it started in memory, so process death reset the counter. It now persists in DataStore, and the increment happens only in the ad's on-shown callback. Counting "times we actually showed" rather than "times we tried" turned out to be the operationally correct definition.
Collisions with the review prompt: before the gate era, the exit ad and the review prompt could appear in the same frame. The modal-collision gate is the structural fix; the dialog-collision problem itself is written up separately in the ModalGate implementation memo for Android dialog collisions.
Predictive back: as above, evaluation must stay side-effect free. If someone later adds a side effect to evaluate, the preview gesture alone will mutate state — a bug that will look supernatural in reports.
Unlogged pass-throughs: adding one line to record the silent path is what turned "the ad never shows on my device" tickets into two-minute lookups. The log line is as valuable as the gate design itself.
Since the rollout completed, exit-ad bug reports stand at zero. AdMob exit-interstitial impressions have settled into the range the frequency cap was designed to produce. The biggest day-to-day difference is investigation time: GateLog records which gate consumed each back-press, so "why did this user see no ad?" is answered by reading one log line rather than rebuilding a repro environment, which used to consume half a day.
The honest revenue answer is "still measuring." Closing the over-firing path trimmed short-term impressions slightly. After running AdMob in production for close to a decade, I expect fewer ad complaints in reviews to be worth more than those impressions over the long run — app-business compounding tends to favor trust.
Your Next Step: Count Your Gates Before Writing Any Code
If your own exit flow has become the code nobody wants to touch, do one thing before refactoring: write down, as a plain list, every question your back button currently answers. Mine had five. If you find three or more, the nesting almost certainly hides paths you cannot explain. That list of questions becomes your gate list, the gate list becomes your priority spec, and only then is it worth opening the editor.
If you are facing the same structural trap, I hope this record saves you some of the detours it cost me.
Share
Thank You for Reading
Claude 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.