Skip to main content

How to Monetize an Offline Android App with AdMob and Google Play Billing (Full Guide) Grimory App

Step-by-step guide to monetize an offline Android app with AdMob banners, rewarded ads, and a one-time Google Play Billing premium unlock. Java, no backend.

Android app monetization with AdMob banner and Google Play Billing premium unlock

If you've built an offline Android app and want to monetize it with AdMob and Google Play Billing, you already know the documentation is fragmented. This guide walks through the exact implementation of banner ads, rewarded ads, interstitial ads, and a one-time premium unlock in a real production app — in Java, without a backend, and without compromising the offline-first design. Everything below is tested on the Play Store, including the mistakes.

AdMob Android Quick Start

Google Play Billing Library

app-ads.txt specification

If you've built an Android app that works completely offline — no backend, no accounts, no cloud — you might think monetization is straightforward. It isn't. AdMob and Google Play Billing both assume a level of network and account infrastructure that offline apps don't have. This guide walks through the implementation of both in a real production app, in Java, with the same code structure I use in every project.

It's not theoretical. Everything below has been tested on the Play Store, including the mistakes.

Why offline apps still need ads

An offline app can still show ads. The only thing that requires the network is fetching the ad itself — your app's core functionality stays local. This means:

- You need the INTERNET permission, but the user never feels the app going online.

- Ads load asynchronously and are cached by the SDK.

- If the network is unavailable, banners simply don't load. Everything else works.

The trick is to design the ad layer so that ad failure is invisible to the user. An empty banner container is fine. A crashing app is not.

Step 1 — Centralize everything that will change

Before touching AdMob or Billing, decide where the sensitive IDs will live. They should not be scattered across activities.

Put AdMob IDs in a single AdIds.java:

public final class AdIds {

    private AdIds() {}

    public static final String APP_ID       = "ca-app-pub-3940256099942544~3347511713";

    public static final String BANNER       = "ca-app-pub-3940256099942544/6300978111";

    public static final String INTERSTITIAL = "ca-app-pub-3940256099942544/1033173712";

    public static final String REWARDED     = "ca-app-pub-3940256099942544/5224354917";

}

Those are the universal Google test IDs. Use them for all development. Real ads in a debug build are a policy violation and a common cause of AdMob account suspension.

For production, don't just edit AdIds.java. Use buildConfigField so debug and release builds automatically use different values:

android {

    defaultConfig {

        buildConfigField "String", "ADMOB_BANNER", "\"ca-app-pub-3940256099942544/6300978111\""

    }

    buildTypes {

        release {

            buildConfigField "String", "ADMOB_BANNER", "\"ca-app-pub-XXXXXXXX/YYYYYYYY\""

        }

    }

}

This way, you can't accidentally ship test IDs to production — or production IDs to your test device.

Step 2 — The AdMob App ID and the manifest

The AdMob SDK requires the App ID to be declared in AndroidManifest.xml. Without it, the app crashes at startup.

<meta-data

    android:name="com.google.android.gms.ads.APPLICATION_ID"

    android:value="${admobAppId}" />

And add the two permissions:

<uses-permission android:name="android.permission.INTERNET" />

<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

They don't make your app "online." They only let the SDK fetch ads.

Step 3 — Wrap AdMob in a manager

The biggest architectural mistake I see is calling the AdMob SDK directly from activities. This leaks the SDK's lifecycle into every screen and makes it impossible to disable ads for premium users.

Instead, create a single AdMobManager with three encapsulated controllers: BannerController, InterstitialController, and RewardedController. Each one manages its own lifecycle and can be attached to any activity.

The BannerController deserves special attention because banners are the easiest ad format to get wrong.

Step 4 — The banner rule that improves retention

Loading a banner immediately on screen open is bad UX. It adds a visual pop, competes with content, and users who are actively interacting with your app will accidentally tap it. Accidental taps are the leading cause of AdMob policy violations.

The fix is to show the banner only after 10 seconds of user inactivity. During active use, keep it hidden.

private final Runnable showBannerRunnable = () -> {

    if (activity.isFinishing() || activity.isDestroyed()) return;

    if (adView == null) return;

    adView.setVisibility(View.VISIBLE);

    visible = true;

};


