●VERSION — v2.1.227 landed on August 10 with no new features, just fixes around plan detection and CI behavior●AUTO — Two days remain until August 14, when auto mode becomes the default in Claude Code for Pro, Max, and Team●CI — Bash commands no longer fail across the board under claude-code-action with allowed_non_write_users on GitHub-hosted runners●BILLING — Sessions started with an expired token could misread your plan and nudge Max users toward usage credits; that is now fixed●SUNSET — The legacy Workbench and the experimental prompt tool APIs retire on August 17, five days out●PRICE — Sonnet 5 promo pricing at $2/$10 per Mtok runs through August 31, moving to $3/$15 on September 1●VERSION — v2.1.227 landed on August 10 with no new features, just fixes around plan detection and CI behavior●AUTO — Two days remain until August 14, when auto mode becomes the default in Claude Code for Pro, Max, and Team●CI — Bash commands no longer fail across the board under claude-code-action with allowed_non_write_users on GitHub-hosted runners●BILLING — Sessions started with an expired token could misread your plan and nudge Max users toward usage credits; that is now fixed●SUNSET — The legacy Workbench and the experimental prompt tool APIs retire on August 17, five days out●PRICE — Sonnet 5 promo pricing at $2/$10 per Mtok runs through August 31, moving to $3/$15 on September 1
When Paying Users Still See Ads: Collapsing the Ad-Free Check Into One Place
A user who paid to remove ads still sees them. The cause is rarely a missing flag — it is a check that lives in too many places. Here is how I inventoried every ad call with Claude Code and folded the decision into a single gate.
There is no message I dread more than one from someone who paid to remove ads and still sees them.
A crash I can fix and move on from. But when someone pays and the thing they paid for does not happen, refunding the money does not undo the impression it left. As an indie developer running wallpaper apps for years, this is the single failure mode I have spent the most care on avoiding.
And almost every time I have chased one of these reports, the cause was not a missing flag. The flag existed. One place in the app simply never asked about it.
Ads disappear through more than one door
My apps never had a single path to an ad-free state.
Path
Owner of the state
Lifetime
How it ends
Ad-removal purchase
BillingManager (Google Play Billing)
Permanent
Refund, account change
Rewarded video
AdFreeManager (stored locally)
Fixed window
Time passes
Purchase restore
BillingManager (queried at launch)
Permanent
Unknown until the query returns
These three differ in lifetime and, more importantly, in when they become knowable. A purchase is not known during the first moments after launch. A reward is a clock problem. A restore turns true asynchronously, after the screen has already drawn.
Yet at the call site, all three collapse into a condition that looks the same.
// The early version. Each screen grew its own slightly different condition.if (!billingManager.isAdFree) { interstitialAd.show(this)}
Then rewarded video arrives, and the line grows:
if (!billingManager.isAdFree && !adFreeManager.isRewardAdFree) { interstitialAd.show(this)}
Adding one clause looks like a correct, local fix. The real problem is that nobody knows how many copies of that line exist. Gallery, detail, category, the return from settings, the exit flow behind the back button. Miss one, and that screen keeps the old rule forever.
Search for the ad calls, not for the flag
When I started the inventory, I grepped for isAdFree. That finds only half of what matters, because the places you forgot do not contain isAdFree at all. The right starting point is the other side: everywhere the app can show an ad.
Claude Code handles this inventory well, as long as you pin the search to the SDK side rather than to the condition.
claude -p 'List every call path that can result in an ad being displayed.Start from the call sites below, not from any condition:- InterstitialAd.show / RewardedAd.show / RewardedInterstitialAd.show- AdView.loadAd / AdLoader.loadAd- AdView declared directly in layout XMLFor each hit, give me three columns:1) file and line number2) the ad-suppression check immediately guarding it (write "none" if absent)3) which state that check reads (billing / reward / both / none)Do not infer intent. Report only what exists in the code.'
The third column is what makes this useful. If you stop at "guarded / unguarded", the sites that read only one of the two states pass inspection. In my case the gaps were not unguarded calls — they were calls that checked the purchase but not the reward, plus a banner placed directly in a layout XML. No amount of grepping Kotlin will ever surface that XML banner.
Splitting the results into "none" and "partial" also orders the work for you. I worked through mine in this order:
Close the "none" rows first — these are the ones showing ads to people who paid
Fix the "partial" rows next — these show ads to people who watched a rewarded video
Sweep the layout XML banners by eye, since no Kotlin grep will ever list them
My apps run on AdMob, where banners, interstitials and rewarded units coexist on the same screens. With an SDK that separates loading from display, leaving loadAd out of the gate produces a quiet failure mode in production: no ad is shown, but requests keep going out. I recommend routing both the load and the show through the same decision.
✦
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 fold ad-free state that lives in two systems — purchases and rewarded video — into a single decision point
✦You will be able to prevent the one bug that damages trust the most, paying users seeing ads, before it ships rather than after a refund request
✦You will be able to systematically find every place your codebase can still show an ad without asking whether it should
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.
This is where I ended up doing the opposite of what I first planned.
My initial idea was to merge the state: keep one isAdFree boolean somewhere and have both purchases and rewards write into it. It is easy to build and it falls apart quickly. Rewards expire and purchases do not, so a single boolean immediately inherits a re-evaluation problem. Recompute on every navigation? Poll on a timer? And with more writers, you lose track of who last wrote false.
What actually held up was leaving the state with its owners and unifying only the question.
/** * The single place that decides whether an ad may be shown. * State stays with BillingManager / AdFreeManager; this class only * composes the answer for right now. */class AdGate( private val billing: BillingManager, private val rewardAdFree: AdFreeManager,) { /** True when any path — purchase, restore, or reward — suppresses ads. */ fun isAdFree(): Boolean = billing.isPurchasedAdFree() || rewardAdFree.isActive() /** * Whether an ad may be shown. Call sites write no other condition. * The reason is returned so suppressions are traceable in logs. */ fun evaluate(): AdDecision = when { billing.isPurchasedAdFree() -> AdDecision.Suppress(Reason.PURCHASED) rewardAdFree.isActive() -> AdDecision.Suppress(Reason.REWARD) !billing.isRestoreSettled() -> AdDecision.Suppress(Reason.RESTORE_PENDING) else -> AdDecision.Allow }}sealed interface AdDecision { data object Allow : AdDecision data class Suppress(val reason: Reason) : AdDecision}enum class Reason { PURCHASED, REWARD, RESTORE_PENDING }
Call sites become:
when (adGate.evaluate()) { is AdDecision.Allow -> interstitialAd.show(this) is AdDecision.Suppress -> Unit // do nothing}
Putting RESTORE_PENDING on the suppress side is the part that earns its keep in production. The Play Billing query has not returned during the first seconds after launch. Treating "unknown" as "go ahead and show" produces a bug that is hard to reproduce and perfectly visible to the person affected: a purchaser sees ads for a few seconds every time they open the app. Losing one impression costs far less than showing an ad to someone who paid not to see one.
The first pitfall I hit in production was holding this temporary window against the device clock alone. The obvious implementation looks like this.
// Obvious, but survives a user rolling the device clock backwardsfun grant(durationMillis: Long) { prefs.edit().putLong(KEY_EXPIRES_AT, System.currentTimeMillis() + durationMillis).apply()}fun isActive(): Boolean = prefs.getLong(KEY_EXPIRES_AT, 0L) > System.currentTimeMillis()
System.currentTimeMillis() is user-editable in Settings. Move the clock back and the stored expiry effectively extends forever. SystemClock.elapsedRealtime() cannot be tampered with, but it resets on reboot, so it cannot carry a window across a restart on its own.
Neither works alone. To avoid a rolled-back clock while still surviving a reboot, I store both and take whichever expires sooner.
class AdFreeManager(private val prefs: SharedPreferences) { fun grant(durationMillis: Long) { prefs.edit() .putLong(KEY_WALL_EXPIRES_AT, System.currentTimeMillis() + durationMillis) .putLong(KEY_BOOT_EXPIRES_AT, SystemClock.elapsedRealtime() + durationMillis) .putLong(KEY_GRANTED_BOOT_ID, bootId()) .apply() } fun isActive(): Boolean { val wallLeft = prefs.getLong(KEY_WALL_EXPIRES_AT, 0L) - System.currentTimeMillis() // Trust elapsedRealtime only within the boot session that granted it val sameBoot = prefs.getLong(KEY_GRANTED_BOOT_ID, -1L) == bootId() val bootLeft = if (sameBoot) { prefs.getLong(KEY_BOOT_EXPIRES_AT, 0L) - SystemClock.elapsedRealtime() } else { Long.MAX_VALUE // after a reboot, fall back to wall clock only } return minOf(wallLeft, bootLeft) > 0L } /** Boot session identifier: now minus uptime is roughly the boot instant. */ private fun bootId(): Long = (System.currentTimeMillis() - SystemClock.elapsedRealtime()) / 1000L private companion object { const val KEY_WALL_EXPIRES_AT = "ad_free_wall_expires_at" const val KEY_BOOT_EXPIRES_AT = "ad_free_boot_expires_at" const val KEY_GRANTED_BOOT_ID = "ad_free_granted_boot_id" }}
Within one boot session the two clocks cross-check each other; after a reboot the wall clock decides alone. Rolling the clock backwards also shifts the estimated boot instant, so bootId() changes and the uptime branch stops applying. It is not airtight, but it closes the case where changing the date in Settings converts a short reward window into a permanent one.
If you have a backend, I recommend holding the expiry there — this whole section disappears. My wallpaper apps run without one, so this is the compromise that works inside the device. Decide based on your own architecture rather than copying mine.
A pure function turns "should test" into "can test"
The side benefit of AdGate mattered more than I expected: the decision became testable. While the logic lived as conditions scattered across Activities, writing a test for it was not realistic.
class AdGateTest { private class FakeBilling( private val purchased: Boolean, private val settled: Boolean = true, ) : BillingManager { override fun isPurchasedAdFree() = purchased override fun isRestoreSettled() = settled } private class FakeReward(private val active: Boolean) : AdFreeManager { override fun isActive() = active } @Test fun `suppresses while a restore is still unsettled`() { val gate = AdGate(FakeBilling(purchased = false, settled = false), FakeReward(false)) assertEquals(AdDecision.Suppress(Reason.RESTORE_PENDING), gate.evaluate()) } @Test fun `a purchase wins regardless of reward state`() { val gate = AdGate(FakeBilling(purchased = true), FakeReward(false)) assertEquals(AdDecision.Suppress(Reason.PURCHASED), gate.evaluate()) } @Test fun `allows when no path suppresses`() { val gate = AdGate(FakeBilling(purchased = false), FakeReward(false)) assertEquals(AdDecision.Allow, gate.evaluate()) }}
More valuable than the tests themselves was finally being able to count the inputs. Three axes — purchase, reward, restore — give eight combinations, and exactly one of them permits an ad. Back when the conditions were scattered, I had no sense of that "exactly one".
Prevent the next omission structurally
Cleaning this up once does nothing for the screen you add next month. So I moved the guarantee out of human attention and into CI.
#!/usr/bin/env bash# tools/check-ad-gate.sh# Detect ad calls that do not go through AdGateset -euo pipefailVIOLATIONS=$(grep -rnE '\.(show|loadAd)\(' app/src/main/java \ --include='*.kt' \ | grep -vE 'AdGate\.kt' \ | while IFS= read -r hit; do FILE="${hit%%:*}" # does the same file reference the gate at all? grep -q 'adGate' "$FILE" || echo "$hit" done)if [ -n "$VIOLATIONS" ]; then echo "Ad calls not routed through AdGate:" echo "$VIOLATIONS" exit 1fiecho "ad gate check: OK"
It is a coarse, file-level check. But its actual job is to make sure anyone adding an ad to a new screen bumps into AdGate exactly once, and for that it has been enough. I chose discoverability over rigor.
Banners declared in layout XML slip past this grep entirely. I handle those by wrapping the ad view in a single custom view and making "never place AdView directly" the rule, which brings them back onto the same check.
Anything that must not appear simultaneously — dialogs, paywalls, ads — tends to converge on this shape. I landed in the same place in fixing overlapping paywall and review dialogs with a central ModalGate: stop letting each site decide visibility, and give them one place to ask.
What I handed to Claude Code, and what I kept
I delegated the inventory and the first draft of the mechanical replacements. Nothing beyond that.
The delegation paid off on exhaustive enumeration. When a person greps, they look for what they expect to exist and miss what was never written. Starting from the call sites makes the unguarded ones surface on their own.
The decision to put RESTORE_PENDING on the suppress side, though, was not derivable from anything I handed over. That is a judgment about how much a purchaser seeing an ad costs the business. An agent is fast at laying out the options; choosing among them stays with you. That boundary is clearer to me now than it was before this refactor.
One next step
If you run an app with this shape, try just one thing. List every show and loadAd call, and next to each write whether the guard in front of it reads both the purchase state and the reward state.
If even one line reads only one of them, that is where someone is being shown an ad right now. For me, that single column was reason enough to start.
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.