Where Is the Source of Truth for Billing State? Designing the ad-free Pattern with Antigravity
A proven pattern from a 50 million download app: centralizing billing state with AdFreeManager, BillingManager, and ModalGate — designed and implemented using Antigravity IDE.
"I purchased the ad removal but ads are still showing."
In April 2026, this review appeared on Beautiful HD Wallpapers (Android — over 50 million cumulative downloads). The purchase flow was working correctly. The transaction was recorded in Google Play Billing. Yet on certain devices, for certain users, ads were still appearing after a confirmed purchase.
Tracing through the code revealed the root cause: billing state was being evaluated in five separate places across the codebase. Each location updated independently, and during app restarts or session transitions, temporary inconsistencies were causing the bug.
This was one crystallization of technical debt accumulated since I began developing apps in 2013. And it was exactly the kind of problem that gets harder to fix the longer you wait.
Working through this with Antigravity led me to a design pattern built around three components — AdFreeManager, BillingManager, and ModalGate — unified by the concept of a single Source of Truth. This article covers the design reasoning and the implementation details.
Why Billing State Gets Scattered
When you run a personal app for years, billing state checks tend to grow organically. The pattern is predictable. You start with a simple boolean flag in SharedPreferences, read from an Activity. Then you introduce rewarded ads, and now there's a temporary session state that lives only in memory. Later you add purchase restoration logic, creating another code path. Each new Fragment gets its own copy of the billing check, written slightly differently.
By the time the bug appears, you have a collection of "distributed truths" — each one individually correct, but inconsistent with each other during window edges like app restarts or asynchronous Billing Library connection delays.
When I described this situation to Antigravity, its first response wasn't to generate code. It asked a design question: "Let's identify a single reliable location for billing state across the entire app. That's the Source of Truth. Which data source do you think is most authoritative?"
That question became the starting point.
The Source of Truth Principle
In software design, a "Source of Truth" means having one canonical place for a given piece of data — a location you can trust unconditionally to hold the correct value. When multiple locations hold the same data and update independently, inconsistencies are inevitable. The design challenge isn't preventing bugs — it's making them impossible by construction.
For billing state, the candidates are clear. Google Play Billing API responses are authoritative because they reflect what Google's servers know about the user's purchase history. SharedPreferences are a local cache — fast but potentially stale. In-memory variables work for transient state like rewarded ad sessions but disappear on restart.
Antigravity's recommendation was to use Google Play Billing as the Source of Truth and route all state evaluation through an AdFreeManager class. The reasoning is straightforward: the purchase happened on Google's infrastructure. Every other representation is a derived copy. When copies disagree, the original wins.
By centralizing evaluation in one class, the design guarantees that wherever you check billing state in the app, you get the same answer. Logic changes live in one place. Bug investigations start in one place.
✦
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
✦Solve the scattered billing state problem once and for all with the Source of Truth pattern — no more ad-display bugs after purchases
✦Implement the AdFreeManager + BillingManager + ModalGate three-layer architecture to centralize ad and paywall control
✦Learn the architectural thinking process behind Antigravity-assisted design and apply it to your own app's structural challenges
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.
AdFreeManager becomes the sole origin of billing state in the app. Its design reflects a deliberate choice: callers don't need to know whether the user is ad-free because they purchased the product or because they watched a rewarded ad. That distinction is encapsulated inside.
class AdFreeManager private constructor( private val billingManager: BillingManager, private val rewardManager: RewardedAdManager) { val isAdFree: Boolean get() = billingManager.isPurchased() || rewardManager.isAdFreeSession val isPermanentlyAdFree: Boolean get() = billingManager.isPurchased() val isSessionAdFree: Boolean get() = rewardManager.isAdFreeSession && !billingManager.isPurchased() companion object { @Volatile private var instance: AdFreeManager? = null fun getInstance( billingManager: BillingManager, rewardManager: RewardedAdManager ): AdFreeManager = instance ?: synchronized(this) { instance ?: AdFreeManager(billingManager, rewardManager).also { instance = it } } }}
The singleton pattern guarantees consistent state across the entire app. @Volatile combined with synchronized prevents race conditions during instance creation in a multithreaded environment — the double-checked locking pattern, properly implemented in Kotlin.
Exposing three properties rather than one is intentional. Most locations should use isAdFree. But some features want to distinguish between permanent and session-only access — for example, showing a "thank you for your purchase" message only to permanent subscribers, or tracking rewarded ad engagement separately. The three-property design makes those distinctions explicit without pushing the logic back into callers.
BillingManager: Bridging Google Play Billing
BillingManager wraps Google Play Billing Library and handles the asynchronous reality of purchase verification. The design decision that matters most here is using StateFlow for purchase state.
StateFlow is a Kotlin Coroutines construct: it holds a value and automatically notifies collectors when that value changes. For purchase state, StateFlow fits naturally because purchase events — completion, restoration, revocation — arrive asynchronously. A StateFlow-based design means the UI reacts to those events automatically without polling or manual refreshes. Configuration changes (screen rotation) trigger automatic re-emission of the current value, so UI state is never lost.
class BillingManager(private val context: Context) { private var billingClient: BillingClient? = null private val _isPurchasedFlow = MutableStateFlow(false) val isPurchasedFlow: StateFlow<Boolean> = _isPurchasedFlow.asStateFlow() init { setupBillingClient() } private fun setupBillingClient() { billingClient = BillingClient.newBuilder(context) .setListener { billingResult, purchases -> if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { handlePurchases(purchases) } } .enablePendingPurchases( PendingPurchasesParams.newBuilder().enableOneTimeProducts().build() ) .build() connectAndVerify() } private fun connectAndVerify() { billingClient?.startConnection(object : BillingClientStateListener { override fun onBillingSetupFinished(billingResult: BillingResult) { if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { queryExistingPurchases() } } override fun onBillingServiceDisconnected() { } }) } private fun queryExistingPurchases() { val params = QueryPurchasesParams.newBuilder() .setProductType(BillingClient.ProductType.INAPP) .build() billingClient?.queryPurchasesAsync(params) { billingResult, purchases -> if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { handlePurchases(purchases) } } } private fun handlePurchases(purchases: List<Purchase>?) { val hasPurchased = purchases?.any { purchase -> purchase.products.contains(PRODUCT_ID_AD_FREE) && purchase.purchaseState == Purchase.PurchaseState.PURCHASED } ?: false _isPurchasedFlow.value = hasPurchased } fun isPurchased(): Boolean = _isPurchasedFlow.value companion object { const val PRODUCT_ID_AD_FREE = "remove_ads" }}
Notice that handlePurchases is called in two places: through the setListener callback when a purchase completes, and through queryExistingPurchases at connection setup. This ensures the state is accurate both on app launch (for existing purchasers) and immediately after a new purchase completes.
ModalGate: Preventing Dialog Conflicts
The Source of Truth principle extends naturally to dialog management. Beautiful HD Wallpapers has three dialogs that can potentially surface simultaneously:
PaywallDialog: Ad removal upsell
ReviewInductionDialog: In-app review prompt
RewardedInterstitialDialog: Rewarded ad explanation and consent
When these compete, user experience breaks down in ways that are difficult to reproduce but easy to complain about in reviews. A user trying to dismiss the paywall gets the review prompt instead. A user about to watch a rewarded ad gets interrupted by another dialog. These are real scenarios that generated real user complaints.
The underlying problem is that each dialog has no visibility into whether another is already showing. The solution is a centralized gatekeeper — a singleton through which all dialogs attempt to acquire display rights.
object ModalGate { private val activeModal = AtomicReference<String?>(null) fun tryShow(tag: String): Boolean { return activeModal.compareAndSet(null, tag) } fun dismiss(tag: String) { activeModal.compareAndSet(tag, null) } fun isShowing(): Boolean = activeModal.get() != null fun currentTag(): String? = activeModal.get()}
AtomicReference.compareAndSet(expected, update) swaps the value only when it currently matches expected, returning true on success. This is a lock-free compare-and-swap: thread-safe by construction, no risk of deadlocks. The protocol is simple — try to acquire, show on success, dismiss gracefully on failure.
class PaywallDialog : DialogFragment() { override fun onStart() { super.onStart() if (!ModalGate.tryShow(TAG)) { dismissAllowingStateLoss() } } override fun onDismiss(dialog: DialogInterface) { super.onDismiss(dialog) ModalGate.dismiss(TAG) } override fun onDestroyView() { super.onDestroyView() ModalGate.dismiss(TAG) } companion object { const val TAG = "PaywallDialog" }}
The onDestroyView dismiss is a safety net for cases where onDismiss is never called — when the app is killed in the background due to memory pressure, for example. The double-dismiss is safe because compareAndSet is idempotent when the value doesn't match.
Three Traps I Walked Into
This design was not born clean. Here are the problems I encountered during implementation.
Trap 1: BillingClient connection timing
BillingClient connects asynchronously. Calling isPurchased() immediately on app launch returns false before connection is established — meaning a legitimate purchaser sees ads for a brief moment at startup. This is surprising and erodes trust.
The fix is to use StateFlow reactively rather than polling. During SplashScreen, initiate the BillingClient connection and wait for queryExistingPurchases to complete before transitioning to the home screen. The added SplashScreen time is a worthwhile tradeoff for correctly hiding ads on launch for every purchaser.
Trap 2: Stale ModalGate state after process death
When an app returns to the foreground after being killed in the background, ModalGate.activeModal can retain a stale tag if the dialog was destroyed without calling onDismiss. The result: subsequent tryShow calls all return false, permanently preventing any dialog from appearing.
The fix: call ModalGate.dismiss(TAG) in both onDismiss and onDestroyView. The compareAndSet semantics make the duplicate call safe — if the tag doesn't match (because another dialog is active), nothing changes.
Trap 3: Singleton state pollution in unit tests
As a Kotlin object, ModalGate is a process-lifetime singleton. Test cases sharing the same singleton instance interfere with each other. A test that acquires the gate but doesn't release it causes all subsequent tests' tryShow calls to fail.
The fix: add a @VisibleForTesting fun reset() method to ModalGate that clears activeModal, and call it in each test's @Before. The annotation documents the intent without restricting access.
Using Antigravity as an Architecture Partner
The designs above came through conversation with Antigravity. I want to be specific about what that means, because "talk to the AI" is vague advice that often produces shallow results.
The interactions that generated useful output looked like this. "Both BillingManager and SharedPreferences hold purchase state. Which should be the Source of Truth? Give me the reasoning, not just the answer." Or: "I want to prevent competing dialogs. Show me two approaches — one with AtomicReference and one without — and explain the tradeoffs." Or: "Here is my implementation. List the potential failure modes, specifically around threading and Fragment lifecycle."
The pattern is: ask for reasoning, not just code. Ask for alternatives, not just recommendations. Ask for failure modes, not just happy paths. When Antigravity explains why before generating the code, that reasoning becomes part of your architecture vocabulary — not just a copied snippet.
My paternal and maternal grandfathers were both temple carpenters. In our family, there was a saying: "Using your hands is its own form of prayer." I've held onto that idea across 12 years of app development. Even with AI-generated code, I read through each line before committing it. The process of verification is where understanding happens.
UI Integration: Reactive Ad Visibility with StateFlow
Here is how the three components come together in practice.
class MyApplication : Application() { lateinit var billingManager: BillingManager private set lateinit var rewardedAdManager: RewardedAdManager private set lateinit var adFreeManager: AdFreeManager private set override fun onCreate() { super.onCreate() billingManager = BillingManager(this) rewardedAdManager = RewardedAdManager() adFreeManager = AdFreeManager.getInstance(billingManager, rewardedAdManager) }}class HomeFragment : Fragment() { private val app by lazy { requireActivity().application as MyApplication } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewLifecycleOwner.lifecycleScope.launch { app.billingManager.isPurchasedFlow.collect { _ -> updateAdVisibility() } } } private fun updateAdVisibility() { binding.bannerAdView.visibility = if (app.adFreeManager.isAdFree) View.GONE else View.VISIBLE }}
When a purchase completes, isPurchasedFlow updates. Every Fragment collecting it calls updateAdVisibility() automatically. No manual screen refresh. No "please restart the app to apply your purchase." The design eliminates the friction that the bug was creating.
The Long-Term Return on Architectural Investment
Centralized billing state design pays dividends that compound over time. When adding a new subscription tier, the change lives in AdFreeManager.isAdFree. When adding a new ad format, the change lives in RewardedAdManager. When adding a new dialog, it plugs into ModalGate and inherits conflict prevention automatically.
The alternative is continuing to update scattered conditions spread across a codebase that grows more fragile with each feature. After running apps with tens of millions of downloads across more than a decade, I've learned that the problems most likely to damage user trust are the ones that feel intermittent and hard to reproduce — exactly the class of bugs that distributed billing state creates.
If you get more than ten results, a Source of Truth refactor is worth planning. Show Antigravity your existing billing state code and ask for help identifying the canonical source. Have the conversation about architecture before asking for code. The design clarity you get from that conversation is more valuable than any individual implementation.
Building this foundation doesn't produce visible features. But it changes what's possible afterward — and how sustainable the development becomes over years.
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.