public void scheduleShow() {

    handler.removeCallbacks(showBannerRunnable);

    handler.postDelayed(showBannerRunnable, 10_000L);

}


public void onUserInteraction() {

    handler.removeCallbacks(showBannerRunnable);

    if (adView != null && visible) {

        adView.setVisibility(View.GONE);

        visible = false;

    }

}

Two rules:

1. Call scheduleShow() in onResume so the timer restarts every time the user comes back to the screen.

2. Call onUserInteraction() in onPause and in every input listener — typing, scrolling, opening the spinner.

And the cleanup that everyone forgets:

public void destroy() {

    handler.removeCallbacksAndMessages(null);

    if (adView != null) {

        adView.destroy();

        adView = null;

    }

    container.removeAllViews();

}

Missing destroy() produces a memory leak that's invisible in testing and kills battery in production.

Step 5 — Rewarded ads are worth the effort

Rewarded ads have the highest eCPM of any format. The trade-off is that the user has to choose to watch. That's not a downside — it's the point. A rewarded ad is a transaction: 30 seconds of the user's attention for something they want.

In our app, users watch a short ad to unlock visual skins. The controller handles the flow:

public void showIfReady(Activity activity,

                        OnRewarded onRewarded,

                        OnRewardFailed onFailed) {

    if (ad == null) {

        onFailed.onRewardFailed("not_ready");

        return;

    }

    final boolean[] earned = {false};

    ad.setFullScreenContentCallback(new FullScreenContentCallback() {

        @Override

        public void onAdDismissedFullScreenContent() {

            ad = null;

            preload(activity.getApplicationContext());

            if (!earned[0]) {

                onFailed.onRewardFailed("dismissed_early");

            }

        }

    });

    ad.show(activity, rewardItem -> {

        earned[0] = true;

        onRewarded.onRewarded();

    });

}

The boolean[] earned array is the trick. The reward callback fires before the dismiss callback, so we need a way to tell them apart. If the user closes the ad early, onUserEarnedReward never fires, and onRewardFailed handles it.

Always re-preload after showing. Rewarded ads are one-shot; you must load a new one for the next session.

Step 6 — Google Play Billing for a one-time premium unlock

Billing is where most indie developers give up. The API is verbose, the Play Console setup has hidden requirements, and testing is confusing.

Here's the flow, in the order it must happen:

1. Connect to the billing service.

2. Query active purchases — the user may have already bought.

3. Query product details — needed to launch the purchase flow.

4. Launch the purchase when the user taps the button.

5. Acknowledge the purchase within 3 days, or Google refunds it automatically.

The acknowledge step is the one that bites. Every purchase that isn't acknowledged gets auto-refunded and the user loses access:

private void acknowledgeIfNeeded(Purchase purchase) {

    if (purchase.getPurchaseState() != Purchase.PurchaseState.PURCHASED) return;

    if (purchase.isAcknowledged()) return;


    AcknowledgePurchaseParams params = AcknowledgePurchaseParams.newBuilder()

            .setPurchaseToken(purchase.getPurchaseToken())

            .build();


    billingClient.acknowledgePurchase(params, billingResult -> {

        // log only; nothing else to do

    });

}

Then, always offer a "Restore purchases" button. The user may have bought on another device, or reinstalled the app and lost local state. queryPurchasesAsync retrieves the truth from the Play Store.

Step 7 — Hide ads from premium users

Premium users paid to remove ads. Do not just hide the banner — do not initialize the SDK at all:

if (!PremiumManager.isPremium(this)) {

    AdMobManager.initSdk(getApplicationContext());

    bannerController = new AdMobManager.BannerController(this, binding.bannerContainer);

    bannerController.load();

}

PremiumManager reads a SharedPreferences boolean. It's the synchronous local cache of a state whose real source of truth is the Play Store. On every app launch, BillingManager.queryActivePurchases() refreshes it in the background.

The Play Console setup that nobody explains

Here are the requirements that live outside the code:

You need a merchant profile. Configure it in Play Console → Settings → Payments before anything else. This is a one-time setup and cannot be changed later without creating a new developer account.

The app must be published before you can create the product. Google requires at least one release in any track (internal, closed, or production) before the "Monetize with Play" menu unlocks. This is the single most common blocker for developers trying to set up Billing for the first time.

