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.
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
Post a Comment