Add yourself as a License Tester. Play Console → Settings → License testing. Without this, you cannot test purchases without real money.

One-time product ID cannot be changed. If you publish premium_unlock, that's the ID forever. Pick wisely.

The product must be Active. A draft product is invisible to the Billing SDK.

app-ads.txt is mandatory since 2025

If you want AdMob to serve ads reliably, you must publish a file at the root domain of the developer website listed in your Play Store listing:

https://yourdomain.com/app-ads.txt

The file contains exactly one line:

google.com, pub-XXXXXXXXXXXXXXXX, DIRECT, f08c47fec0942fa0

Replace pub-XXXXXXXX with your AdMob publisher ID. Without this file, AdMob marks your app as unverified and throttles delivery. It's not obvious from the Play Console UI, and a lot of developers discover it months into production.

The bug I introduced and how I found it

In our DetailActivity, we have a save() method that writes a character to the database. During one iteration, we added a feature to support a custom "Other" type, and the patch introduced a duplicated line:

current.setType(finalType);

current.setType(binding.spinnerType.getSelectedItem().toString()); // overwrites

The second line silently overwrote the first with the wrong value. Everything compiled. Everything ran. But the field never saved correctly, and the bug only showed up when reopening a saved character.

The lesson: when a change adds a feature, verify that the change didn't remove existing behavior. Adding a new line is easy to see. Removing an old line by accident is not.

Five things I'd tell my past self

1. Never test with real ad IDs. One accidental tap during development can trigger a policy violation that suspends your AdMob account.

2. Acknowledge every purchase. Missing this auto-refunds the purchase after three days.

3. Publish a test track first. Billing doesn't work without a published release. Internal testing is enough.

4. Centralize IDs in BuildConfig. One edit to change test → production beats a project-wide search.

5. Publish app-ads.txt on day one. AdMob won't serve ads reliably without it.

Wrapping up

Monetization in an offline Android app is entirely doable. It just requires treating the ad layer and the billing layer as separate concerns, wrapped in their own classes, with clear lifecycles attached to activities.

The complete project — including all the code above and the layout files, managers, and Play Console configuration — is available in the Grimory app on Google Play. It's a fully offline tabletop RPG companion: character sheets, dice roller, initiative tracker, and premium unlock.

If you're building something similar and get stuck on a specific step, feel free to reach out.

In the meantime...

I develop small Android apps as a solo developer. I write about Android, monetization, and *offline-first* design at montchristo.com.

A warm welcome to you!


Comments

Related Posts

Most viewed topics

From Zero to AdSense: A Complete Guide to Monetizing Your Website

Google AdSense is one of the most popular ways to monetize a website, allowing you to display relevant ads to your visitors and earn money from it. However, to be approved by AdSense and keep your account active, you need to follow some guidelines and best practices. This complete guide will teach you the step-by-step process to create and maintain a website that meets the AdSense requirements. 1. Planning and Creating the Website 1.1 Choose a Profitable Niche Niche research: Identify a niche market with high demand and low competition. Use tools like Google Trends and Keyword Planner to find relevant topics with good search volume. Passion and knowledge: Choose a niche that you are an expert in and that motivates you to create quality content. 1.2 Domain Registration and Hosting Domain name: Choose a short, easy-to-remember domain name that is relevant to your niche. Hosting: Choose a reliable and high-performance hosting service. 1.3 Website Design and Structure Responsive Layout: Us...

Adaptive Refresh Rate Displays: Intelligent Smoothness That Saves Battery

Smartphone displays have come a long way in recent years, and one of the most innovative technologies is adaptive refresh rate. This feature allows the display to automatically adjust the number of times it refreshes per second, offering a smoother user experience while also saving battery. How Do Adaptive Refresh Rate Displays Work? The refresh rate, measured in Hertz (Hz), indicates how many times the display is refreshed per second. The higher the refresh rate, the smoother the transition between images, which is especially important in games and videos. However, higher refresh rates consume more power. Adaptive refresh rate displays solve this problem by dynamically adjusting the refresh rate according to the content displayed. In situations that require more fluidity, such as games and videos, the display operates at a higher refresh rate (for example, 120 Hz). In static situations, such as reading text or browsing the web, the refresh rate is reduced (for example, 60 Hz or less),...

montChristo Website Privacy Policy

Your privacy is important to us. It is montChristo's policy to respect your privacy regarding any information we may collect from you on the montChristo website and other sites we own and operate. We only ask for personal information when we truly need it to provide a service to you. We collect it by fair and lawful means, with your knowledge and consent. We also let you know why we are collecting it and how it will be used. We only retain collected information for as long as necessary to provide the requested service. When we store data, we protect it using commercially acceptable means to prevent loss and theft, as well as unauthorized access, disclosure, copying, use, or modification. We do not share personally identifiable information publicly or with third parties, except when required by law. Our website may link to external sites that are not operated by us. Please be aware that we have no control over the content and practices of these sites and cannot accept responsibility...

Legal Notice

Dear reader, The MontChristo website is a space dedicated to sharing information and opinions. The content presented here is for informational purposes only and does not constitute professional advice of any kind. Accuracy of Information While we strive to ensure the accuracy and currency of the information published, we make no warranties, express or implied, regarding the completeness, reliability, or suitability of the content. The information may be incomplete, outdated, or contain errors. Professional Advice The content of this blog does not replace advice from a qualified professional. We recommend consulting a specialist in your area of ​​interest before making any decisions based on the information presented here. Copyright The content of this blog—including text, images, videos, and other materials—is protected by copyright and may not be reproduced, distributed, or used without the author's express permission. Where third-party content is used, appropriate credit will be ...

Creutzfeldt-Jakob Disease (CJD): A Neurodegenerative Conundrum

Creutzfeldt-Jakob disease (CJD) is a rare and fatal neurodegenerative disease caused by prions, infectious proteins that affect the brain. CJD causes progressive dementia, loss of motor coordination, and eventually death. The variant form of CJD (vCJD), linked to the consumption of beef contaminated with bovine spongiform encephalopathy (BSE), known as "mad cow disease", raised great concern in the 1990s. What are Prions? Prions are infectious proteins that cause neurodegenerative diseases by causing normal brain proteins to fold abnormally. This abnormal folding leads to the formation of protein aggregates that damage brain cells, causing degeneration of brain tissue. Forms of CJD CJD can manifest itself in different ways: Sporadic CJD (aJCJD): The most common form, accounting for about 85% of cases. AJCJD occurs when the normal prion protein spontaneously folds abnormally, with no known cause. Familial CJD (fCJD): An inherited form of the disease, accounting for about...

Terms and Conditions of Use for the montChristo Website

1. Terms By accessing the montChristo website, you agree to be bound by these terms of service, all applicable laws and regulations, and agree that you are responsible for compliance with any applicable local laws. If you do not agree with any of these terms, you are prohibited from using or accessing this site. The materials contained in this site are protected by applicable copyright and trademark law. 2. Use License Permission is granted to temporarily download one copy of the materials (information or software) on the montChristo website for personal, non-commercial transitory viewing only. This is the grant of a license, not a transfer of title, and under this license you may not: A. modify or copy the materials ; B. use the materials for any commercial purpose, or for any public display (commercial or non-commercial); C. attempt to decompile or reverse engineer any software contained on the montChristo website; D. remove any copyright or other proprietary notations from the ma...

Lassa Fever: A Silent Threat in West Africa

Lassa fever is a viral hemorrhagic fever endemic to West Africa, caused by the Lassa virus, an arenavirus transmitted by infected rodents. Although less well-known than other viral hemorrhagic fevers such as Ebola, Lassa fever poses a significant public health threat in the region, with the potential to cause severe outbreaks and impact entire communities. Transmission and Reservoir: The Rodent as Vector Lassa virus is transmitted to humans primarily through contact with urine or feces of infected rodents, especially the multimammal rat Mastomys natalensis. Transmission can occur through ingestion of contaminated food, inhalation of aerosols, or direct contact with wounds or mucous membranes. Human-to-human transmission is also possible through contact with bodily fluids of infected patients. Symptoms and Disease Progression: A Variable Clinical Picture Symptoms of Lassa fever usually appear 1 to 3 weeks after exposure to the virus and can range from mild to severe. Initial symptoms ar...